mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-13 07:25:07 +00:00
add water shader and deformation, update force field shader
This commit is contained in:
parent
4fc405ac4b
commit
996c289032
25 changed files with 753 additions and 324 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
156
src/forceFieldMaterial.ts
Normal 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
167
src/terrainMaterial.ts
Normal 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;
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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
219
src/waterMaterial.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue