add water shader and deformation, update force field shader

This commit is contained in:
Brian Beck 2025-12-05 15:44:35 -08:00
parent 4fc405ac4b
commit 996c289032
25 changed files with 753 additions and 324 deletions

View file

@ -6,17 +6,19 @@ import {
BoxGeometry,
Color,
DoubleSide,
NoColorSpace,
LinearSRGBColorSpace,
RepeatWrapping,
ShaderMaterial,
Texture,
Vector2,
} from "three";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { textureToUrl } from "../loaders";
import { useSettings } from "./SettingsProvider";
import { useDatablock } from "./useDatablock";
import {
createForceFieldMaterial,
OPACITY_FACTOR,
} from "../forceFieldMaterial";
/**
* Get texture URLs from datablock.
@ -43,77 +45,10 @@ function parseColor(colorStr: string): [number, number, number] {
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
// Vertex shader
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
// Fragment shader - handles frame animation, UV scrolling, and color tinting
// NOTE: Shader supports up to 5 texture frames (hardcoded samplers)
const fragmentShader = `
uniform sampler2D frame0;
uniform sampler2D frame1;
uniform sampler2D frame2;
uniform sampler2D frame3;
uniform sampler2D frame4;
uniform int currentFrame;
uniform float vScroll;
uniform vec2 uvScale;
uniform vec3 tintColor;
uniform float opacity;
varying vec2 vUv;
// FIXME: This gamma correction may not be accurate. Tribes 2 had no gamma correction;
// Three.js applies gamma on output, so we pre-darken to compensate. The result is
// close but not quite right - the force field is still slightly more opaque than in T2.
vec3 srgbToLinear(vec3 srgb) {
return pow(srgb, vec3(2.2));
}
void main() {
// Scale and scroll UVs
vec2 scrolledUv = vec2(vUv.x * uvScale.x, vUv.y * uvScale.y + vScroll);
// Sample the current frame
vec4 texColor;
if (currentFrame == 0) {
texColor = texture2D(frame0, scrolledUv);
} else if (currentFrame == 1) {
texColor = texture2D(frame1, scrolledUv);
} else if (currentFrame == 2) {
texColor = texture2D(frame2, scrolledUv);
} else if (currentFrame == 3) {
texColor = texture2D(frame3, scrolledUv);
} else {
texColor = texture2D(frame4, scrolledUv);
}
// Apply color tint with constant opacity (like Tribes 2's GL_MODULATE)
vec3 finalColor = texColor.rgb * tintColor;
// Pre-darken to counteract renderer's sRGB gamma encoding
// This makes additive blending behave like Tribes 2's non-gamma-corrected output
finalColor = srgbToLinear(finalColor);
// FIXME: Halving opacity is a rough approximation to compensate for front+back faces
// both contributing (BoxGeometry with DoubleSide causes additive stacking that Tribes 2's
// thin quads didn't have). This doesn't account for viewing angles where more faces are visible.
gl_FragColor = vec4(finalColor, opacity * 0.5);
}
`;
function setupForceFieldTexture(texture: Texture) {
texture.wrapS = texture.wrapT = RepeatWrapping;
// FIXME: Using NoColorSpace to treat textures as raw linear values like Tribes 2 did,
// but the interaction with the renderer's sRGB output and shader gamma correction
// may not be fully correct. The force field appears close but not identical to T2.
texture.colorSpace = NoColorSpace;
// Linear color space - gamma correction is applied in the shader
texture.colorSpace = LinearSRGBColorSpace;
texture.flipY = false;
texture.needsUpdate = true;
}
@ -171,31 +106,13 @@ function ForceFieldMesh({
// Create shader material once (uniforms updated in useFrame)
const material = useMemo(() => {
// UV scale based on the two largest dimensions (force fields are thin planes)
const dims = [...scale].sort((a, b) => b - a);
const uvScale = new Vector2(dims[0] * umapping, dims[1] * vmapping);
// Use first texture as fallback for unused slots
const fallbackTex = textures[0];
return new ShaderMaterial({
uniforms: {
frame0: { value: textures[0] ?? fallbackTex },
frame1: { value: textures[1] ?? fallbackTex },
frame2: { value: textures[2] ?? fallbackTex },
frame3: { value: textures[3] ?? fallbackTex },
frame4: { value: textures[4] ?? fallbackTex },
currentFrame: { value: 0 },
vScroll: { value: 0 },
uvScale: { value: uvScale },
tintColor: { value: new Color(...color) },
opacity: { value: baseTranslucency },
},
vertexShader,
fragmentShader,
transparent: true,
blending: AdditiveBlending,
side: DoubleSide,
depthWrite: false,
return createForceFieldMaterial({
textures,
scale,
umapping,
vmapping,
color,
baseTranslucency,
});
}, [textures, scale, umapping, vmapping, color, baseTranslucency]);
@ -225,7 +142,10 @@ function ForceFieldMesh({
material.uniforms.vScroll.value = elapsedRef.current * scrollSpeed;
});
return <mesh geometry={geometry} material={material} />;
// renderOrder ensures force fields render after water (which uses default 0).
// Water writes depth, force fields don't - so depth testing gives correct
// per-pixel occlusion (underwater force fields are hidden, above-water visible).
return <mesh geometry={geometry} material={material} renderOrder={1} />;
}
function ForceFieldFallback({
@ -235,15 +155,27 @@ function ForceFieldFallback({
}: ForceFieldGeometryProps) {
const geometry = useCornerBoxGeometry(scale);
// Apply gamma correction to match the main shader's pow(color, 2.2)
const gammaColor = useMemo(
() =>
new Color(
Math.pow(color[0], 2.2),
Math.pow(color[1], 2.2),
Math.pow(color[2], 2.2),
),
[color],
);
return (
<mesh geometry={geometry}>
<mesh geometry={geometry} renderOrder={1}>
<meshBasicMaterial
color={new Color(...color)}
color={gammaColor}
transparent
opacity={baseTranslucency * 0.5}
opacity={baseTranslucency * OPACITY_FACTOR}
blending={AdditiveBlending}
side={DoubleSide}
depthWrite={false}
fog={false} // Standard fog doesn't work with additive blending
/>
</mesh>
);

View file

@ -5,7 +5,7 @@ import { filterGeometryByVertexGroups, getHullBoneIndices } from "../meshUtils";
import {
createAlphaAsRoughnessMaterial,
setupAlphaAsRoughnessTexture,
} from "../shaderMaterials";
} from "../shapeMaterial";
import { MeshStandardMaterial } from "three";
import { setupColor } from "../textureUtils";
import { useDebug } from "./SettingsProvider";

View file

@ -2,7 +2,8 @@ import { memo, Suspense, useCallback, useMemo } from "react";
import { DataTexture, DoubleSide, FrontSide, type PlaneGeometry } from "three";
import { useTexture } from "@react-three/drei";
import { terrainTextureToUrl } from "../loaders";
import { setupColor, updateTerrainTextureShader } from "../textureUtils";
import { setupColor } from "../textureUtils";
import { updateTerrainTextureShader } from "../terrainMaterial";
import { useDebug } from "./SettingsProvider";
const DEFAULT_SQUARE_SIZE = 8;

View file

@ -1,11 +1,114 @@
import { memo, Suspense, useEffect, useMemo } from "react";
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { useTexture } from "@react-three/drei";
import { BoxGeometry, DoubleSide } from "three";
import { useFrame } from "@react-three/fiber";
import { DoubleSide, PlaneGeometry, RepeatWrapping } from "three";
import { textureToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { setupColor } from "../textureUtils";
import { createWaterMaterial } from "../waterMaterial";
import { useSettings } from "./SettingsProvider";
/**
* 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];
}
/**
* 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);
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.
*/
export function WaterMaterial({
surfaceTexture,
attach,
@ -27,6 +130,18 @@ export function WaterMaterial({
);
}
/**
* 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.
*/
export const WaterBlock = memo(function WaterBlock({
object,
}: {
@ -38,64 +153,63 @@ export const WaterBlock = memo(function WaterBlock({
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 geometry = useMemo(() => {
const geom = new BoxGeometry(scaleX, scaleY, scaleZ);
// 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);
geom.translate(scaleX / 2, scaleY / 2, scaleZ / 2);
// PlaneGeometry is created in XY plane, we'll rotate it to XZ
const geom = new PlaneGeometry(scaleX, scaleZ, segmentsX, segmentsZ);
const uvAttr = geom.getAttribute("uv");
const uv = uvAttr.array as Float32Array;
const faceRepeats: [number, number][] = [
// +x, -x (depth x height)
[scaleX / 32, scaleY / 32],
[scaleX / 32, scaleY / 32],
// +y, -y (width x depth)
[scaleZ / 32, scaleX / 32],
[scaleZ / 32, scaleX / 32],
// +z, -z (width x height)
[scaleZ / 32, scaleY / 32],
[scaleZ / 32, scaleY / 32],
];
// 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)
geom.translate(scaleX / 2, scaleY, scaleZ / 2);
for (let face = 0; face < 6; face++) {
const [uRepeat, vRepeat] = faceRepeats[face];
const offset = face * 4 * 2; // 4 verts per face, 2 components per vert
for (let i = 0; i < 4; i++) {
uv[offset + i * 2] *= uRepeat;
uv[offset + i * 2 + 1] *= vRepeat;
}
}
uvAttr.needsUpdate = true;
return geom;
}, [scaleX, scaleY, scaleZ]);
useEffect(() => {
return () => {
geometry.dispose();
surfaceGeometry.dispose();
};
}, [geometry]);
}, [surfaceGeometry]);
return (
<mesh position={position} quaternion={q} geometry={geometry}>
<meshStandardMaterial attach="material-0" transparent opacity={0} />
<meshStandardMaterial attach="material-1" transparent opacity={0} />
<Suspense
fallback={
<meshStandardMaterial
attach="material-2"
color="blue"
transparent
opacity={0.3}
side={DoubleSide}
<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}
/>
}
>
<WaterSurfaceMaterial
attach="material"
surfaceTexture={surfaceTexture}
envMapTexture={envMapTexture}
opacity={opacity}
waveMagnitude={waveMagnitude}
envMapIntensity={envMapIntensity}
/>
}
>
<WaterMaterial attach="material-2" surfaceTexture={surfaceTexture} />
</Suspense>
<meshStandardMaterial attach="material-3" transparent opacity={0} />
<meshStandardMaterial attach="material-4" transparent opacity={0} />
<meshStandardMaterial attach="material-5" transparent opacity={0} />
</mesh>
</Suspense>
</mesh>
</group>
);
});

156
src/forceFieldMaterial.ts Normal file
View file

@ -0,0 +1,156 @@
/**
* Force field shader material for Tribes 2 ForceFieldBare objects.
*
* Tribes 2 rendering (forceFieldBare.cc):
* - glBlendFunc(GL_SRC_ALPHA, GL_ONE) - additive blending
* - glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE)
* - Final: framebuffer += (texture.rgb * fieldColor.rgb) * fieldColor.alpha
* - Renders 6 separate outward-facing quads with glDisable(GL_CULL_FACE)
* - Depth test enabled but depth write disabled - back faces can be occluded
*
* Differences from engine that affect brightness:
* 1. In T2, force fields are in doorways with geometry that occludes back faces
* 2. T2 textures were authored for CRT gamma (~2.2) with no correction
* 3. BoxGeometry + DoubleSide renders all faces even in empty space
*/
import {
AdditiveBlending,
Color,
DoubleSide,
ShaderMaterial,
Texture,
Vector2,
} from "three";
// Opacity multiplier to compensate for DoubleSide rendering both front and back faces.
// In Tribes 2, back faces were often occluded by surrounding geometry (door frames, walls).
export const OPACITY_FACTOR = 0.5;
// Vertex shader
const vertexShader = `
#include <fog_pars_vertex>
varying vec2 vUv;
void main() {
vUv = uv;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}
`;
// Fragment shader - handles frame animation, UV scrolling, and color tinting
// NOTE: Shader supports up to 5 texture frames (hardcoded samplers)
const fragmentShader = `
#include <fog_pars_fragment>
uniform sampler2D frame0;
uniform sampler2D frame1;
uniform sampler2D frame2;
uniform sampler2D frame3;
uniform sampler2D frame4;
uniform int currentFrame;
uniform float vScroll;
uniform vec2 uvScale;
uniform vec3 tintColor;
uniform float opacity;
uniform float opacityFactor;
varying vec2 vUv;
void main() {
// Scale and scroll UVs
vec2 scrolledUv = vec2(vUv.x * uvScale.x, vUv.y * uvScale.y + vScroll);
// Sample the current frame
vec4 texColor;
if (currentFrame == 0) {
texColor = texture2D(frame0, scrolledUv);
} else if (currentFrame == 1) {
texColor = texture2D(frame1, scrolledUv);
} else if (currentFrame == 2) {
texColor = texture2D(frame2, scrolledUv);
} else if (currentFrame == 3) {
texColor = texture2D(frame3, scrolledUv);
} else {
texColor = texture2D(frame4, scrolledUv);
}
// Tribes 2 GL_MODULATE: output = texture * vertexColor
vec3 modulatedColor = texColor.rgb * tintColor;
// Gamma correction: T2 textures were authored for CRT displays (~2.2 gamma).
// Converting to linear space makes them appear as they did on those displays.
// This significantly darkens the colors to match the original look.
modulatedColor = pow(modulatedColor, vec3(2.2));
float adjustedOpacity = opacity * opacityFactor;
gl_FragColor = vec4(modulatedColor, adjustedOpacity);
// Custom fog for additive blending: fade out rather than blend to fog color.
// Standard fog (mix toward fogColor) doesn't work with additive blending
// because we'd still be adding fogColor to the framebuffer.
#ifdef USE_FOG
#ifdef FOG_EXP2
float fogFactor = 1.0 - exp(-fogDensity * fogDensity * vFogDepth * vFogDepth);
#else
float fogFactor = smoothstep(fogNear, fogFar, vFogDepth);
#endif
gl_FragColor.a *= 1.0 - fogFactor;
#endif
}
`;
export interface ForceFieldMaterialOptions {
textures: Texture[];
scale: [number, number, number];
umapping: number;
vmapping: number;
color: [number, number, number];
baseTranslucency: number;
}
export function createForceFieldMaterial({
textures,
scale,
umapping,
vmapping,
color,
baseTranslucency,
}: ForceFieldMaterialOptions): ShaderMaterial {
// UV scale based on the two largest dimensions (force fields are thin planes)
const dims = [...scale].sort((a, b) => b - a);
const uvScale = new Vector2(dims[0] * umapping, dims[1] * vmapping);
// Use first texture as fallback for unused frame slots
const fallback = textures[0];
return new ShaderMaterial({
uniforms: {
frame0: { value: fallback },
frame1: { value: textures[1] ?? fallback },
frame2: { value: textures[2] ?? fallback },
frame3: { value: textures[3] ?? fallback },
frame4: { value: textures[4] ?? fallback },
currentFrame: { value: 0 },
vScroll: { value: 0 },
uvScale: { value: uvScale },
tintColor: { value: new Color(...color) },
opacity: { value: baseTranslucency },
opacityFactor: { value: OPACITY_FACTOR },
// Fog uniforms (Three.js populates from scene fog when fog: true)
fogColor: { value: new Color() },
fogNear: { value: 1 },
fogFar: { value: 2000 },
},
vertexShader,
fragmentShader,
transparent: true,
blending: AdditiveBlending,
side: DoubleSide,
depthWrite: false,
fog: true,
});
}

167
src/terrainMaterial.ts Normal file
View file

@ -0,0 +1,167 @@
/**
* Terrain material shader modifications.
* Handles multi-layer texture blending for Tribes 2 terrain rendering.
*/
export function updateTerrainTextureShader({
shader,
baseTextures,
alphaTextures,
visibilityMask,
tiling,
debugMode = false,
}: {
shader: any;
baseTextures: any[];
alphaTextures: any[];
visibilityMask: any;
tiling: Record<number, number>;
debugMode?: boolean;
}) {
const layerCount = baseTextures.length;
baseTextures.forEach((tex, i) => {
shader.uniforms[`albedo${i}`] = { value: tex };
});
alphaTextures.forEach((tex, i) => {
if (i > 0) {
shader.uniforms[`mask${i}`] = { value: tex };
}
});
// Add visibility mask uniform if we have empty squares
if (visibilityMask) {
shader.uniforms.visibilityMask = { value: visibilityMask };
}
// Add per-texture tiling uniforms
baseTextures.forEach((tex, i) => {
shader.uniforms[`tiling${i}`] = {
value: tiling[i] ?? 32,
};
});
// Add debug mode uniform
shader.uniforms.debugMode = { value: debugMode ? 1.0 : 0.0 };
// Declare our uniforms at the top of the fragment shader
shader.fragmentShader =
`
uniform sampler2D albedo0;
uniform sampler2D albedo1;
uniform sampler2D albedo2;
uniform sampler2D albedo3;
uniform sampler2D albedo4;
uniform sampler2D albedo5;
uniform sampler2D mask1;
uniform sampler2D mask2;
uniform sampler2D mask3;
uniform sampler2D mask4;
uniform sampler2D mask5;
uniform float tiling0;
uniform float tiling1;
uniform float tiling2;
uniform float tiling3;
uniform float tiling4;
uniform float tiling5;
uniform float debugMode;
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
// Wireframe edge detection for debug mode
float getWireframe(vec2 uv, float gridSize, float lineWidth) {
vec2 gridUv = uv * gridSize;
vec2 grid = abs(fract(gridUv - 0.5) - 0.5);
vec2 deriv = fwidth(gridUv);
vec2 edge = smoothstep(vec2(0.0), deriv * lineWidth, grid);
return 1.0 - min(edge.x, edge.y);
}
` + shader.fragmentShader;
if (visibilityMask) {
const clippingPlaceholder = "#include <clipping_planes_fragment>";
shader.fragmentShader = shader.fragmentShader.replace(
clippingPlaceholder,
`${clippingPlaceholder}
// Early discard for invisible areas (before fog/lighting)
float visibility = texture2D(visibilityMask, vMapUv).r;
if (visibility < 0.5) {
discard;
}
`,
);
}
// Replace the default map sampling block with our layered blend.
// We rely on vMapUv provided by USE_MAP.
shader.fragmentShader = shader.fragmentShader.replace(
"#include <map_fragment>",
`
// Sample base albedo layers (sRGB textures auto-decoded to linear)
vec2 baseUv = vMapUv;
vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb;
${
layerCount > 1
? `vec3 c1 = texture2D(albedo1, baseUv * vec2(tiling1)).rgb;`
: ""
}
${
layerCount > 2
? `vec3 c2 = texture2D(albedo2, baseUv * vec2(tiling2)).rgb;`
: ""
}
${
layerCount > 3
? `vec3 c3 = texture2D(albedo3, baseUv * vec2(tiling3)).rgb;`
: ""
}
${
layerCount > 4
? `vec3 c4 = texture2D(albedo4, baseUv * vec2(tiling4)).rgb;`
: ""
}
${
layerCount > 5
? `vec3 c5 = texture2D(albedo5, baseUv * vec2(tiling5)).rgb;`
: ""
}
// Sample linear masks (use R channel)
float a1 = texture2D(mask1, baseUv).r;
${layerCount > 1 ? `float a2 = texture2D(mask2, baseUv).r;` : ""}
${layerCount > 2 ? `float a3 = texture2D(mask3, baseUv).r;` : ""}
${layerCount > 3 ? `float a4 = texture2D(mask4, baseUv).r;` : ""}
${layerCount > 4 ? `float a5 = texture2D(mask5, baseUv).r;` : ""}
// Bottom-up compositing: each mask tells how much the higher layer replaces lower
${layerCount > 1 ? `vec3 blended = mix(c0, c1, clamp(a1, 0.0, 1.0));` : ""}
${layerCount > 2 ? `blended = mix(blended, c2, clamp(a2, 0.0, 1.0));` : ""}
${layerCount > 3 ? `blended = mix(blended, c3, clamp(a3, 0.0, 1.0));` : ""}
${layerCount > 4 ? `blended = mix(blended, c4, clamp(a4, 0.0, 1.0));` : ""}
${layerCount > 5 ? `blended = mix(blended, c5, clamp(a5, 0.0, 1.0));` : ""}
// Assign to diffuseColor before lighting
vec3 textureColor = ${layerCount > 1 ? "blended" : "c0"};
// Debug mode wireframe handling
if (debugMode > 0.5) {
// 256 grid cells across the terrain (matches terrain resolution)
float wireframe = getWireframe(baseUv, 256.0, 1.0);
vec3 wireColor = vec3(0.0, 0.8, 0.4); // Green wireframe
if (gl_FrontFacing) {
// Front face: show textures with barely visible wireframe overlay
diffuseColor.rgb = mix(textureColor, wireColor, wireframe * 0.05);
} else {
// Back face: show only wireframe, discard non-wireframe pixels
if (wireframe < 0.1) {
discard;
}
diffuseColor.rgb = mix(vec3(0.0), wireColor, 0.25);
}
} else {
diffuseColor.rgb = textureColor;
}
`,
);
}

View file

@ -1,3 +1,6 @@
/**
* Generic texture setup utilities.
*/
import {
DataTexture,
LinearFilter,
@ -46,166 +49,3 @@ export function setupMask(data) {
return tex;
}
export function updateTerrainTextureShader({
shader,
baseTextures,
alphaTextures,
visibilityMask,
tiling,
debugMode = false,
}: {
shader: any;
baseTextures: any[];
alphaTextures: any[];
visibilityMask: any;
tiling: Record<number, number>;
debugMode?: boolean;
}) {
const layerCount = baseTextures.length;
baseTextures.forEach((tex, i) => {
shader.uniforms[`albedo${i}`] = { value: tex };
});
alphaTextures.forEach((tex, i) => {
if (i > 0) {
shader.uniforms[`mask${i}`] = { value: tex };
}
});
// Add visibility mask uniform if we have empty squares
if (visibilityMask) {
shader.uniforms.visibilityMask = { value: visibilityMask };
}
// Add per-texture tiling uniforms
baseTextures.forEach((tex, i) => {
shader.uniforms[`tiling${i}`] = {
value: tiling[i] ?? 32,
};
});
// Add debug mode uniform
shader.uniforms.debugMode = { value: debugMode ? 1.0 : 0.0 };
// Declare our uniforms at the top of the fragment shader
shader.fragmentShader =
`
uniform sampler2D albedo0;
uniform sampler2D albedo1;
uniform sampler2D albedo2;
uniform sampler2D albedo3;
uniform sampler2D albedo4;
uniform sampler2D albedo5;
uniform sampler2D mask1;
uniform sampler2D mask2;
uniform sampler2D mask3;
uniform sampler2D mask4;
uniform sampler2D mask5;
uniform float tiling0;
uniform float tiling1;
uniform float tiling2;
uniform float tiling3;
uniform float tiling4;
uniform float tiling5;
uniform float debugMode;
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
// Wireframe edge detection for debug mode
float getWireframe(vec2 uv, float gridSize, float lineWidth) {
vec2 gridUv = uv * gridSize;
vec2 grid = abs(fract(gridUv - 0.5) - 0.5);
vec2 deriv = fwidth(gridUv);
vec2 edge = smoothstep(vec2(0.0), deriv * lineWidth, grid);
return 1.0 - min(edge.x, edge.y);
}
` + shader.fragmentShader;
if (visibilityMask) {
const clippingPlaceholder = "#include <clipping_planes_fragment>";
shader.fragmentShader = shader.fragmentShader.replace(
clippingPlaceholder,
`${clippingPlaceholder}
// Early discard for invisible areas (before fog/lighting)
float visibility = texture2D(visibilityMask, vMapUv).r;
if (visibility < 0.5) {
discard;
}
`,
);
}
// Replace the default map sampling block with our layered blend.
// We rely on vMapUv provided by USE_MAP.
shader.fragmentShader = shader.fragmentShader.replace(
"#include <map_fragment>",
`
// Sample base albedo layers (sRGB textures auto-decoded to linear)
vec2 baseUv = vMapUv;
vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb;
${
layerCount > 1
? `vec3 c1 = texture2D(albedo1, baseUv * vec2(tiling1)).rgb;`
: ""
}
${
layerCount > 2
? `vec3 c2 = texture2D(albedo2, baseUv * vec2(tiling2)).rgb;`
: ""
}
${
layerCount > 3
? `vec3 c3 = texture2D(albedo3, baseUv * vec2(tiling3)).rgb;`
: ""
}
${
layerCount > 4
? `vec3 c4 = texture2D(albedo4, baseUv * vec2(tiling4)).rgb;`
: ""
}
${
layerCount > 5
? `vec3 c5 = texture2D(albedo5, baseUv * vec2(tiling5)).rgb;`
: ""
}
// Sample linear masks (use R channel)
float a1 = texture2D(mask1, baseUv).r;
${layerCount > 1 ? `float a2 = texture2D(mask2, baseUv).r;` : ""}
${layerCount > 2 ? `float a3 = texture2D(mask3, baseUv).r;` : ""}
${layerCount > 3 ? `float a4 = texture2D(mask4, baseUv).r;` : ""}
${layerCount > 4 ? `float a5 = texture2D(mask5, baseUv).r;` : ""}
// Bottom-up compositing: each mask tells how much the higher layer replaces lower
${layerCount > 1 ? `vec3 blended = mix(c0, c1, clamp(a1, 0.0, 1.0));` : ""}
${layerCount > 2 ? `blended = mix(blended, c2, clamp(a2, 0.0, 1.0));` : ""}
${layerCount > 3 ? `blended = mix(blended, c3, clamp(a3, 0.0, 1.0));` : ""}
${layerCount > 4 ? `blended = mix(blended, c4, clamp(a4, 0.0, 1.0));` : ""}
${layerCount > 5 ? `blended = mix(blended, c5, clamp(a5, 0.0, 1.0));` : ""}
// Assign to diffuseColor before lighting
vec3 textureColor = ${layerCount > 1 ? "blended" : "c0"};
// Debug mode wireframe handling
if (debugMode > 0.5) {
// 256 grid cells across the terrain (matches terrain resolution)
float wireframe = getWireframe(baseUv, 256.0, 1.0);
vec3 wireColor = vec3(0.0, 0.8, 0.4); // Green wireframe
if (gl_FrontFacing) {
// Front face: show textures with barely visible wireframe overlay
diffuseColor.rgb = mix(textureColor, wireColor, wireframe * 0.05);
} else {
// Back face: show only wireframe, discard non-wireframe pixels
if (wireframe < 0.1) {
discard;
}
diffuseColor.rgb = mix(vec3(0.0), wireColor, 0.25);
}
} else {
diffuseColor.rgb = textureColor;
}
`,
);
}

219
src/waterMaterial.ts Normal file
View file

@ -0,0 +1,219 @@
import { ShaderMaterial, Texture, DoubleSide, Color } from "three";
/**
* 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/1b: Two cross-faded base texture passes, each rotated 30°
* - 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>
uniform float uTime;
uniform float uWaveMagnitude;
varying vec2 vUv;
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() {
vUv = uv;
// 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 view vector for environment mapping
vViewVector = cameraPosition - worldPos.xyz;
vDistance = length(vViewVector);
vec4 mvPosition = viewMatrix * modelMatrix * vec4(displaced, 1.0);
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}
`;
const fragmentShader = /* glsl */ `
#include <fog_pars_fragment>
uniform float uTime;
uniform float uOpacity;
uniform float uEnvMapIntensity;
uniform sampler2D uBaseTexture;
uniform sampler2D uEnvMapTexture;
varying vec2 vUv;
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)
// Note: In Three.js Y-up coordinates, the water surface is on the XZ plane
// Torque uses Z-up where the surface is XY, so we use xz here
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) ===
// OpenGL matrix order: glRotatef(60) then glTranslatef(drift) means
// the transform is R60 * T, so when applied to UV: R60 * (UV + drift)
// Translation is applied first, then rotation.
vec2 uv1b = rotateUV(baseUV + vec2(baseDriftX, baseDriftY), radians(60.0));
// Calculate cross-fade swing value
// From engine: A1 = cos((X/Q1 + time/Q2) * 6.0), A2 = sin((Y/Q1 + time/Q2) * 6.28)
// Using xz for Three.js Y-up coordinate system
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
// alpha1a = ((1-swing) * opacity) / (1 - (swing * opacity))
// alpha1b = swing * opacity
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);
// Simulate multi-pass alpha accumulation (screen blend formula)
// Pass 1a: framebuffer = tex1a * alpha1a + bg * (1 - alpha1a)
// Pass 1b: framebuffer = tex1b * alpha1b + prev * (1 - alpha1b)
// Combined alpha = 1 - (1 - alpha1a) * (1 - alpha1b)
float combinedAlpha = 1.0 - (1.0 - alpha1a) * (1.0 - alpha1b);
// Combined color (premultiplied then divided by combined alpha)
// color = tex1b * alpha1b + tex1a * alpha1a * (1 - alpha1b)
vec3 baseColor = (texColor1a.rgb * alpha1a * (1.0 - alpha1b) + texColor1b.rgb * alpha1b) / max(combinedAlpha, 0.001);
// === Phase 3: Environment map / specular ===
vec3 viewDir = normalize(vViewVector);
// Reflection UV calculation from engine
// The reflection vector is eye-to-point with positive Z
vec3 reflectVec = viewDir;
reflectVec.z = abs(reflectVec.z);
if (reflectVec.z < 0.001) reflectVec.z = 0.001;
vec2 envUV;
if (vDistance < 0.001) {
envUV = vec2(0.0);
} else {
// Standard UV reflection mapping with adjustment
float value = (vDistance - reflectVec.z) / (vDistance * vDistance);
envUV.x = reflectVec.x * value;
envUV.y = reflectVec.y * value;
}
// Convert from [-1,1] to [0,1]
envUV = envUV * 0.5 + 0.5;
// Add time-based wobble to environment map
envUV.x += A1 * Q3;
envUV.y += A2 * Q3;
vec4 envColor = texture2D(uEnvMapTexture, envUV);
// Blend environment map additively (GL_SRC_ALPHA, GL_ONE in original engine)
// This adds specular highlights without changing base transparency
vec3 finalColor = baseColor + envColor.rgb * uEnvMapIntensity;
gl_FragColor = vec4(finalColor, combinedAlpha);
// Apply scene fog (integrated with Three.js fog system)
#include <fog_fragment>
}
`;
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 },
},
vertexShader,
fragmentShader,
transparent: true,
side: DoubleSide,
// Water writes depth so that objects behind it (like force fields) are
// properly occluded. Force fields use depthWrite: false and render after
// water, so they correctly appear in front of or behind water per-pixel.
depthWrite: true,
fog: true,
});
return material;
}