t2-mapper/src/waterMaterial.ts

228 lines
7.4 KiB
TypeScript

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 <fog_pars_vertex>
#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 <fog_pars_fragment>
// 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;
}