import { ShaderMaterial, Texture, DoubleSide, Color } from "three"; import { globalFogUniforms } from "./globalFogUniforms"; import { fogFragmentShader } from "./fogShader"; /** * Tribes 2 WaterBlock shader material * * Based on analysis of the Torque V12 engine fluid rendering code. * The original engine renders water in multiple passes: * - Phase 1a: Base texture rotated 30° * - Phase 1b: Base texture rotated 60° with drift animation * - Phase 3: Environment/specular map with reflection UVs * - Fog: Integrated with Three.js scene fog (original used custom fog overlay) * * Key animation parameters from the engine: * - Wave motion: sin(X*0.05 + time) + sin(Y*0.05 + time) * - Base texture tiles at 1/48 world units * - Drift cycle time: 8 seconds * - Drift rate: 0.02 linear, 0.03 cosine amplitude * - Cross-fade swing: (A1+A2)*0.15 + 0.5 where A1/A2 are time-modulated */ const vertexShader = /* glsl */ ` #include #ifdef USE_FOG #define USE_FOG_WORLD_POSITION varying vec3 vFogWorldPosition; #endif uniform float uTime; uniform float uWaveMagnitude; varying vec3 vWorldPosition; varying vec3 vViewVector; varying float vDistance; // Wave function matching Tribes 2 engine // Z = surfaceZ + (sin(X*0.05 + time) + sin(Y*0.05 + time)) * waveFactor // waveFactor = waveAmplitude * 0.25 // Note: Using xz for Three.js Y-up (Torque uses XY with Z-up) float getWaveHeight(vec3 worldPos) { float waveFactor = uWaveMagnitude * 0.25; return (sin(worldPos.x * 0.05 + uTime) + sin(worldPos.z * 0.05 + uTime)) * waveFactor; } void main() { // Get world position for wave calculation vec4 worldPos = modelMatrix * vec4(position, 1.0); vWorldPosition = worldPos.xyz; // Apply wave displacement to Y (vertical axis in Three.js) vec3 displaced = position; displaced.y += getWaveHeight(worldPos.xyz); // Calculate final world position after displacement for fog #ifdef USE_FOG vec4 displacedWorldPos = modelMatrix * vec4(displaced, 1.0); vFogWorldPosition = displacedWorldPos.xyz; #endif // Calculate view vector for environment mapping vViewVector = cameraPosition - worldPos.xyz; vDistance = length(vViewVector); vec4 mvPosition = viewMatrix * modelMatrix * vec4(displaced, 1.0); gl_Position = projectionMatrix * mvPosition; // Set fog depth (distance from camera) - normally done by fog_vertex include // but we can't use that include because it references 'transformed' which we don't have #ifdef USE_FOG vFogDepth = length(mvPosition.xyz); #endif } `; const fragmentShader = /* glsl */ ` #include // Enable volumetric fog (must be defined before fog uniforms) #ifdef USE_FOG #define USE_VOLUMETRIC_FOG #define USE_FOG_WORLD_POSITION #endif uniform float uTime; uniform float uOpacity; uniform float uEnvMapIntensity; uniform sampler2D uBaseTexture; uniform sampler2D uEnvMapTexture; // Volumetric fog uniforms #ifdef USE_FOG uniform float fogVolumeData[12]; uniform float cameraHeight; uniform bool fogEnabled; varying vec3 vFogWorldPosition; #endif varying vec3 vWorldPosition; varying vec3 vViewVector; varying float vDistance; #define TWO_PI 6.283185307179586 // Constants from Tribes 2 engine #define BASE_DRIFT_CYCLE_TIME 8.0 #define BASE_DRIFT_RATE 0.02 #define BASE_DRIFT_SCALAR 0.03 #define TEXTURE_SCALE (1.0 / 48.0) // Environment map UV wobble constants #define Q1 150.0 #define Q2 2.0 #define Q3 0.01 // Rotate UV coordinates vec2 rotateUV(vec2 uv, float angle) { float c = cos(angle); float s = sin(angle); return vec2( uv.x * c - uv.y * s, uv.x * s + uv.y * c ); } void main() { // Calculate base texture UVs using world position (1/48 tiling) vec2 baseUV = vWorldPosition.xz * TEXTURE_SCALE; // Phase (time in radians for drift cycle) float phase = mod(uTime * (TWO_PI / BASE_DRIFT_CYCLE_TIME), TWO_PI); // Base texture drift float baseDriftX = uTime * BASE_DRIFT_RATE; float baseDriftY = cos(phase) * BASE_DRIFT_SCALAR; // === Phase 1a: First base texture pass (rotated 30 degrees) === vec2 uv1a = rotateUV(baseUV, radians(30.0)); // === Phase 1b: Second base texture pass (rotated 60 degrees total, with drift) === vec2 uv1b = rotateUV(baseUV + vec2(baseDriftX, baseDriftY), radians(60.0)); // Calculate cross-fade swing value float A1 = cos(((vWorldPosition.x / Q1) + (uTime / Q2)) * 6.0); float A2 = sin(((vWorldPosition.z / Q1) + (uTime / Q2)) * TWO_PI); float swing = (A1 + A2) * 0.15 + 0.5; // Cross-fade alpha calculation from engine float alpha1a = ((1.0 - swing) * uOpacity) / max(1.0 - (swing * uOpacity), 0.001); float alpha1b = swing * uOpacity; // Sample base texture for both passes vec4 texColor1a = texture2D(uBaseTexture, uv1a); vec4 texColor1b = texture2D(uBaseTexture, uv1b); // Combined alpha and color float combinedAlpha = 1.0 - (1.0 - alpha1a) * (1.0 - alpha1b); vec3 baseColor = (texColor1a.rgb * alpha1a * (1.0 - alpha1b) + texColor1b.rgb * alpha1b) / max(combinedAlpha, 0.001); // === Phase 3: Environment map / specular === vec3 reflectVec = -vViewVector; reflectVec.y = abs(reflectVec.y); if (reflectVec.y < 0.001) reflectVec.y = 0.001; vec2 envUV; if (vDistance < 0.001) { envUV = vec2(0.0); } else { float value = (vDistance - reflectVec.y) / (vDistance * vDistance); envUV.x = reflectVec.x * value; envUV.y = reflectVec.z * value; } envUV = envUV * 0.5 + 0.5; envUV.x += A1 * Q3; envUV.y += A2 * Q3; vec4 envColor = texture2D(uEnvMapTexture, envUV); vec3 finalColor = baseColor + envColor.rgb * envColor.a * uEnvMapIntensity; // Note: Tribes 2 water does NOT use lighting - Phase 2 (lightmap) is disabled // in the original engine. Water colors come directly from textures. gl_FragColor = vec4(finalColor, combinedAlpha); // Apply volumetric fog using shared Torque-style fog shader ${fogFragmentShader} } `; export function createWaterMaterial(options?: { opacity?: number; waveMagnitude?: number; envMapIntensity?: number; baseTexture?: Texture | null; envMapTexture?: Texture | null; }): ShaderMaterial { const material = new ShaderMaterial({ uniforms: { uTime: { value: 0 }, uOpacity: { value: options?.opacity ?? 0.75 }, uWaveMagnitude: { value: options?.waveMagnitude ?? 1.0 }, uEnvMapIntensity: { value: options?.envMapIntensity ?? 1.0 }, uBaseTexture: { value: options?.baseTexture ?? null }, uEnvMapTexture: { value: options?.envMapTexture ?? null }, // Fog uniforms (Three.js populates these from scene fog when fog: true) fogColor: { value: new Color() }, fogNear: { value: 1 }, fogFar: { value: 2000 }, // Volumetric fog uniforms (shared with global fog system) fogVolumeData: globalFogUniforms.fogVolumeData, cameraHeight: globalFogUniforms.cameraHeight, fogEnabled: globalFogUniforms.fogEnabled, }, vertexShader, fragmentShader, transparent: true, side: DoubleSide, // NOTE: Engine uses glDepthMask(GL_FALSE) for water, but we use depthWrite: true // so that force fields (which render after water with depthWrite: false) are // correctly occluded per-pixel when underwater. depthWrite: true, fog: true, }); return material; }