WaterBlock tiling to match T2/Torque, improve CLAUDE.md

This commit is contained in:
Brian Beck 2025-12-11 22:07:29 -08:00
parent bcf4f4a1a5
commit aeda3ca8d5
940 changed files with 1207 additions and 337 deletions

View file

@ -1,13 +1,30 @@
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
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 { getPosition, getProperty, getRotation, getScale } from "../mission";
import {
getFloat,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import { setupColor } from "../textureUtils";
import { createWaterMaterial } from "../waterMaterial";
import { useSettings } from "./SettingsProvider";
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.
@ -35,81 +52,6 @@ function calculateWaterSegments(
return [segmentsX, segmentsZ];
}
/**
* Animated water surface material using Tribes 2-accurate shader.
*
* The Torque V12 engine renders water in multiple passes:
* - Phase 1a/1b: Two cross-faded base texture passes, each rotated 30°
* - Phase 3: Environment/specular map with reflection UVs
* - Phase 4: Fog overlay
*/
export function WaterSurfaceMaterial({
surfaceTexture,
envMapTexture,
opacity = 0.75,
waveMagnitude = 1.0,
envMapIntensity = 1.0,
attach,
}: {
surfaceTexture: string;
envMapTexture?: string;
opacity?: number;
waveMagnitude?: number;
envMapIntensity?: number;
attach?: string;
}) {
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) => {
setupColor(tex);
// Use NoColorSpace for water textures - our custom ShaderMaterial
// outputs values that are already in the correct space for display.
// Using SRGBColorSpace would cause double-conversion.
tex.colorSpace = NoColorSpace;
tex.wrapS = RepeatWrapping;
tex.wrapT = RepeatWrapping;
});
},
);
const { animationEnabled } = useSettings();
const material = useMemo(() => {
return createWaterMaterial({
opacity,
waveMagnitude,
envMapIntensity,
baseTexture,
envMapTexture: envTexture,
});
}, [opacity, waveMagnitude, envMapIntensity, baseTexture, envTexture]);
const elapsedRef = useRef(0);
useFrame((_, delta) => {
if (!animationEnabled) {
elapsedRef.current = 0;
material.uniforms.uTime.value = 0;
return;
}
elapsedRef.current += delta;
material.uniforms.uTime.value = elapsedRef.current;
});
useEffect(() => {
return () => {
material.dispose();
};
}, [material]);
return <primitive object={material} attach={attach} />;
}
/**
* Simple fallback material for non-top faces and loading state.
*/
@ -145,26 +87,117 @@ export function WaterMaterial({
*
* 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 position = useMemo(() => getPosition(object), [object]);
const { debugMode } = useDebug();
const q = useMemo(() => getRotation(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(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 = parseFloat(getProperty(object, "surfaceOpacity") ?? "0.75");
const waveMagnitude = parseFloat(
getProperty(object, "waveMagnitude") ?? "1.0",
);
const envMapIntensity = parseFloat(
getProperty(object, "envMapIntensity") ?? "1.0",
);
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)
@ -177,8 +210,8 @@ export const WaterBlock = memo(function WaterBlock({
// Rotate from XY plane to XZ plane (lying flat)
geom.rotateX(-Math.PI / 2);
// Translate so origin is at corner (matching Torque's water block positioning)
// and position at top of water volume (Y = scaleY)
// 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;
@ -190,30 +223,148 @@ export const WaterBlock = memo(function WaterBlock({
};
}, [surfaceGeometry]);
// Render each rep that overlaps with terrain bounds
return (
<group position={position} quaternion={q}>
{/* Water surface - subdivided plane with wave shader */}
<mesh geometry={surfaceGeometry}>
<Suspense
fallback={
<meshStandardMaterial
color="blue"
transparent
opacity={0.3}
side={DoubleSide}
/>
}
<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,
]}
>
<WaterSurfaceMaterial
attach="material"
surfaceTexture={surfaceTexture}
envMapTexture={envMapTexture}
opacity={opacity}
waveMagnitude={waveMagnitude}
envMapIntensity={envMapIntensity}
/>
</Suspense>
</mesh>
<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) => {
setupColor(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]}
/>
);
})}
</>
);
});