WaterBlock tiling to match T2/Torque, improve CLAUDE.md

This commit is contained in:
Brian Beck 2025-12-11 22:07:29 -08:00
parent bcf4f4a1a5
commit aeda3ca8d5
940 changed files with 1207 additions and 337 deletions

38
src/colorUtils.ts Normal file
View file

@ -0,0 +1,38 @@
/**
* Color parsing utilities for Tribes 2 mission files.
*
* Torque (2001) worked in gamma/sRGB space - colors in mission files are
* specified as they should appear on screen. Three.js expects linear colors
* for lighting calculations, so convert with .convertSRGBToLinear() when
* passing to lit materials.
*/
import { Color, SRGBColorSpace } from "three";
/**
* Parse a Tribes 2 color string (space-separated RGB or RGBA values 0-1).
* The values are interpreted as sRGB and stored as linear internally by Three.js.
*
* @param colorString - Space-separated "R G B" or "R G B A" string (0-1 range)
* @returns Color (linear internally), or undefined if no string
*/
export function parseColor(colorString: string | undefined): Color | undefined {
if (!colorString) return undefined;
const parts = colorString.split(" ").map((s) => parseFloat(s));
const [r = 0, g = 0, b = 0] = parts;
// Interpret as sRGB, Three.js converts to linear internally
return new Color().setRGB(r, g, b, SRGBColorSpace);
}
/**
* Parse a Tribes 2 color string and convert to linear color space.
* Use this when passing colors to Three.js lit materials.
*
* @param colorString - Space-separated "R G B" or "R G B A" string (0-1 range)
* @returns Color in linear space, or undefined if no string
*/
export function parseColorLinear(
colorString: string | undefined,
): Color | undefined {
const color = parseColor(colorString);
return color?.convertSRGBToLinear();
}

View file

@ -19,6 +19,8 @@ import type { TorqueObject } from "../torqueScript";
import { getFloat, getProperty } from "../mission";
import { useDebug, useSettings } from "./SettingsProvider";
const noop = () => {};
const GRID_SIZE = 5;
const VERTEX_COUNT = GRID_SIZE * GRID_SIZE;
@ -344,8 +346,6 @@ interface CloudLayerProps {
speed: number;
windDirection: Vector2;
layerIndex: number;
debugMode: boolean;
animationEnabled: boolean;
}
/**
@ -358,11 +358,10 @@ function CloudLayer({
speed,
windDirection,
layerIndex,
debugMode,
animationEnabled,
}: CloudLayerProps) {
const materialRef = useRef<ShaderMaterial>(null!);
const offsetRef = useRef(new Vector2(0, 0));
const { debugMode } = useDebug();
const { animationEnabled } = useSettings();
const offsetRef = useRef<Vector2 | null>(null);
// Load cloud texture
const texture = useTexture(textureUrl, setupCloudTexture);
@ -376,6 +375,12 @@ function CloudLayer({
return createCloudGeometry(radius, centerHeight, innerHeight, edgeHeight);
}, [radius, heightPercent]);
useEffect(() => {
return () => {
geometry.dispose();
};
}, [geometry]);
// Create shader material
const material = useMemo(() => {
return new ShaderMaterial({
@ -393,36 +398,38 @@ function CloudLayer({
});
}, [texture, debugMode, layerIndex]);
useEffect(() => {
return () => {
material.dispose();
};
}, [material]);
// Animate UV offset for cloud scrolling
// From Tribes 2: mOffset = (currentTime - mLastTime) / 32.0 (time in ms)
// delta is in seconds, so: delta * 1000 / 32 = delta * 31.25
useFrame((_, delta) => {
if (!materialRef.current || !animationEnabled) return;
useFrame(
animationEnabled
? (_, delta) => {
// Match Tribes 2 timing: deltaTime(ms) / 32
const mOffset = (delta * 1000) / 32;
// Match Tribes 2 timing: deltaTime(ms) / 32
const mOffset = (delta * 1000) / 32;
offsetRef.current ??= new Vector2(0, 0);
offsetRef.current.x += windDirection.x * speed * mOffset;
offsetRef.current.y += windDirection.y * speed * mOffset;
offsetRef.current.x += windDirection.x * speed * mOffset;
offsetRef.current.y += windDirection.y * speed * mOffset;
// Wrap to [0,1] range
offsetRef.current.x = offsetRef.current.x - Math.floor(offsetRef.current.x);
offsetRef.current.y = offsetRef.current.y - Math.floor(offsetRef.current.y);
// Wrap to [0,1] range
offsetRef.current.x -= Math.floor(offsetRef.current.x);
offsetRef.current.y -= Math.floor(offsetRef.current.y);
materialRef.current.uniforms.uvOffset.value.copy(offsetRef.current);
});
// Cleanup
useEffect(() => {
return () => {
geometry.dispose();
material.dispose();
};
}, [geometry, material]);
material.uniforms.uvOffset.value.copy(offsetRef.current);
}
: noop,
);
return (
<mesh geometry={geometry} frustumCulled={false} renderOrder={10}>
<primitive ref={materialRef} object={material} attach="material" />
<primitive object={material} attach="material" />
</mesh>
);
}
@ -439,7 +446,7 @@ export interface CloudLayerConfig {
* 6: Environment map
* 7+: Cloud layer textures
*/
const CLOUD_TEXTURE_OFFSET = 7;
const CLOUD_TEXTURE_START_INDEX = 7;
/**
* Hook to load a DML file.
@ -467,8 +474,6 @@ export interface CloudLayersProps {
* - windVelocity: Wind direction for cloud movement
*/
export function CloudLayers({ object }: CloudLayersProps) {
const { debugMode } = useDebug();
const { animationEnabled } = useSettings();
const materialList = getProperty(object, "materialList");
const { data: detailMapList } = useDetailMapList(materialList);
@ -476,7 +481,6 @@ export function CloudLayers({ object }: CloudLayersProps) {
const visibleDistance = getFloat(object, "visibleDistance") ?? 500;
const radius = visibleDistance * 0.95;
// Extract cloud speeds from object (cloudSpeed1/2/3 are scalar values)
const cloudSpeeds = useMemo(
() => [
getFloat(object, "cloudSpeed1") ?? 0.0001,
@ -486,17 +490,14 @@ export function CloudLayers({ object }: CloudLayersProps) {
[object],
);
// Extract cloud heights from object
// Default heights match typical Tribes 2 values (e.g., 0.35, 0.25, 0.2)
const cloudHeights = useMemo(() => {
const defaults = [0.35, 0.25, 0.2];
const heights: number[] = [];
for (let i = 0; i < 3; i++) {
const height = getFloat(object, `cloudHeightPer${i}`) ?? defaults[i];
heights.push(height);
}
return heights;
}, [object]);
const cloudHeights = useMemo(
() => [
getFloat(object, "cloudHeightPer1") ?? 0.35,
getFloat(object, "cloudHeightPer2") ?? 0.25,
getFloat(object, "cloudHeightPer3") ?? 0.2,
],
[object],
);
// Wind direction from windVelocity
// Torque uses Z-up with windVelocity (x, y, z) where Y is forward.
@ -519,14 +520,13 @@ export function CloudLayers({ object }: CloudLayersProps) {
if (!detailMapList) return [];
const result: CloudLayerConfig[] = [];
for (let i = CLOUD_TEXTURE_OFFSET; i < detailMapList.length; i++) {
const texture = detailMapList[i];
for (let i = 0; i < 3; i++) {
const texture = detailMapList[CLOUD_TEXTURE_START_INDEX + i];
if (texture) {
const layerIndex = i - CLOUD_TEXTURE_OFFSET;
result.push({
texture,
height: cloudHeights[layerIndex] ?? 0,
speed: cloudSpeeds[layerIndex] ?? 0.0001 * (layerIndex + 1),
height: cloudHeights[i],
speed: cloudSpeeds[i],
});
}
}
@ -562,8 +562,6 @@ export function CloudLayers({ object }: CloudLayersProps) {
speed={layer.speed}
windDirection={windDirection}
layerIndex={i}
debugMode={debugMode}
animationEnabled={animationEnabled}
/>
</Suspense>
);

View file

@ -79,8 +79,8 @@ function createMaterialFromFlags(
side: 2, // DoubleSide
transparent: isAdditive,
alphaTest: isAdditive ? 0 : 0.5,
blending: isAdditive ? AdditiveBlending : undefined,
fog: true,
...(isAdditive && { blending: AdditiveBlending }),
});
applyShapeShaderModifications(mat);
return mat;

View file

@ -1,9 +1,11 @@
import { memo, Suspense, useMemo, useCallback } from "react";
import { memo, Suspense, useMemo, useCallback, useEffect, useRef } from "react";
import { ErrorBoundary } from "react-error-boundary";
import {
Mesh,
Material,
MeshStandardMaterial,
MeshBasicMaterial,
MeshLambertMaterial,
Texture,
SRGBColorSpace,
} from "three";
@ -58,23 +60,43 @@ function InteriorTexture({
injectCustomFog(shader, globalFogUniforms);
injectInteriorLighting(shader, {
surfaceOutsideVisible: isSurfaceOutsideVisible,
debugMode,
});
},
[isSurfaceOutsideVisible, debugMode],
[isSurfaceOutsideVisible],
);
// Key includes shader-affecting props to force recompilation when they change
// (r3f doesn't reactively recompile shaders on prop changes)
const materialKey = `${isSurfaceOutsideVisible}-${debugMode}`;
// Refs for forcing shader recompilation
const basicMaterialRef = useRef<MeshBasicMaterial>(null);
const lambertMaterialRef = useRef<MeshLambertMaterial>(null);
// Force shader recompilation when debugMode changes
// r3f doesn't sync defines prop changes, so we update the material directly
useEffect(() => {
const mat = (basicMaterialRef.current ?? lambertMaterialRef.current) as
| (Material & { defines?: Record<string, number> })
| null;
if (mat) {
mat.defines ??= {};
mat.defines.DEBUG_MODE = debugMode ? 1 : 0;
mat.needsUpdate = true;
}
}, [debugMode]);
const defines = { DEBUG_MODE: debugMode ? 1 : 0 };
// Key for shader structure changes (surfaceOutsideVisible affects lighting model)
const materialKey = `${isSurfaceOutsideVisible}`;
// Self-illuminating materials are fullbright (unlit), no lightmap
if (isSelfIlluminating) {
return (
<meshBasicMaterial
ref={basicMaterialRef}
key={materialKey}
map={texture}
toneMapped={false}
// @ts-expect-error - defines exists on Material but R3F types don't expose it
defines={defines}
onBeforeCompile={onBeforeCompile}
/>
);
@ -89,10 +111,13 @@ function InteriorTexture({
// Using FrontSide (default) - normals are fixed in io_dif Blender export
return (
<meshLambertMaterial
ref={lambertMaterialRef}
key={materialKey}
map={texture}
lightMap={lightMap ?? undefined}
toneMapped={false}
// @ts-expect-error - defines exists on Material but R3F types don't expose it
defines={defines}
onBeforeCompile={onBeforeCompile}
/>
);

View file

@ -3,6 +3,7 @@ import {
ReactNode,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -95,7 +96,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
);
// Read persisted settings from localStorage.
useEffect(() => {
useLayoutEffect(() => {
let savedSettings: PersistedSettings = {};
try {
savedSettings = JSON.parse(localStorage.getItem("settings")) || {};

View file

@ -79,6 +79,19 @@ function SkyBoxTexture({
[fogState],
);
// Create stable uniform objects that persist across renders
// This ensures the shader gets updated values when props change
const uniformsRef = useRef({
skybox: { value: skyBox },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: 0.18 },
});
// Calculate the horizon fog cutoff based on visible distance
// In Torque's sky.cc:
// mRadius = visibleDistance * 0.95
@ -106,6 +119,15 @@ function SkyBoxTexture({
);
}, [fogState]);
// Update uniform values when props change
useEffect(() => {
uniformsRef.current.skybox.value = skyBox;
uniformsRef.current.fogColor.value = fogColor ?? new Color(0, 0, 0);
uniformsRef.current.enableFog.value = enableFog;
uniformsRef.current.fogVolumeData.value = fogVolumeData;
uniformsRef.current.horizonFogHeight.value = horizonFogHeight;
}, [skyBox, fogColor, enableFog, fogVolumeData, horizonFogHeight]);
return (
<mesh renderOrder={-1000} frustumCulled={false}>
<bufferGeometry>
@ -123,16 +145,7 @@ function SkyBoxTexture({
/>
</bufferGeometry>
<shaderMaterial
uniforms={{
skybox: { value: skyBox },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: horizonFogHeight },
}}
uniforms={uniformsRef.current}
vertexShader={`
varying vec2 vUv;
@ -339,6 +352,27 @@ function SolidColorSky({
);
}, [fogState]);
// Create stable uniform objects that persist across renders
const uniformsRef = useRef({
skyColor: { value: skyColor },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: horizonFogHeight },
});
// Update uniform values when props change
useEffect(() => {
uniformsRef.current.skyColor.value = skyColor;
uniformsRef.current.fogColor.value = fogColor ?? new Color(0, 0, 0);
uniformsRef.current.enableFog.value = enableFog;
uniformsRef.current.fogVolumeData.value = fogVolumeData;
uniformsRef.current.horizonFogHeight.value = horizonFogHeight;
}, [skyColor, fogColor, enableFog, fogVolumeData, horizonFogHeight]);
return (
<mesh renderOrder={-1000} frustumCulled={false}>
<bufferGeometry>
@ -356,16 +390,7 @@ function SolidColorSky({
/>
</bufferGeometry>
<shaderMaterial
uniforms={{
skyColor: { value: skyColor },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: enableFog },
inverseProjectionMatrix: { value: inverseProjectionMatrix },
cameraMatrixWorld: { value: camera.matrixWorld },
cameraHeight: globalFogUniforms.cameraHeight,
fogVolumeData: { value: fogVolumeData },
horizonFogHeight: { value: horizonFogHeight },
}}
uniforms={uniformsRef.current}
vertexShader={`
varying vec2 vUv;
@ -491,7 +516,13 @@ function calculateFogParameters(
* 2. Volume fog: Height-based fog using per-volume parameters
* Both are combined additively, matching Torque's getHazeAndFog function.
*/
function DynamicFog({ fogState }: { fogState: FogState }) {
function DynamicFog({
fogState,
enabled,
}: {
fogState: FogState;
enabled: boolean;
}) {
const { scene, camera } = useThree();
const fogRef = useRef<Fog | null>(null);
@ -530,6 +561,24 @@ function DynamicFog({ fogState }: { fogState: FogState }) {
};
}, [scene, camera, fogState, fogVolumeData]);
// When fog is disabled, set near=far to effectively disable fog
// without removing scene.fog (which would require shader recompilation)
useEffect(() => {
const fog = fogRef.current;
if (!fog) return;
if (enabled) {
const [near, far] = calculateFogParameters(fogState, camera.position.y);
fog.near = near;
fog.far = far;
} else {
// Setting near = far = large value effectively disables fog
// (fog factor = 0 when distance < near)
fog.near = 1e10;
fog.far = 1e10;
}
}, [enabled, fogState, camera.position.y]);
// Update fog parameters each frame based on camera height
useFrame(() => {
const fog = fogRef.current;
@ -537,14 +586,17 @@ function DynamicFog({ fogState }: { fogState: FogState }) {
const cameraHeight = camera.position.y;
// Update Three.js basic fog
const [near, far] = calculateFogParameters(fogState, cameraHeight);
fog.near = near;
fog.far = far;
fog.color.copy(fogState.fogColor);
// Always update global fog uniforms so shaders know the enabled state
updateGlobalFogUniforms(cameraHeight, fogVolumeData, enabled);
// Update global fog uniforms for volumetric fog shaders
updateGlobalFogUniforms(cameraHeight, fogVolumeData);
if (enabled) {
// Update Three.js basic fog
const [near, far] = calculateFogParameters(fogState, cameraHeight);
fog.near = near;
fog.far = far;
fog.color.copy(fogState.fogColor);
}
// When disabled, fog.near/far are already set to 1e10 by the useEffect
});
return null;
@ -632,7 +684,12 @@ export function Sky({ object }: { object: TorqueObject }) {
<Suspense>
<CloudLayers object={object} />
</Suspense>
{hasFogParams ? <DynamicFog fogState={fogState} /> : null}
{/* Always render DynamicFog when mission has fog params.
Pass fogEnabled to control visibility - this avoids shader recompilation
when toggling fog (USE_FOG stays defined, but fog.near/far disable fog). */}
{fogState.enabled ? (
<DynamicFog fogState={fogState} enabled={fogEnabled} />
) : null}
</>
);
}

View file

@ -0,0 +1,89 @@
import { createContext, ReactNode, useContext, useMemo, useState } from "react";
/**
* Handle for querying terrain data.
*
* TerrainBlock registers this via useEffect, allowing other components
* to query terrain data without prop drilling.
*/
export interface TerrainHandle {
/**
* Query terrain height at world coordinates.
* Coordinates wrap via `& 255` to support infinite terrain tiling,
* matching Torque's FluidSupport.cc:
* i = (((m_SquareY0+Y) & 255) << 8) + ((m_SquareX0+X) & 255);
*/
getHeightAt: (worldX: number, worldZ: number) => number;
/**
* Check if a point is above terrain at given world coordinates.
* Used for water masking (matching Torque's fluid reject mask).
* Coordinates wrap to support infinite terrain tiling.
*/
isAboveTerrain: (worldX: number, worldZ: number, height: number) => boolean;
/**
* Get primary terrain tile bounds in world coordinates.
* Note: Terrain actually tiles infinitely via coordinate wrapping.
*/
getBounds: () => {
minX: number;
maxX: number;
minZ: number;
maxZ: number;
};
}
type StateSetter<T> = ReturnType<typeof useState<T>>[1];
interface TerrainContextValue {
terrain: TerrainHandle | null;
setTerrain: StateSetter<TerrainHandle | null>;
}
const TerrainContext = createContext<TerrainContextValue | null>(null);
interface TerrainProviderProps {
children: ReactNode;
}
/**
* Provider for terrain query handle.
*
* TerrainBlock registers its handle via useEffect on mount, and other
* components (like WaterBlock) can access it to query terrain heights.
*/
export function TerrainProvider({ children }: TerrainProviderProps) {
const [terrain, setTerrain] = useState<TerrainHandle | null>(null);
const context = useMemo(() => ({ terrain, setTerrain }), [terrain]);
return (
<TerrainContext.Provider value={context}>
{children}
</TerrainContext.Provider>
);
}
/**
* Get the terrain handle from context.
* Returns null if no TerrainBlock has registered yet.
*/
export function useTerrainHandle() {
const context = useContext(TerrainContext);
if (!context) {
throw new Error("useTerrainHandle must be used within a TerrainProvider");
}
return context.terrain;
}
/**
* Get the terrain setter for registration.
* Used by TerrainBlock to register its handle.
*/
export function useRegisterTerrain() {
const context = useContext(TerrainContext);
if (!context) {
throw new Error("useRegisterTerrain must be used within a TerrainProvider");
}
return context.setTerrain;
}

View file

@ -1,5 +1,10 @@
import { memo, Suspense, useCallback, useMemo } from "react";
import { type BufferGeometry, DataTexture, FrontSide } from "three";
import { memo, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
import {
type BufferGeometry,
DataTexture,
FrontSide,
type MeshLambertMaterial,
} from "three";
import { useTexture } from "@react-three/drei";
import {
FALLBACK_TEXTURE_URL,
@ -83,7 +88,6 @@ function BlendedTerrainTextures({
alphaTextures,
visibilityMask,
tiling: TILING,
debugMode,
detailTexture: detailTextureUrl ? detailTexture : null,
lightmap,
});
@ -95,28 +99,43 @@ function BlendedTerrainTextures({
baseTextures,
alphaTextures,
visibilityMask,
debugMode,
detailTexture,
detailTextureUrl,
lightmap,
],
);
// Key must include factors that change shader code structure (not just uniforms)
// - debugMode: affects fragment shader branching
// - detailTextureUrl: affects vertex shader (adds varying) and fragment shader
// - lightmap: affects shader structure (uses lightmap for NdotL instead of vertex normals)
const materialKey = `${debugMode ? "debug" : "normal"}-${detailTextureUrl ? "detail" : "nodetail"}-${lightmap ? "lightmap" : "nolightmap"}`;
// Ref for forcing shader recompilation
const materialRef = useRef<MeshLambertMaterial>(null);
// Force shader recompilation when debugMode changes
// r3f doesn't sync defines prop changes, so we update the material directly
useEffect(() => {
const mat = materialRef.current as MeshLambertMaterial & {
defines?: Record<string, number>;
};
if (mat) {
mat.defines ??= {};
mat.defines.DEBUG_MODE = debugMode ? 1 : 0;
mat.needsUpdate = true;
}
}, [debugMode]);
// Key for shader structure changes (detail texture, lightmap)
const materialKey = `${detailTextureUrl ? "detail" : "nodetail"}-${lightmap ? "lightmap" : "nolightmap"}`;
// Displacement is done on CPU, so no displacementMap needed
// We keep 'map' to provide UV coordinates for shader (vMapUv)
// Use MeshLambertMaterial for compatibility with shadow maps
return (
<meshLambertMaterial
ref={materialRef}
key={materialKey}
map={displacementMap}
depthWrite
side={FrontSide}
// @ts-expect-error - defines exists on Material but R3F types don't expose it
defines={{ DEBUG_MODE: debugMode ? 1 : 0 }}
onBeforeCompile={onBeforeCompile}
/>
);

View file

@ -1,13 +1,30 @@
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { memo, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { Box, useTexture } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { DoubleSide, NoColorSpace, PlaneGeometry, RepeatWrapping } from "three";
import { textureToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import {
getFloat,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import { setupColor } from "../textureUtils";
import { createWaterMaterial } from "../waterMaterial";
import { useSettings } from "./SettingsProvider";
import { useDebug, useSettings } from "./SettingsProvider";
import { usePositionTracker } from "./usePositionTracker";
const REP_SIZE = 2048;
/**
* Terrain coordinate offset from world space.
* In Tribes 2, terrain coordinates are offset by +1024 from world coordinates.
* This allows world positions like (-672, -736) to map to valid terrain
* positions (352, 288) after the offset.
*/
const TERRAIN_OFFSET = 1024;
/**
* Calculate tessellation to match Tribes 2 engine.
@ -35,81 +52,6 @@ function calculateWaterSegments(
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);
// Use NoColorSpace for water textures - our custom ShaderMaterial
// outputs values that are already in the correct space for display.
// Using SRGBColorSpace would cause double-conversion.
tex.colorSpace = NoColorSpace;
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.
*/
@ -145,26 +87,117 @@ export function WaterMaterial({
*
* Unlike a simple box, we use a subdivided PlaneGeometry for the water surface
* so that vertex displacement can create visible waves.
*
* Water tiling follows Torque's FluidQuadTree.cc behavior:
* - Determines camera's terrain "rep" via I = (s32)(m_Eye.X / 2048.0f)
* - Renders 9 reps (3x3 grid) centered on camera's rep
*/
export const WaterBlock = memo(function WaterBlock({
object,
}: {
object: TorqueObject;
}) {
const position = useMemo(() => getPosition(object), [object]);
const { debugMode } = useDebug();
const q = useMemo(() => getRotation(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const [scaleX, scaleY, scaleZ] = scale;
const camera = useThree((state) => state.camera);
const hasCameraPositionChanged = usePositionTracker();
// Water surface height (top of water volume)
// TODO: Use this for terrain intersection masking (reject water blocks where
// terrain height > surfaceZ + waveMagnitude/2). Requires TerrainProvider.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const surfaceZ = position[1] + scaleY;
// Wave magnitude affects terrain masking (Torque adds half to surface height)
const waveMagnitude = getFloat(object, "waveMagnitude") ?? 1.0;
// Convert world position to terrain space and snap to grid.
// Matches Torque's UpdateFluidRegion() and fluid::SetInfo():
// 1. Add TERRAIN_OFFSET (1024) to convert world -> terrain space
// 2. Round to nearest terrain square: (s32)((X0 / 8.0f) + 0.5f) = Math.round(x/8)
// 3. Clamp to valid terrain range [0, 2040] squares
// 4. Convert back to world units (multiply by 8)
const basePosition = useMemo(() => {
const [x, y, z] = position;
// Convert to terrain space (add 1024 offset)
const terrainX = x + TERRAIN_OFFSET;
const terrainZ = z + TERRAIN_OFFSET;
// Round to terrain squares: (s32)((X0 / 8.0f) + 0.5f) is Math.round()
let squareX = Math.round(terrainX / 8);
let squareZ = Math.round(terrainZ / 8);
// Clamp to valid range [0, 2040] as in fluidSupport.cc
squareX = Math.max(0, Math.min(2040, squareX));
squareZ = Math.max(0, Math.min(2040, squareZ));
// Convert back to world units (terrain squares * 8)
// This is the fluid's anchor point in terrain space
const baseX = squareX * 8;
const baseZ = squareZ * 8;
return [baseX, y, baseZ] as [number, number, number];
}, [position]);
// Calculate 3x3 grid of reps centered on camera position.
// ALL water blocks use 9-rep tiling - the masking system handles visibility.
// Matches fluidQuadTree.cc RunQuadTree():
// I = (s32)(m_Eye.X / 2048.0f);
// if( m_Eye.X < 0.0f ) I--;
const calculateReps = (camX: number, camZ: number): Array<[number, number]> => {
// Convert camera to terrain space
const terrainCamX = camX + TERRAIN_OFFSET;
const terrainCamZ = camZ + TERRAIN_OFFSET;
// Determine camera's terrain rep using terrain coordinates.
// Torque uses: I = (s32)(m_Eye.X / 2048.0f); if (m_Eye.X < 0) I--;
// This is truncation toward zero, then decrement for negative.
let cameraRepX = Math.trunc(terrainCamX / REP_SIZE);
let cameraRepZ = Math.trunc(terrainCamZ / REP_SIZE);
if (terrainCamX < 0) cameraRepX--;
if (terrainCamZ < 0) cameraRepZ--;
// Build 3x3 grid of reps around camera
const newReps: Array<[number, number]> = [];
for (let repZ = cameraRepZ - 1; repZ <= cameraRepZ + 1; repZ++) {
for (let repX = cameraRepX - 1; repX <= cameraRepX + 1; repX++) {
newReps.push([repX, repZ]);
}
}
return newReps;
};
// Track which reps to render, updated each frame based on camera position.
// Initialize with current camera position so water is visible immediately.
const [reps, setReps] = useState<Array<[number, number]>>(() =>
calculateReps(camera.position.x, camera.position.z),
);
useFrame(() => {
if (!hasCameraPositionChanged(camera.position)) {
return;
}
const newReps = calculateReps(camera.position.x, camera.position.z);
// Only update state if reps actually changed (avoid unnecessary re-renders)
setReps((prevReps) => {
if (JSON.stringify(prevReps) === JSON.stringify(newReps)) {
return prevReps;
}
return newReps;
});
});
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 opacity = getFloat(object, "surfaceOpacity") ?? 0.75;
const envMapIntensity = getFloat(object, "envMapIntensity") ?? 1.0;
// Create subdivided plane geometry for the water surface
// Tessellation matches Tribes 2 engine (5x5 vertices per block)
@ -177,8 +210,8 @@ export const WaterBlock = memo(function WaterBlock({
// 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)
// Position at top of water volume (Y = scaleY)
// Offset by half scale to put corner at origin (position is corner after modulo)
geom.translate(scaleX / 2, scaleY, scaleZ / 2);
return geom;
@ -190,30 +223,148 @@ export const WaterBlock = memo(function WaterBlock({
};
}, [surfaceGeometry]);
// Render each rep that overlaps with terrain bounds
return (
<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}
/>
}
<group quaternion={q}>
{/* Debug wireframe showing actual water block bounds */}
{debugMode && (
<Box
args={scale}
position={[
position[0] + scaleX / 2,
position[1] + scaleY / 2,
position[2] + scaleZ / 2,
]}
>
<WaterSurfaceMaterial
attach="material"
surfaceTexture={surfaceTexture}
envMapTexture={envMapTexture}
opacity={opacity}
waveMagnitude={waveMagnitude}
envMapIntensity={envMapIntensity}
/>
</Suspense>
</mesh>
<meshBasicMaterial color="#00fbff" wireframe />
</Box>
)}
<Suspense
fallback={reps.map(([repX, repZ]) => {
// Convert from terrain space to world space by subtracting TERRAIN_OFFSET
// Matches Torque's L2Wm transform: L2Wv = (-1024, -1024, 0)
const worldX = basePosition[0] + repX * REP_SIZE - TERRAIN_OFFSET;
const worldZ = basePosition[2] + repZ * REP_SIZE - TERRAIN_OFFSET;
return (
<mesh
key={`${repX},${repZ}`}
geometry={surfaceGeometry}
position={[worldX, basePosition[1], worldZ]}
>
<meshStandardMaterial
color="#00fbff"
transparent
opacity={0.4}
wireframe
side={DoubleSide}
/>
</mesh>
);
})}
>
<WaterReps
reps={reps}
basePosition={basePosition}
surfaceGeometry={surfaceGeometry}
surfaceTexture={surfaceTexture}
envMapTexture={envMapTexture}
opacity={opacity}
waveMagnitude={waveMagnitude}
envMapIntensity={envMapIntensity}
/>
</Suspense>
</group>
);
});
/**
* Inner component that renders all water reps with a shared material.
* Separated to allow Suspense boundary around texture loading while
* ensuring all reps share the same material instance and animation.
*/
const WaterReps = memo(function WaterReps({
reps,
basePosition,
surfaceGeometry,
surfaceTexture,
envMapTexture,
opacity,
waveMagnitude,
envMapIntensity,
}: {
reps: Array<[number, number]>;
basePosition: [number, number, number];
surfaceGeometry: PlaneGeometry;
surfaceTexture: string;
envMapTexture: string | undefined;
opacity: number;
waveMagnitude: number;
envMapIntensity: number;
}) {
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.colorSpace = NoColorSpace;
tex.wrapS = RepeatWrapping;
tex.wrapT = RepeatWrapping;
});
},
);
const { animationEnabled } = useSettings();
// Single shared material for all water reps
const material = useMemo(() => {
return createWaterMaterial({
opacity,
waveMagnitude,
envMapIntensity,
baseTexture,
envMapTexture: envTexture,
});
}, [opacity, waveMagnitude, envMapIntensity, baseTexture, envTexture]);
// Single animation loop for the shared material
const elapsedRef = useRef(0);
useFrame((_, delta) => {
if (!animationEnabled) {
elapsedRef.current = 0;
material.uniforms.uTime.value = 0;
} else {
elapsedRef.current += delta;
material.uniforms.uTime.value = elapsedRef.current;
}
});
useEffect(() => {
return () => {
material.dispose();
};
}, [material]);
return (
<>
{reps.map(([repX, repZ]) => {
// Convert from terrain space to world space by subtracting TERRAIN_OFFSET
// Matches Torque's L2Wm transform: L2Wv = (-1024, -1024, 0)
const worldX = basePosition[0] + repX * REP_SIZE - TERRAIN_OFFSET;
const worldZ = basePosition[2] + repZ * REP_SIZE - TERRAIN_OFFSET;
return (
<mesh
key={`${repX},${repZ}`}
geometry={surfaceGeometry}
material={material}
position={[worldX, basePosition[1], worldZ]}
/>
);
})}
</>
);
});

View file

@ -0,0 +1,25 @@
import { useCallback, useRef } from "react";
import { Vector3 } from "three";
export function usePositionTracker() {
const positionRef = useRef<Vector3>(null);
const hasChanged = useCallback((position: Vector3) => {
if (!positionRef.current) {
positionRef.current = position.clone();
return true;
}
const isSamePosition =
positionRef.current.x === position.x &&
positionRef.current.y === position.y &&
positionRef.current.z === position.z;
if (!isSamePosition) {
positionRef.current.copy(position);
}
return isSamePosition;
}, []);
return hasChanged;
}

112
src/file.vert Normal file
View file

@ -0,0 +1,112 @@
#ifdef USE_FOG
// Check runtime fog enabled uniform - allows toggling without shader recompilation
if (fogEnabled) {
// Fog disabled at runtime, skip all fog calculations
} else {
float dist = vFogDepth;
// Discard fragments at or beyond visible distance - matches Torque's behavior
// where objects beyond visibleDistance are not rendered at all.
// This prevents fully-fogged geometry from showing as silhouettes against
// the sky's fog-to-sky gradient.
if (dist >= fogFar) {
discard;
}
// Step 1: Calculate distance-based haze (quadratic falloff)
// Since we discard at fogFar, haze never reaches 1.0 here
float haze = 0.0;
if (dist > fogNear) {
float fogScale = 1.0 / (fogFar - fogNear);
float distFactor = (dist - fogNear) * fogScale - 1.0;
haze = 1.0 - distFactor * distFactor;
}
// Step 2: Calculate fog volume contributions
// Note: Per-volume colors are NOT used in Tribes 2 ($specialFog defaults to false)
// All fog uses the global fogColor - see Tribes2_Fog_System.md for details
float volumeFog = 0.0;
#ifdef USE_VOLUMETRIC_FOG
{
#ifdef USE_FOG_WORLD_POSITION
float fragmentHeight = vFogWorldPosition.y;
#else
float fragmentHeight = cameraHeight;
#endif
float deltaY = fragmentHeight - cameraHeight;
float absDeltaY = abs(deltaY);
// Determine if we're going up (positive) or down (negative)
if (absDeltaY > 0.01) {
// Non-horizontal ray: ray-march through fog volumes
for (int i = 0; i < 3; i++) {
int offset = i * 4;
float volVisDist = allFogVolumes[offset + 0];
float volMinH = allFogVolumes[offset + 1];
float volMaxH = allFogVolumes[offset + 2];
float volPct = allFogVolumes[offset + 3];
// Skip inactive volumes (visibleDistance = 0)
if (volVisDist <= 0.0) continue;
// Calculate fog factor for this volume
// From Torque: factor = (1 / (volumeVisDist * visFactor)) * percentage
// where visFactor is smVisibleDistanceMod (a user quality pref, default 1.0)
// Since we don't have quality settings, we use visFactor = 1.0
float factor = (1.0 / volVisDist) * volPct;
// Find ray intersection with this volume's height range
float rayMinY = min(cameraHeight, fragmentHeight);
float rayMaxY = max(cameraHeight, fragmentHeight);
// Check if ray intersects volume height range
if (rayMinY < volMaxH && rayMaxY > volMinH) {
float intersectMin = max(rayMinY, volMinH);
float intersectMax = min(rayMaxY, volMaxH);
float intersectHeight = intersectMax - intersectMin;
// Calculate distance traveled through this volume using similar triangles:
// subDist / dist = intersectHeight / absDeltaY
float subDist = dist * (intersectHeight / absDeltaY);
// Accumulate fog: fog += subDist * factor
volumeFog += subDist * factor;
}
}
} else {
// Near-horizontal ray: if camera is inside a volume, apply full fog for that volume
for (int i = 0; i < 3; i++) {
int offset = i * 4;
float volVisDist = allFogVolumes[offset + 0];
float volMinH = allFogVolumes[offset + 1];
float volMaxH = allFogVolumes[offset + 2];
float volPct = allFogVolumes[offset + 3];
if (volVisDist <= 0.0) continue;
// If camera is inside this volume, apply fog for full distance
if (cameraHeight >= volMinH && cameraHeight <= volMaxH) {
float factor = (1.0 / volVisDist) * volPct;
volumeFog += dist * factor;
}
}
}
}
#endif
// Step 3: Combine haze and volume fog
// Torque's clamping: if (bandPct + hazePct > 1) hazePct = 1 - bandPct
// This gives fog volumes priority over haze
float volPct = min(volumeFog, 1.0);
float hazePct = haze;
if (volPct + hazePct > 1.0) {
hazePct = 1.0 - volPct;
}
float fogFactor = hazePct + volPct;
// Apply fog using global fogColor (per-volume colors not used in Tribes 2)
gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, fogFactor);
}
#endif

View file

@ -58,6 +58,13 @@ export const fogUniformsDeclaration = `
*/
export const fogFragmentShader = `
#ifdef USE_FOG
// Check fog enabled uniform - allows toggling without shader recompilation
#ifdef USE_VOLUMETRIC_FOG
if (!fogEnabled) {
// Skip all fog calculations when disabled
} else {
#endif
float dist = vFogDepth;
// Discard fragments at or beyond visible distance - matches Torque's behavior
@ -163,6 +170,10 @@ export const fogFragmentShader = `
// Apply fog using global fogColor (per-volume colors not used in Tribes 2)
gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, fogFactor);
#ifdef USE_VOLUMETRIC_FOG
} // end fogEnabled check
#endif
#endif
`;
@ -245,6 +256,7 @@ export function installCustomFogShader(): void {
export interface FogShaderUniformObjects {
fogVolumeData: { value: Float32Array };
cameraHeight: { value: number };
fogEnabled: { value: boolean };
}
/**
@ -261,6 +273,7 @@ export function addFogUniformsToShader(
// Pass the uniform objects directly so they stay linked to FogProvider updates
shader.uniforms.fogVolumeData = fogUniforms.fogVolumeData;
shader.uniforms.cameraHeight = fogUniforms.cameraHeight;
shader.uniforms.fogEnabled = fogUniforms.fogEnabled;
}
/**
@ -309,6 +322,7 @@ export function injectCustomFog(
#define USE_VOLUMETRIC_FOG
uniform float fogVolumeData[12];
uniform float cameraHeight;
uniform bool fogEnabled;
#define USE_FOG_WORLD_POSITION
varying vec3 vFogWorldPosition;
#endif`,

View file

@ -22,6 +22,7 @@ export const globalFogUniforms = {
value: new Float32Array(MAX_FOG_VOLUMES * FLOATS_PER_VOLUME),
},
cameraHeight: { value: 0 },
fogEnabled: { value: true },
};
/**
@ -31,9 +32,11 @@ export const globalFogUniforms = {
export function updateGlobalFogUniforms(
cameraHeight: number,
fogVolumeData: Float32Array,
enabled: boolean = true,
): void {
globalFogUniforms.cameraHeight.value = cameraHeight;
globalFogUniforms.fogVolumeData.value.set(fogVolumeData);
globalFogUniforms.fogEnabled.value = enabled;
}
/**
@ -43,6 +46,7 @@ export function updateGlobalFogUniforms(
export function resetGlobalFogUniforms(): void {
globalFogUniforms.cameraHeight.value = 0;
globalFogUniforms.fogVolumeData.value.fill(0);
globalFogUniforms.fogEnabled.value = true;
}
/**

View file

@ -28,7 +28,6 @@ import { Vector3 } from "three";
export type InteriorLightingOptions = {
surfaceOutsideVisible?: boolean;
debugMode?: boolean;
};
// sRGB <-> Linear conversion functions (GLSL)
@ -57,18 +56,16 @@ float debugGrid(vec2 uv, float gridSize, float lineWidth) {
export function injectInteriorLighting(
shader: any,
options?: InteriorLightingOptions,
options: InteriorLightingOptions,
): void {
const isOutsideVisible = options?.surfaceOutsideVisible ?? false;
const isDebugMode = options?.debugMode ?? false;
const isOutsideVisible = options.surfaceOutsideVisible ?? false;
// Outside surfaces: scene lighting + lightmap
// Inside surfaces: lightmap only (no scene lighting)
shader.uniforms.useSceneLighting = { value: isOutsideVisible };
// Debug mode uniforms
shader.uniforms.interiorDebugMode = { value: isDebugMode };
// Blue for outside visible, red for inside
// Debug color: blue for outside visible, red for inside
// Only used when DEBUG_MODE define is set
shader.uniforms.interiorDebugColor = {
value: isOutsideVisible
? new Vector3(0.0, 0.4, 1.0)
@ -81,7 +78,6 @@ export function injectInteriorLighting(
`#include <common>
${colorSpaceFunctions}
uniform bool useSceneLighting;
uniform bool interiorDebugMode;
uniform vec3 interiorDebugColor;
`,
);
@ -150,12 +146,10 @@ outgoingLight = resultLinear + totalEmissiveRadiance;
`// Debug mode: overlay colored grid on top of normal rendering
// Blue grid = SurfaceOutsideVisible (receives scene ambient light)
// Red grid = inside surface (no scene ambient light)
#ifdef USE_MAP
if (interiorDebugMode) {
#if DEBUG_MODE && defined(USE_MAP)
// gridSize=4 creates 4x4 grid per UV tile, lineWidth=1.5 is ~1.5 pixels wide
float gridIntensity = debugGrid(vMapUv, 4.0, 1.5);
gl_FragColor.rgb = mix(gl_FragColor.rgb, interiorDebugColor, gridIntensity * 0.1);
}
#endif
#include <tonemapping_fragment>`,

127
src/skyMaterial.ts Normal file
View file

@ -0,0 +1,127 @@
import { ShaderMaterial } from "three";
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.9999, 1.0);
}
`;
const fragmentShader = /*glsl*/ `
uniform samplerCube skybox;
uniform vec3 fogColor;
uniform bool enableFog;
uniform mat4 inverseProjectionMatrix;
uniform mat4 cameraMatrixWorld;
uniform float cameraHeight;
uniform float fogVolumeData[12];
uniform float horizonFogHeight;
varying vec2 vUv;
void main() {
vec2 ndc = vUv * 2.0 - 1.0;
vec4 viewPos = inverseProjectionMatrix * vec4(ndc, 1.0, 1.0);
viewPos.xyz /= viewPos.w;
vec3 direction = normalize((cameraMatrixWorld * vec4(viewPos.xyz, 0.0)).xyz);
direction = vec3(direction.z, direction.y, -direction.x);
// Sample skybox - Three.js CubeTexture with SRGBColorSpace auto-converts to linear
vec4 skyColor = textureCube(skybox, direction);
vec3 finalColor;
if (enableFog) {
// fogColor is passed in linear space (converted in Sky.tsx)
// Calculate how much fog volume the ray passes through
// For skybox at "infinite" distance, the relevant height is how much
// of the volume is above/below camera depending on view direction
float volumeFogInfluence = 0.0;
for (int i = 0; i < 3; i++) {
int offset = i * 4;
float volVisDist = fogVolumeData[offset + 0];
float volMinH = fogVolumeData[offset + 1];
float volMaxH = fogVolumeData[offset + 2];
float volPct = fogVolumeData[offset + 3];
if (volVisDist <= 0.0) continue;
// Check if camera is inside this volume
if (cameraHeight >= volMinH && cameraHeight <= volMaxH) {
// Camera is inside the fog volume
// Looking horizontally or up at shallow angles means ray travels
// through more fog before exiting the volume
float heightAboveCamera = volMaxH - cameraHeight;
float heightBelowCamera = cameraHeight - volMinH;
float volumeHeight = volMaxH - volMinH;
// For horizontal rays (direction.y ≈ 0), maximum fog influence
// For rays going up steeply, less fog (exits volume quickly)
// For rays going down, more fog (travels through volume below)
float rayInfluence;
if (direction.y >= 0.0) {
// Looking up: influence based on how steep we're looking
// Shallow angles = long path through fog = high influence
rayInfluence = 1.0 - smoothstep(0.0, 0.3, direction.y);
} else {
// Looking down: always high fog (into the volume)
rayInfluence = 1.0;
}
// Scale by percentage and volume depth factor
volumeFogInfluence += rayInfluence * volPct;
}
}
// Base fog factor from view direction (for haze at horizon)
// In Torque, the fog "bans" (bands) are rendered as geometry from
// height 0 (HORIZON) to height 60 (OFFSET_HEIGHT) on the skybox.
// The skybox corner is at mSkyBoxPt.x = mRadius / sqrt(3).
//
// horizonFogHeight is the direction.y value where the fog band ends:
// horizonFogHeight = 60 / sqrt(skyBoxPt.x^2 + 60^2)
//
// For Firestorm (visDist=600): mRadius=570, skyBoxPt.x=329, horizonFogHeight≈0.18
//
// Torque renders the fog bands as geometry with linear vertex alpha
// interpolation. We use a squared curve (t^2) to create a gentler
// falloff at the top of the gradient, matching Tribes 2's appearance.
float baseFogFactor;
if (direction.y <= 0.0) {
// Looking at or below horizon: full fog
baseFogFactor = 1.0;
} else if (direction.y >= horizonFogHeight) {
// Above fog band: no fog
baseFogFactor = 0.0;
} else {
// Within fog band: squared curve for gentler falloff at top
float t = direction.y / horizonFogHeight;
baseFogFactor = (1.0 - t) * (1.0 - t);
}
// Combine base fog with volume fog influence
// When inside a volume, increase fog intensity
float finalFogFactor = min(1.0, baseFogFactor + volumeFogInfluence * 0.5);
// Mix in linear space (skybox and fogColor are both linear)
finalColor = mix(skyColor.rgb, fogColor, finalFogFactor);
} else {
finalColor = skyColor.rgb;
}
gl_FragColor = vec4(finalColor, 1.0);
#include <colorspace_fragment>
}
`;
export function createSkyBoxMaterial({ uniforms }) {
return new ShaderMaterial({
uniforms,
vertexShader,
fragmentShader,
depthWrite: false,
depthTest: false,
});
}

View file

@ -57,7 +57,6 @@ export function updateTerrainTextureShader({
alphaTextures,
visibilityMask,
tiling,
debugMode = false,
detailTexture = null,
lightmap = null,
}: {
@ -66,7 +65,6 @@ export function updateTerrainTextureShader({
alphaTextures: any[];
visibilityMask: any;
tiling: Record<number, number>;
debugMode?: boolean;
detailTexture?: any;
lightmap?: any;
}) {
@ -94,8 +92,6 @@ export function updateTerrainTextureShader({
};
});
// Add debug mode uniform
shader.uniforms.debugMode = { value: debugMode ? 1.0 : 0.0 };
// Add lightmap uniform for smooth per-pixel terrain lighting
if (lightmap) {
@ -141,7 +137,6 @@ uniform float tiling2;
uniform float tiling3;
uniform float tiling4;
uniform float tiling5;
uniform float debugMode;
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
${lightmap ? "uniform sampler2D terrainLightmap;" : ""}
${
@ -344,14 +339,15 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
);
// Add debug grid overlay AFTER opaque_fragment sets gl_FragColor
// Uses #if so material.defines.DEBUG_MODE (0 or 1) can trigger recompilation
shader.fragmentShader = shader.fragmentShader.replace(
"#include <tonemapping_fragment>",
`// Debug mode: overlay green grid matching terrain grid squares (256x256)
if (debugMode > 0.5) {
`#if DEBUG_MODE
// Debug mode: overlay green grid matching terrain grid squares (256x256)
float gridIntensity = terrainDebugGrid(vMapUv, 256.0, 1.5);
vec3 gridColor = vec3(0.0, 0.8, 0.4); // Green
gl_FragColor.rgb = mix(gl_FragColor.rgb, gridColor, gridIntensity * 0.05);
}
#endif
#include <tonemapping_fragment>`,
);

View file

@ -75,18 +75,32 @@ export interface RunServerResult {
export function runServer(options: RunServerOptions): RunServerResult {
const { missionName, missionType, runtimeOptions, onMissionLoadDone } =
options;
const { signal, fileSystem } = runtimeOptions ?? {};
const {
signal,
fileSystem,
globals = {},
preloadScripts = [],
} = runtimeOptions ?? {};
// server.cs has a loop that calls `findFirstFile("scripts/*Game.cs")` and
// runs `exec()` on each resulting glob match. Since we can't statically
// analyze dynamic exec paths, we need to preload all game scripts in the same
// way (so they're available when exec() is called). We could assume that we
// only need some (like DefaultGame.cs and the one for our game type), but
// sometimes map authors bundle a custom script that they don't exec() in the
// .mis file, instead preferring to give it a "*Game.cs" name so it's loaded
// automatically.
const gameScripts = fileSystem.findFiles("scripts/*Game.cs");
const runtime = createRuntime({
...runtimeOptions,
globals: {
...runtimeOptions?.globals,
...globals,
"$Host::Map": missionName,
"$Host::MissionType": missionType,
},
preloadScripts: [...preloadScripts, ...gameScripts],
});
const gameTypeName = `${missionType}Game`;
const gameTypeScript = `scripts/${gameTypeName}.cs`;
const ready = (async function createServer() {
try {
@ -94,29 +108,6 @@ export function runServer(options: RunServerOptions): RunServerResult {
const serverScript = await runtime.loadFromPath("scripts/server.cs");
signal?.throwIfAborted();
// server.cs has a glob loop that does: findFirstFile("scripts/*Game.cs")
// and then exec()s each result dynamically. Since we can't statically
// analyze dynamic exec paths, we need to either preload all game scripts
// in the same way (so they're available when exec() is called) or just
// exec() the ones we know we need...
//
// To load them all, do:
// if (fileSystem) {
// const gameScripts = fileSystem.findFiles("scripts/*Game.cs");
// await Promise.all(
// gameScripts.map((path) => runtime.loadFromPath(path)),
// );
// signal?.throwIfAborted();
// }
await runtime.loadFromPath("scripts/DefaultGame.cs");
signal?.throwIfAborted();
try {
await runtime.loadFromPath(gameTypeScript);
} catch (err) {
// It's OK if that one fails. Not every game type needs its own script.
}
signal?.throwIfAborted();
// Also preload the mission file (another dynamic exec path)
await runtime.loadFromPath(`missions/${missionName}.mis`);
signal?.throwIfAborted();

View file

@ -93,6 +93,7 @@ const fragmentShader = /* glsl */ `
#ifdef USE_FOG
uniform float fogVolumeData[12];
uniform float cameraHeight;
uniform bool fogEnabled;
varying vec3 vFogWorldPosition;
#endif
@ -210,6 +211,7 @@ export function createWaterMaterial(options?: {
// Volumetric fog uniforms (shared with global fog system)
fogVolumeData: globalFogUniforms.fogVolumeData,
cameraHeight: globalFogUniforms.cameraHeight,
fogEnabled: globalFogUniforms.fogEnabled,
},
vertexShader,
fragmentShader,