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 { SceneWaterBlock } from "../scene/types";
import {
torqueToThree,
torqueScaleToThree,
matrixFToQuaternion,
} from "../scene";
import { setupTexture } from "../textureUtils";
import { createWaterMaterial } from "../waterMaterial";
import { useDebug, useSettings } from "./SettingsProvider";
import { usePositionTracker } from "./usePositionTracker";
import { WaterBlockEntity } from "../state/gameEntityTypes";
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 (
);
}
/**
* 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({
entity,
}: {
entity: WaterBlockEntity;
}) {
const scene = entity.waterData;
const { debugMode } = useDebug();
const q = useMemo(
() => matrixFToQuaternion(scene.transform),
[scene.transform],
);
const position = useMemo(
() => torqueToThree(scene.transform.position),
[scene.transform],
);
const scale = useMemo(() => torqueScaleToThree(scene.scale), [scene.scale]);
const [scaleX, scaleY, scaleZ] = scale;
const camera = useThree((state) => state.camera);
const hasCameraPositionChanged = usePositionTracker();
const waveMagnitude = scene.waveMagnitude;
// 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>(() =>
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 = scene.surfaceName || "liquidTiles/BlueWater";
const envMapTexture = scene.envMapName || undefined;
const opacity = scene.surfaceOpacity;
const envMapIntensity = scene.envMapIntensity;
// 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 (
{/* Debug wireframe showing actual water block bounds */}
{debugMode && (
)}
{
// 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 (
);
})}
>
);
});
/**
* 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 (
);
})}
>
);
});