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

@ -19,6 +19,8 @@ import type { TorqueObject } from "../torqueScript";
import { getFloat, getProperty } from "../mission";
import { useDebug, useSettings } from "./SettingsProvider";
const noop = () => {};
const GRID_SIZE = 5;
const VERTEX_COUNT = GRID_SIZE * GRID_SIZE;
@ -344,8 +346,6 @@ interface CloudLayerProps {
speed: number;
windDirection: Vector2;
layerIndex: number;
debugMode: boolean;
animationEnabled: boolean;
}
/**
@ -358,11 +358,10 @@ function CloudLayer({
speed,
windDirection,
layerIndex,
debugMode,
animationEnabled,
}: CloudLayerProps) {
const materialRef = useRef<ShaderMaterial>(null!);
const offsetRef = useRef(new Vector2(0, 0));
const { debugMode } = useDebug();
const { animationEnabled } = useSettings();
const offsetRef = useRef<Vector2 | null>(null);
// Load cloud texture
const texture = useTexture(textureUrl, setupCloudTexture);
@ -376,6 +375,12 @@ function CloudLayer({
return createCloudGeometry(radius, centerHeight, innerHeight, edgeHeight);
}, [radius, heightPercent]);
useEffect(() => {
return () => {
geometry.dispose();
};
}, [geometry]);
// Create shader material
const material = useMemo(() => {
return new ShaderMaterial({
@ -393,36 +398,38 @@ function CloudLayer({
});
}, [texture, debugMode, layerIndex]);
useEffect(() => {
return () => {
material.dispose();
};
}, [material]);
// Animate UV offset for cloud scrolling
// From Tribes 2: mOffset = (currentTime - mLastTime) / 32.0 (time in ms)
// delta is in seconds, so: delta * 1000 / 32 = delta * 31.25
useFrame((_, delta) => {
if (!materialRef.current || !animationEnabled) return;
useFrame(
animationEnabled
? (_, delta) => {
// Match Tribes 2 timing: deltaTime(ms) / 32
const mOffset = (delta * 1000) / 32;
// Match Tribes 2 timing: deltaTime(ms) / 32
const mOffset = (delta * 1000) / 32;
offsetRef.current ??= new Vector2(0, 0);
offsetRef.current.x += windDirection.x * speed * mOffset;
offsetRef.current.y += windDirection.y * speed * mOffset;
offsetRef.current.x += windDirection.x * speed * mOffset;
offsetRef.current.y += windDirection.y * speed * mOffset;
// Wrap to [0,1] range
offsetRef.current.x = offsetRef.current.x - Math.floor(offsetRef.current.x);
offsetRef.current.y = offsetRef.current.y - Math.floor(offsetRef.current.y);
// Wrap to [0,1] range
offsetRef.current.x -= Math.floor(offsetRef.current.x);
offsetRef.current.y -= Math.floor(offsetRef.current.y);
materialRef.current.uniforms.uvOffset.value.copy(offsetRef.current);
});
// Cleanup
useEffect(() => {
return () => {
geometry.dispose();
material.dispose();
};
}, [geometry, material]);
material.uniforms.uvOffset.value.copy(offsetRef.current);
}
: noop,
);
return (
<mesh geometry={geometry} frustumCulled={false} renderOrder={10}>
<primitive ref={materialRef} object={material} attach="material" />
<primitive object={material} attach="material" />
</mesh>
);
}
@ -439,7 +446,7 @@ export interface CloudLayerConfig {
* 6: Environment map
* 7+: Cloud layer textures
*/
const CLOUD_TEXTURE_OFFSET = 7;
const CLOUD_TEXTURE_START_INDEX = 7;
/**
* Hook to load a DML file.
@ -467,8 +474,6 @@ export interface CloudLayersProps {
* - windVelocity: Wind direction for cloud movement
*/
export function CloudLayers({ object }: CloudLayersProps) {
const { debugMode } = useDebug();
const { animationEnabled } = useSettings();
const materialList = getProperty(object, "materialList");
const { data: detailMapList } = useDetailMapList(materialList);
@ -476,7 +481,6 @@ export function CloudLayers({ object }: CloudLayersProps) {
const visibleDistance = getFloat(object, "visibleDistance") ?? 500;
const radius = visibleDistance * 0.95;
// Extract cloud speeds from object (cloudSpeed1/2/3 are scalar values)
const cloudSpeeds = useMemo(
() => [
getFloat(object, "cloudSpeed1") ?? 0.0001,
@ -486,17 +490,14 @@ export function CloudLayers({ object }: CloudLayersProps) {
[object],
);
// Extract cloud heights from object
// Default heights match typical Tribes 2 values (e.g., 0.35, 0.25, 0.2)
const cloudHeights = useMemo(() => {
const defaults = [0.35, 0.25, 0.2];
const heights: number[] = [];
for (let i = 0; i < 3; i++) {
const height = getFloat(object, `cloudHeightPer${i}`) ?? defaults[i];
heights.push(height);
}
return heights;
}, [object]);
const cloudHeights = useMemo(
() => [
getFloat(object, "cloudHeightPer1") ?? 0.35,
getFloat(object, "cloudHeightPer2") ?? 0.25,
getFloat(object, "cloudHeightPer3") ?? 0.2,
],
[object],
);
// Wind direction from windVelocity
// Torque uses Z-up with windVelocity (x, y, z) where Y is forward.
@ -519,14 +520,13 @@ export function CloudLayers({ object }: CloudLayersProps) {
if (!detailMapList) return [];
const result: CloudLayerConfig[] = [];
for (let i = CLOUD_TEXTURE_OFFSET; i < detailMapList.length; i++) {
const texture = detailMapList[i];
for (let i = 0; i < 3; i++) {
const texture = detailMapList[CLOUD_TEXTURE_START_INDEX + i];
if (texture) {
const layerIndex = i - CLOUD_TEXTURE_OFFSET;
result.push({
texture,
height: cloudHeights[layerIndex] ?? 0,
speed: cloudSpeeds[layerIndex] ?? 0.0001 * (layerIndex + 1),
height: cloudHeights[i],
speed: cloudSpeeds[i],
});
}
}
@ -562,8 +562,6 @@ export function CloudLayers({ object }: CloudLayersProps) {
speed={layer.speed}
windDirection={windDirection}
layerIndex={i}
debugMode={debugMode}
animationEnabled={animationEnabled}
/>
</Suspense>
);

View file

@ -79,8 +79,8 @@ function createMaterialFromFlags(
side: 2, // DoubleSide
transparent: isAdditive,
alphaTest: isAdditive ? 0 : 0.5,
blending: isAdditive ? AdditiveBlending : undefined,
fog: true,
...(isAdditive && { blending: AdditiveBlending }),
});
applyShapeShaderModifications(mat);
return mat;

View file

@ -1,9 +1,11 @@
import { memo, Suspense, useMemo, useCallback } from "react";
import { memo, Suspense, useMemo, useCallback, useEffect, useRef } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
Mesh,
Material,
MeshStandardMaterial,
MeshBasicMaterial,
MeshLambertMaterial,
Texture,
SRGBColorSpace,
} from "three";
@ -58,23 +60,43 @@ function InteriorTexture({
injectCustomFog(shader, globalFogUniforms);
injectInteriorLighting(shader, {
surfaceOutsideVisible: isSurfaceOutsideVisible,
debugMode,
});
},
[isSurfaceOutsideVisible, debugMode],
[isSurfaceOutsideVisible],
);
// Key includes shader-affecting props to force recompilation when they change
// (r3f doesn't reactively recompile shaders on prop changes)
const materialKey = `${isSurfaceOutsideVisible}-${debugMode}`;
// Refs for forcing shader recompilation
const basicMaterialRef = useRef<MeshBasicMaterial>(null);
const lambertMaterialRef = useRef<MeshLambertMaterial>(null);
// Force shader recompilation when debugMode changes
// r3f doesn't sync defines prop changes, so we update the material directly
useEffect(() => {
const mat = (basicMaterialRef.current ?? lambertMaterialRef.current) as
| (Material & { defines?: Record<string, number> })
| null;
if (mat) {
mat.defines ??= {};
mat.defines.DEBUG_MODE = debugMode ? 1 : 0;
mat.needsUpdate = true;
}
}, [debugMode]);
const defines = { DEBUG_MODE: debugMode ? 1 : 0 };
// Key for shader structure changes (surfaceOutsideVisible affects lighting model)
const materialKey = `${isSurfaceOutsideVisible}`;
// Self-illuminating materials are fullbright (unlit), no lightmap
if (isSelfIlluminating) {
return (
<meshBasicMaterial
ref={basicMaterialRef}
key={materialKey}
map={texture}
toneMapped={false}
// @ts-expect-error - defines exists on Material but R3F types don't expose it
defines={defines}
onBeforeCompile={onBeforeCompile}
/>
);
@ -89,10 +111,13 @@ function InteriorTexture({
// Using FrontSide (default) - normals are fixed in io_dif Blender export
return (
<meshLambertMaterial
ref={lambertMaterialRef}
key={materialKey}
map={texture}
lightMap={lightMap ?? undefined}
toneMapped={false}
// @ts-expect-error - defines exists on Material but R3F types don't expose it
defines={defines}
onBeforeCompile={onBeforeCompile}
/>
);

View file

@ -3,6 +3,7 @@ import {
ReactNode,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -95,7 +96,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
);
// Read persisted settings from localStorage.
useEffect(() => {
useLayoutEffect(() => {
let savedSettings: PersistedSettings = {};
try {
savedSettings = JSON.parse(localStorage.getItem("settings")) || {};

View file

@ -79,6 +79,19 @@ function SkyBoxTexture({
[fogState],
);
// Create stable uniform objects that persist across renders
// This ensures the shader gets updated values when props change
const uniformsRef = useRef({
skybox: { value: skyBox },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: 0.18 },
});
// Calculate the horizon fog cutoff based on visible distance
// In Torque's sky.cc:
// mRadius = visibleDistance * 0.95
@ -106,6 +119,15 @@ function SkyBoxTexture({
);
}, [fogState]);
// Update uniform values when props change
useEffect(() => {
uniformsRef.current.skybox.value = skyBox;
uniformsRef.current.fogColor.value = fogColor ?? new Color(0, 0, 0);
uniformsRef.current.enableFog.value = enableFog;
uniformsRef.current.fogVolumeData.value = fogVolumeData;
uniformsRef.current.horizonFogHeight.value = horizonFogHeight;
}, [skyBox, fogColor, enableFog, fogVolumeData, horizonFogHeight]);
return (
<mesh renderOrder={-1000} frustumCulled={false}>
<bufferGeometry>
@ -123,16 +145,7 @@ function SkyBoxTexture({
/>
</bufferGeometry>
<shaderMaterial
uniforms={{
skybox: { value: skyBox },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: horizonFogHeight },
}}
uniforms={uniformsRef.current}
vertexShader={`
varying vec2 vUv;
@ -339,6 +352,27 @@ function SolidColorSky({
);
}, [fogState]);
// Create stable uniform objects that persist across renders
const uniformsRef = useRef({
skyColor: { value: skyColor },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: horizonFogHeight },
});
// Update uniform values when props change
useEffect(() => {
uniformsRef.current.skyColor.value = skyColor;
uniformsRef.current.fogColor.value = fogColor ?? new Color(0, 0, 0);
uniformsRef.current.enableFog.value = enableFog;
uniformsRef.current.fogVolumeData.value = fogVolumeData;
uniformsRef.current.horizonFogHeight.value = horizonFogHeight;
}, [skyColor, fogColor, enableFog, fogVolumeData, horizonFogHeight]);
return (
<mesh renderOrder={-1000} frustumCulled={false}>
<bufferGeometry>
@ -356,16 +390,7 @@ function SolidColorSky({
/>
</bufferGeometry>
<shaderMaterial
uniforms={{
skyColor: { value: skyColor },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: horizonFogHeight },
}}
uniforms={uniformsRef.current}
vertexShader={`
varying vec2 vUv;
@ -491,7 +516,13 @@ function calculateFogParameters(
* 2. Volume fog: Height-based fog using per-volume parameters
* Both are combined additively, matching Torque's getHazeAndFog function.
*/
function DynamicFog({ fogState }: { fogState: FogState }) {
function DynamicFog({
fogState,
enabled,
}: {
fogState: FogState;
enabled: boolean;
}) {
const { scene, camera } = useThree();
const fogRef = useRef<Fog | null>(null);
@ -530,6 +561,24 @@ function DynamicFog({ fogState }: { fogState: FogState }) {
};
}, [scene, camera, fogState, fogVolumeData]);
// When fog is disabled, set near=far to effectively disable fog
// without removing scene.fog (which would require shader recompilation)
useEffect(() => {
const fog = fogRef.current;
if (!fog) return;
if (enabled) {
const [near, far] = calculateFogParameters(fogState, camera.position.y);
fog.near = near;
fog.far = far;
} else {
// Setting near = far = large value effectively disables fog
// (fog factor = 0 when distance < near)
fog.near = 1e10;
fog.far = 1e10;
}
}, [enabled, fogState, camera.position.y]);
// Update fog parameters each frame based on camera height
useFrame(() => {
const fog = fogRef.current;
@ -537,14 +586,17 @@ function DynamicFog({ fogState }: { fogState: FogState }) {
const cameraHeight = camera.position.y;
// Update Three.js basic fog
const [near, far] = calculateFogParameters(fogState, cameraHeight);
fog.near = near;
fog.far = far;
fog.color.copy(fogState.fogColor);
// Always update global fog uniforms so shaders know the enabled state
updateGlobalFogUniforms(cameraHeight, fogVolumeData, enabled);
// Update global fog uniforms for volumetric fog shaders
updateGlobalFogUniforms(cameraHeight, fogVolumeData);
if (enabled) {
// Update Three.js basic fog
const [near, far] = calculateFogParameters(fogState, cameraHeight);
fog.near = near;
fog.far = far;
fog.color.copy(fogState.fogColor);
}
// When disabled, fog.near/far are already set to 1e10 by the useEffect
});
return null;
@ -632,7 +684,12 @@ export function Sky({ object }: { object: TorqueObject }) {
<Suspense>
<CloudLayers object={object} />
</Suspense>
{hasFogParams ? <DynamicFog fogState={fogState} /> : null}
{/* Always render DynamicFog when mission has fog params.
Pass fogEnabled to control visibility - this avoids shader recompilation
when toggling fog (USE_FOG stays defined, but fog.near/far disable fog). */}
{fogState.enabled ? (
<DynamicFog fogState={fogState} enabled={fogEnabled} />
) : null}
</>
);
}

View file

@ -0,0 +1,89 @@
import { createContext, ReactNode, useContext, useMemo, useState } from "react";
/**
* Handle for querying terrain data.
*
* TerrainBlock registers this via useEffect, allowing other components
* to query terrain data without prop drilling.
*/
export interface TerrainHandle {
/**
* Query terrain height at world coordinates.
* Coordinates wrap via `& 255` to support infinite terrain tiling,
* matching Torque's FluidSupport.cc:
* i = (((m_SquareY0+Y) & 255) << 8) + ((m_SquareX0+X) & 255);
*/
getHeightAt: (worldX: number, worldZ: number) => number;
/**
* Check if a point is above terrain at given world coordinates.
* Used for water masking (matching Torque's fluid reject mask).
* Coordinates wrap to support infinite terrain tiling.
*/
isAboveTerrain: (worldX: number, worldZ: number, height: number) => boolean;
/**
* Get primary terrain tile bounds in world coordinates.
* Note: Terrain actually tiles infinitely via coordinate wrapping.
*/
getBounds: () => {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
};
}
type StateSetter<T> = ReturnType<typeof useState<T>>[1];
interface TerrainContextValue {
terrain: TerrainHandle | null;
setTerrain: StateSetter<TerrainHandle | null>;
}
const TerrainContext = createContext<TerrainContextValue | null>(null);
interface TerrainProviderProps {
children: ReactNode;
}
/**
* Provider for terrain query handle.
*
* TerrainBlock registers its handle via useEffect on mount, and other
* components (like WaterBlock) can access it to query terrain heights.
*/
export function TerrainProvider({ children }: TerrainProviderProps) {
const [terrain, setTerrain] = useState<TerrainHandle | null>(null);
const context = useMemo(() => ({ terrain, setTerrain }), [terrain]);
return (
<TerrainContext.Provider value={context}>
{children}
</TerrainContext.Provider>
);
}
/**
* Get the terrain handle from context.
* Returns null if no TerrainBlock has registered yet.
*/
export function useTerrainHandle() {
const context = useContext(TerrainContext);
if (!context) {
throw new Error("useTerrainHandle must be used within a TerrainProvider");
}
return context.terrain;
}
/**
* Get the terrain setter for registration.
* Used by TerrainBlock to register its handle.
*/
export function useRegisterTerrain() {
const context = useContext(TerrainContext);
if (!context) {
throw new Error("useRegisterTerrain must be used within a TerrainProvider");
}
return context.setTerrain;
}

View file

@ -1,5 +1,10 @@
import { memo, Suspense, useCallback, useMemo } from "react";
import { type BufferGeometry, DataTexture, FrontSide } from "three";
import { memo, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
import {
type BufferGeometry,
DataTexture,
FrontSide,
type MeshLambertMaterial,
} from "three";
import { useTexture } from "@react-three/drei";
import {
FALLBACK_TEXTURE_URL,
@ -83,7 +88,6 @@ function BlendedTerrainTextures({
alphaTextures,
visibilityMask,
tiling: TILING,
debugMode,
detailTexture: detailTextureUrl ? detailTexture : null,
lightmap,
});
@ -95,28 +99,43 @@ function BlendedTerrainTextures({
baseTextures,
alphaTextures,
visibilityMask,
debugMode,
detailTexture,
detailTextureUrl,
lightmap,
],
);
// Key must include factors that change shader code structure (not just uniforms)
// - debugMode: affects fragment shader branching
// - detailTextureUrl: affects vertex shader (adds varying) and fragment shader
// - lightmap: affects shader structure (uses lightmap for NdotL instead of vertex normals)
const materialKey = `${debugMode ? "debug" : "normal"}-${detailTextureUrl ? "detail" : "nodetail"}-${lightmap ? "lightmap" : "nolightmap"}`;
// Ref for forcing shader recompilation
const materialRef = useRef<MeshLambertMaterial>(null);
// Force shader recompilation when debugMode changes
// r3f doesn't sync defines prop changes, so we update the material directly
useEffect(() => {
const mat = materialRef.current as MeshLambertMaterial & {
defines?: Record<string, number>;
};
if (mat) {
mat.defines ??= {};
mat.defines.DEBUG_MODE = debugMode ? 1 : 0;
mat.needsUpdate = true;
}
}, [debugMode]);
// Key for shader structure changes (detail texture, lightmap)
const materialKey = `${detailTextureUrl ? "detail" : "nodetail"}-${lightmap ? "lightmap" : "nolightmap"}`;
// Displacement is done on CPU, so no displacementMap needed
// We keep 'map' to provide UV coordinates for shader (vMapUv)
// Use MeshLambertMaterial for compatibility with shadow maps
return (
<meshLambertMaterial
ref={materialRef}
key={materialKey}
map={displacementMap}
depthWrite
side={FrontSide}
// @ts-expect-error - defines exists on Material but R3F types don't expose it
defines={{ DEBUG_MODE: debugMode ? 1 : 0 }}
onBeforeCompile={onBeforeCompile}
/>
);

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]}
/>
);
})}
</>
);
});

View file

@ -0,0 +1,25 @@
import { useCallback, useRef } from "react";
import { Vector3 } from "three";
export function usePositionTracker() {
const positionRef = useRef<Vector3>(null);
const hasChanged = useCallback((position: Vector3) => {
if (!positionRef.current) {
positionRef.current = position.clone();
return true;
}
const isSamePosition =
positionRef.current.x === position.x &&
positionRef.current.y === position.y &&
positionRef.current.z === position.z;
if (!isSamePosition) {
positionRef.current.copy(position);
}
return isSamePosition;
}, []);
return hasChanged;
}