mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-02-24 00:53:49 +00:00
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import { memo, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||
import { Box, useTexture } from "@react-three/drei";
|
||
import { useFrame, useThree } from "@react-three/fiber";
|
||
import { DoubleSide, NoColorSpace, PlaneGeometry, RepeatWrapping } from "three";
|
||
import { textureToUrl } from "../loaders";
|
||
import type { TorqueObject } from "../torqueScript";
|
||
import {
|
||
getFloat,
|
||
getPosition,
|
||
getProperty,
|
||
getRotation,
|
||
getScale,
|
||
} from "../mission";
|
||
import { setupTexture } from "../textureUtils";
|
||
import { createWaterMaterial } from "../waterMaterial";
|
||
import { useDebug, useSettings } from "./SettingsProvider";
|
||
import { usePositionTracker } from "./usePositionTracker";
|
||
|
||
const REP_SIZE = 2048;
|
||
|
||
/**
|
||
* Terrain coordinate offset from world space.
|
||
* In Tribes 2, terrain coordinates are offset by +1024 from world coordinates.
|
||
* This allows world positions like (-672, -736) to map to valid terrain
|
||
* positions (352, 288) after the offset.
|
||
*/
|
||
const TERRAIN_OFFSET = 1024;
|
||
|
||
/**
|
||
* Calculate tessellation to match Tribes 2 engine.
|
||
*
|
||
* The engine uses two modes based on water size:
|
||
* - High-res mode (size <= 1024): 32-unit blocks with 5x5 vertices = 8 units between verts
|
||
* - Normal mode (size > 1024): 64-unit blocks with 5x5 vertices = 16 units between verts
|
||
*
|
||
* Each block has 4 segments (5 vertices across), creating 32 triangles per block.
|
||
*/
|
||
function calculateWaterSegments(
|
||
sizeX: number,
|
||
sizeZ: number,
|
||
): [number, number] {
|
||
// High-res mode threshold: 1024 world units (128 terrain squares × 8 units)
|
||
const isHighRes = sizeX <= 1024 && sizeZ <= 1024;
|
||
|
||
// Vertex spacing: 8 units for high-res, 16 units for normal
|
||
const vertexSpacing = isHighRes ? 8 : 16;
|
||
|
||
// Calculate segments (vertices - 1)
|
||
const segmentsX = Math.max(4, Math.ceil(sizeX / vertexSpacing));
|
||
const segmentsZ = Math.max(4, Math.ceil(sizeZ / vertexSpacing));
|
||
|
||
return [segmentsX, segmentsZ];
|
||
}
|
||
|
||
/**
|
||
* Simple fallback material for non-top faces and loading state.
|
||
*/
|
||
export function WaterMaterial({
|
||
surfaceTexture,
|
||
attach,
|
||
}: {
|
||
surfaceTexture: string;
|
||
attach?: string;
|
||
}) {
|
||
const url = textureToUrl(surfaceTexture);
|
||
const texture = useTexture(url, (texture) => setupTexture(texture));
|
||
|
||
return (
|
||
<meshStandardMaterial
|
||
attach={attach}
|
||
map={texture}
|
||
transparent
|
||
opacity={0.8}
|
||
side={DoubleSide}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* WaterBlock component that renders water with Tribes 2-accurate animation.
|
||
*
|
||
* The water surface uses a custom shader that replicates the original Torque
|
||
* engine's multi-pass rendering:
|
||
* - Dual cross-faded base textures with 30° rotation
|
||
* - Sinusoidal wave displacement
|
||
* - Environment map reflection with animated UVs
|
||
*
|
||
* Unlike a simple box, we use a subdivided PlaneGeometry for the water surface
|
||
* so that vertex displacement can create visible waves.
|
||
*
|
||
* Water tiling follows Torque's FluidQuadTree.cc behavior:
|
||
* - Determines camera's terrain "rep" via I = (s32)(m_Eye.X / 2048.0f)
|
||
* - Renders 9 reps (3x3 grid) centered on camera's rep
|
||
*/
|
||
export const WaterBlock = memo(function WaterBlock({
|
||
object,
|
||
}: {
|
||
object: TorqueObject;
|
||
}) {
|
||
const { debugMode } = useDebug();
|
||
const q = useMemo(() => getRotation(object), [object]);
|
||
const position = useMemo(() => getPosition(object), [object]);
|
||
const scale = useMemo(() => getScale(object), [object]);
|
||
const [scaleX, scaleY, scaleZ] = scale;
|
||
const camera = useThree((state) => state.camera);
|
||
const hasCameraPositionChanged = usePositionTracker();
|
||
|
||
// Water surface height (top of water volume)
|
||
// TODO: Use this for terrain intersection masking (reject water blocks where
|
||
// terrain height > surfaceZ + waveMagnitude/2). Requires TerrainProvider.
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
// const surfaceZ = position[1] + scaleY;
|
||
|
||
// Wave magnitude affects terrain masking (Torque adds half to surface height)
|
||
const waveMagnitude = getFloat(object, "waveMagnitude") ?? 1.0;
|
||
|
||
// Convert world position to terrain space and snap to grid.
|
||
// Matches Torque's UpdateFluidRegion() and fluid::SetInfo():
|
||
// 1. Add TERRAIN_OFFSET (1024) to convert world -> terrain space
|
||
// 2. Round to nearest terrain square: (s32)((X0 / 8.0f) + 0.5f) = Math.round(x/8)
|
||
// 3. Clamp to valid terrain range [0, 2040] squares
|
||
// 4. Convert back to world units (multiply by 8)
|
||
const basePosition = useMemo(() => {
|
||
const [x, y, z] = position;
|
||
|
||
// Convert to terrain space (add 1024 offset)
|
||
const terrainX = x + TERRAIN_OFFSET;
|
||
const terrainZ = z + TERRAIN_OFFSET;
|
||
|
||
// Round to terrain squares: (s32)((X0 / 8.0f) + 0.5f) is Math.round()
|
||
let squareX = Math.round(terrainX / 8);
|
||
let squareZ = Math.round(terrainZ / 8);
|
||
|
||
// Clamp to valid range [0, 2040] as in fluidSupport.cc
|
||
squareX = Math.max(0, Math.min(2040, squareX));
|
||
squareZ = Math.max(0, Math.min(2040, squareZ));
|
||
|
||
// Convert back to world units (terrain squares * 8)
|
||
// This is the fluid's anchor point in terrain space
|
||
const baseX = squareX * 8;
|
||
const baseZ = squareZ * 8;
|
||
|
||
return [baseX, y, baseZ] as [number, number, number];
|
||
}, [position]);
|
||
|
||
// Calculate 3x3 grid of reps centered on camera position.
|
||
// ALL water blocks use 9-rep tiling - the masking system handles visibility.
|
||
// Matches fluidQuadTree.cc RunQuadTree():
|
||
// I = (s32)(m_Eye.X / 2048.0f);
|
||
// if( m_Eye.X < 0.0f ) I--;
|
||
const calculateReps = (
|
||
camX: number,
|
||
camZ: number,
|
||
): Array<[number, number]> => {
|
||
// Convert camera to terrain space
|
||
const terrainCamX = camX + TERRAIN_OFFSET;
|
||
const terrainCamZ = camZ + TERRAIN_OFFSET;
|
||
|
||
// Determine camera's terrain rep using terrain coordinates.
|
||
// Torque uses: I = (s32)(m_Eye.X / 2048.0f); if (m_Eye.X < 0) I--;
|
||
// This is truncation toward zero, then decrement for negative.
|
||
let cameraRepX = Math.trunc(terrainCamX / REP_SIZE);
|
||
let cameraRepZ = Math.trunc(terrainCamZ / REP_SIZE);
|
||
if (terrainCamX < 0) cameraRepX--;
|
||
if (terrainCamZ < 0) cameraRepZ--;
|
||
|
||
// Build 3x3 grid of reps around camera
|
||
const newReps: Array<[number, number]> = [];
|
||
for (let repZ = cameraRepZ - 1; repZ <= cameraRepZ + 1; repZ++) {
|
||
for (let repX = cameraRepX - 1; repX <= cameraRepX + 1; repX++) {
|
||
newReps.push([repX, repZ]);
|
||
}
|
||
}
|
||
return newReps;
|
||
};
|
||
|
||
// Track which reps to render, updated each frame based on camera position.
|
||
// Initialize with current camera position so water is visible immediately.
|
||
const [reps, setReps] = useState<Array<[number, number]>>(() =>
|
||
calculateReps(camera.position.x, camera.position.z),
|
||
);
|
||
|
||
useFrame(() => {
|
||
if (!hasCameraPositionChanged(camera.position)) {
|
||
return;
|
||
}
|
||
|
||
const newReps = calculateReps(camera.position.x, camera.position.z);
|
||
|
||
// Only update state if reps actually changed (avoid unnecessary re-renders)
|
||
setReps((prevReps) => {
|
||
if (JSON.stringify(prevReps) === JSON.stringify(newReps)) {
|
||
return prevReps;
|
||
}
|
||
return newReps;
|
||
});
|
||
});
|
||
|
||
const surfaceTexture =
|
||
getProperty(object, "surfaceTexture") ?? "liquidTiles/BlueWater";
|
||
const envMapTexture = getProperty(object, "envMapTexture");
|
||
const opacity = getFloat(object, "surfaceOpacity") ?? 0.75;
|
||
const envMapIntensity = getFloat(object, "envMapIntensity") ?? 1.0;
|
||
|
||
// Create subdivided plane geometry for the water surface
|
||
// Tessellation matches Tribes 2 engine (5x5 vertices per block)
|
||
const surfaceGeometry = useMemo(() => {
|
||
const [segmentsX, segmentsZ] = calculateWaterSegments(scaleX, scaleZ);
|
||
|
||
// PlaneGeometry is created in XY plane, we'll rotate it to XZ
|
||
const geom = new PlaneGeometry(scaleX, scaleZ, segmentsX, segmentsZ);
|
||
|
||
// Rotate from XY plane to XZ plane (lying flat)
|
||
geom.rotateX(-Math.PI / 2);
|
||
|
||
// Position at top of water volume (Y = scaleY)
|
||
// Offset by half scale to put corner at origin (position is corner after modulo)
|
||
geom.translate(scaleX / 2, scaleY, scaleZ / 2);
|
||
|
||
return geom;
|
||
}, [scaleX, scaleY, scaleZ]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
surfaceGeometry.dispose();
|
||
};
|
||
}, [surfaceGeometry]);
|
||
|
||
// Render each rep that overlaps with terrain bounds
|
||
return (
|
||
<group quaternion={q}>
|
||
{/* Debug wireframe showing actual water block bounds */}
|
||
{debugMode && (
|
||
<Box
|
||
args={scale}
|
||
position={[
|
||
position[0] + scaleX / 2,
|
||
position[1] + scaleY / 2,
|
||
position[2] + scaleZ / 2,
|
||
]}
|
||
>
|
||
<meshBasicMaterial color="#00fbff" wireframe />
|
||
</Box>
|
||
)}
|
||
<Suspense
|
||
fallback={reps.map(([repX, repZ]) => {
|
||
// Convert from terrain space to world space by subtracting TERRAIN_OFFSET
|
||
// Matches Torque's L2Wm transform: L2Wv = (-1024, -1024, 0)
|
||
const worldX = basePosition[0] + repX * REP_SIZE - TERRAIN_OFFSET;
|
||
const worldZ = basePosition[2] + repZ * REP_SIZE - TERRAIN_OFFSET;
|
||
return (
|
||
<mesh
|
||
key={`${repX},${repZ}`}
|
||
geometry={surfaceGeometry}
|
||
position={[worldX, basePosition[1], worldZ]}
|
||
>
|
||
<meshStandardMaterial
|
||
color="#00fbff"
|
||
transparent
|
||
opacity={0.4}
|
||
wireframe
|
||
side={DoubleSide}
|
||
/>
|
||
</mesh>
|
||
);
|
||
})}
|
||
>
|
||
<WaterReps
|
||
reps={reps}
|
||
basePosition={basePosition}
|
||
surfaceGeometry={surfaceGeometry}
|
||
surfaceTexture={surfaceTexture}
|
||
envMapTexture={envMapTexture}
|
||
opacity={opacity}
|
||
waveMagnitude={waveMagnitude}
|
||
envMapIntensity={envMapIntensity}
|
||
/>
|
||
</Suspense>
|
||
</group>
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Inner component that renders all water reps with a shared material.
|
||
* Separated to allow Suspense boundary around texture loading while
|
||
* ensuring all reps share the same material instance and animation.
|
||
*/
|
||
const WaterReps = memo(function WaterReps({
|
||
reps,
|
||
basePosition,
|
||
surfaceGeometry,
|
||
surfaceTexture,
|
||
envMapTexture,
|
||
opacity,
|
||
waveMagnitude,
|
||
envMapIntensity,
|
||
}: {
|
||
reps: Array<[number, number]>;
|
||
basePosition: [number, number, number];
|
||
surfaceGeometry: PlaneGeometry;
|
||
surfaceTexture: string;
|
||
envMapTexture: string | undefined;
|
||
opacity: number;
|
||
waveMagnitude: number;
|
||
envMapIntensity: number;
|
||
}) {
|
||
const baseUrl = textureToUrl(surfaceTexture);
|
||
const envUrl = textureToUrl(envMapTexture ?? "special/lush_env");
|
||
|
||
const [baseTexture, envTexture] = useTexture(
|
||
[baseUrl, envUrl],
|
||
(textures) => {
|
||
const texArray = Array.isArray(textures) ? textures : [textures];
|
||
texArray.forEach((tex) => {
|
||
setupTexture(tex);
|
||
tex.colorSpace = NoColorSpace;
|
||
tex.wrapS = RepeatWrapping;
|
||
tex.wrapT = RepeatWrapping;
|
||
});
|
||
},
|
||
);
|
||
|
||
const { animationEnabled } = useSettings();
|
||
|
||
// Single shared material for all water reps
|
||
const material = useMemo(() => {
|
||
return createWaterMaterial({
|
||
opacity,
|
||
waveMagnitude,
|
||
envMapIntensity,
|
||
baseTexture,
|
||
envMapTexture: envTexture,
|
||
});
|
||
}, [opacity, waveMagnitude, envMapIntensity, baseTexture, envTexture]);
|
||
|
||
// Single animation loop for the shared material
|
||
const elapsedRef = useRef(0);
|
||
|
||
useFrame((_, delta) => {
|
||
if (!animationEnabled) {
|
||
elapsedRef.current = 0;
|
||
material.uniforms.uTime.value = 0;
|
||
} else {
|
||
elapsedRef.current += delta;
|
||
material.uniforms.uTime.value = elapsedRef.current;
|
||
}
|
||
});
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
material.dispose();
|
||
};
|
||
}, [material]);
|
||
|
||
return (
|
||
<>
|
||
{reps.map(([repX, repZ]) => {
|
||
// Convert from terrain space to world space by subtracting TERRAIN_OFFSET
|
||
// Matches Torque's L2Wm transform: L2Wv = (-1024, -1024, 0)
|
||
const worldX = basePosition[0] + repX * REP_SIZE - TERRAIN_OFFSET;
|
||
const worldZ = basePosition[2] + repZ * REP_SIZE - TERRAIN_OFFSET;
|
||
return (
|
||
<mesh
|
||
key={`${repX},${repZ}`}
|
||
geometry={surfaceGeometry}
|
||
material={material}
|
||
position={[worldX, basePosition[1], worldZ]}
|
||
/>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
});
|