mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-04-26 14:55:48 +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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
docs/_next/static/chunks/618f302c73bc6a21.js
Normal file
1
docs/_next/static/chunks/618f302c73bc6a21.js
Normal file
File diff suppressed because one or more lines are too long
1
docs/_next/static/chunks/6a22d5a06cf91e1e.js
Normal file
1
docs/_next/static/chunks/6a22d5a06cf91e1e.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
docs/_next/static/chunks/b70f08013a69708a.js
Normal file
1
docs/_next/static/chunks/b70f08013a69708a.js
Normal file
File diff suppressed because one or more lines are too long
1
docs/_next/static/chunks/dac67b4b788a5958.js
Normal file
1
docs/_next/static/chunks/dac67b4b788a5958.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,42585,t=>{"use strict";t.s(["WaterBlock",()=>u,"WaterMaterial",()=>o]);var e=t.i(43476),a=t.i(71645),r=t.i(47071),i=t.i(90072),s=t.i(12979),l=t.i(62395),n=t.i(75567);function o(t){let{surfaceTexture:a,attach:l}=t,o=(0,s.textureToUrl)(a),u=(0,r.useTexture)(o,t=>(0,n.setupColor)(t));return(0,e.jsx)("meshStandardMaterial",{attach:l,map:u,transparent:!0,opacity:.8,side:i.DoubleSide})}let u=(0,a.memo)(function(t){var r;let{object:s}=t,n=(0,a.useMemo)(()=>(0,l.getPosition)(s),[s]),u=(0,a.useMemo)(()=>(0,l.getRotation)(s),[s]),[c,d,m]=(0,a.useMemo)(()=>(0,l.getScale)(s),[s]),p=null!=(r=(0,l.getProperty)(s,"surfaceTexture"))?r:"liquidTiles/BlueWater",h=(0,a.useMemo)(()=>{let t=new i.BoxGeometry(c,d,m);t.translate(c/2,d/2,m/2);let e=t.getAttribute("uv"),a=e.array,r=[[c/32,d/32],[c/32,d/32],[m/32,c/32],[m/32,c/32],[m/32,d/32],[m/32,d/32]];for(let t=0;t<6;t++){let[e,i]=r[t],s=4*t*2;for(let t=0;t<4;t++)a[s+2*t]*=e,a[s+2*t+1]*=i}return e.needsUpdate=!0,t},[c,d,m]);return(0,a.useEffect)(()=>()=>{h.dispose()},[h]),(0,e.jsxs)("mesh",{position:n,quaternion:u,geometry:h,children:[(0,e.jsx)("meshStandardMaterial",{attach:"material-0",transparent:!0,opacity:0}),(0,e.jsx)("meshStandardMaterial",{attach:"material-1",transparent:!0,opacity:0}),(0,e.jsx)(a.Suspense,{fallback:(0,e.jsx)("meshStandardMaterial",{attach:"material-2",color:"blue",transparent:!0,opacity:.3,side:i.DoubleSide}),children:(0,e.jsx)(o,{attach:"material-2",surfaceTexture:p})}),(0,e.jsx)("meshStandardMaterial",{attach:"material-3",transparent:!0,opacity:0}),(0,e.jsx)("meshStandardMaterial",{attach:"material-4",transparent:!0,opacity:0}),(0,e.jsx)("meshStandardMaterial",{attach:"material-5",transparent:!0,opacity:0})]})})}]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -2,7 +2,7 @@
|
|||
2:I[39756,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"default"]
|
||||
3:I[37457,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"default"]
|
||||
4:I[47257,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"ClientPageRoot"]
|
||||
5:I[31713,["/t2-mapper/_next/static/chunks/1498d1f5aa36f719.js","/t2-mapper/_next/static/chunks/32ef0c8650712240.js","/t2-mapper/_next/static/chunks/4b378a46243a33ea.js","/t2-mapper/_next/static/chunks/e48d3b25285f3054.js"],"default"]
|
||||
5:I[31713,["/t2-mapper/_next/static/chunks/618f302c73bc6a21.js","/t2-mapper/_next/static/chunks/32ef0c8650712240.js","/t2-mapper/_next/static/chunks/b70f08013a69708a.js","/t2-mapper/_next/static/chunks/aaf7d6869584978f.js"],"default"]
|
||||
8:I[97367,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"OutletBoundary"]
|
||||
a:I[11533,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"AsyncMetadataOutlet"]
|
||||
c:I[97367,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"ViewportBoundary"]
|
||||
|
|
@ -10,7 +10,7 @@ e:I[97367,["/t2-mapper/_next/static/chunks/060f9a97930f3d04.js"],"MetadataBounda
|
|||
f:"$Sreact.suspense"
|
||||
11:I[68027,[],"default"]
|
||||
:HL["/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","style"]
|
||||
0:{"P":null,"b":"aeewrZ6UlSCSwvUGCHr1a","p":"/t2-mapper","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","searchParams":{},"params":{},"promises":["$@6","$@7"]}],[["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/1498d1f5aa36f719.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/32ef0c8650712240.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/4b378a46243a33ea.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/e48d3b25285f3054.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$L9",["$","$La",null,{"promise":"$@b"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Lc",null,{"children":"$Ld"}],null],["$","$Le",null,{"children":["$","div",null,{"hidden":true,"children":["$","$f",null,{"fallback":null,"children":"$L10"}]}]}]]}],false]],"m":"$undefined","G":["$11",[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"s":false,"S":true}
|
||||
0:{"P":null,"b":"LT1qVc5jR2WYUbO3-wDuw","p":"/t2-mapper","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L2",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L3",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L4",null,{"Component":"$5","searchParams":{},"params":{},"promises":["$@6","$@7"]}],[["$","script","script-0",{"src":"/t2-mapper/_next/static/chunks/618f302c73bc6a21.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/t2-mapper/_next/static/chunks/32ef0c8650712240.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/t2-mapper/_next/static/chunks/b70f08013a69708a.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/t2-mapper/_next/static/chunks/aaf7d6869584978f.js","async":true,"nonce":"$undefined"}]],["$","$L8",null,{"children":["$L9",["$","$La",null,{"promise":"$@b"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Lc",null,{"children":"$Ld"}],null],["$","$Le",null,{"children":["$","div",null,{"hidden":true,"children":["$","$f",null,{"fallback":null,"children":"$L10"}]}]}]]}],false]],"m":"$undefined","G":["$11",[["$","link","0",{"rel":"stylesheet","href":"/t2-mapper/_next/static/chunks/15b04c9d2ba2c4cf.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"s":false,"S":true}
|
||||
6:{}
|
||||
7:"$0:f:0:1:2:children:1:props:children:0:props:params"
|
||||
d:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||
|
|
|
|||
|
|
@ -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