improve lighting, fog, clouds, force fields

This commit is contained in:
Brian Beck 2025-12-09 14:59:47 -08:00
parent 3ba1ce9afd
commit a4b7021acc
40 changed files with 4046 additions and 291 deletions

View file

@ -11,7 +11,7 @@ import {
Texture,
RepeatWrapping,
LinearFilter,
SRGBColorSpace,
NoColorSpace,
Group,
} from "three";
import { loadDetailMapList, textureToUrl } from "../loaders";
@ -119,8 +119,11 @@ function createCloudGeometry(
positions[idx * 3 + 2] = z;
// UV coordinates for texture (will be offset for scrolling)
uvs[idx * 2] = col / (GRID_SIZE - 1);
uvs[idx * 2 + 1] = row / (GRID_SIZE - 1);
// Torque uses mTextureScale default of (1, 1), which with x/y going 0-4
// gives UV range 0-4, tiling the texture 4 times across the dome.
// This creates the swirly detail effect visible in Tribes 2.
uvs[idx * 2] = col; // 0 to 4 (tiles 4x)
uvs[idx * 2 + 1] = row; // 0 to 4 (tiles 4x)
}
}
@ -245,13 +248,17 @@ function adjustCorners(positions: Float32Array): void {
/**
* Setup cloud texture with proper wrapping and filtering.
* Uses NoColorSpace to pass values through directly without conversion,
* matching Torque's gamma-space rendering pipeline.
*/
function setupCloudTexture(texture: Texture): Texture {
texture.wrapS = RepeatWrapping;
texture.wrapT = RepeatWrapping;
texture.minFilter = LinearFilter;
texture.magFilter = LinearFilter;
texture.colorSpace = SRGBColorSpace;
// NoColorSpace: values pass through directly without sRGB conversion.
// Torque didn't do color space conversion - textures went straight to display.
texture.colorSpace = NoColorSpace;
texture.needsUpdate = true;
return texture;
}
@ -484,12 +491,16 @@ export function CloudLayers({ object }: CloudLayersProps) {
}, [object]);
// Wind direction from windVelocity
// Torque uses Z-up with windVelocity (x, y, z) where Y is forward.
// Our cloud geometry has UV U along world X, UV V along world Z.
// Rotate 90 degrees clockwise to match Torque's coordinate system.
const windDirection = useMemo(() => {
const windVelocity = getProperty(object, "windVelocity");
if (windVelocity) {
const [x, y] = windVelocity.split(" ").map((s: string) => parseFloat(s));
if (x !== 0 || y !== 0) {
return new Vector2(x, y).normalize();
// Rotate 90 degrees clockwise: (x, y) -> (y, -x)
return new Vector2(y, -x).normalize();
}
}
return new Vector2(1, 0);

View file

@ -0,0 +1,303 @@
/**
* FogProvider - Manages Tribes 2 fog state and provides fog uniforms to materials.
*
* Tribes 2 has two fog systems:
* 1. Distance-based haze: Global fog from fogDistance to visibleDistance with quadratic falloff
* 2. Height-based volumetric fog: Up to 3 fog volumes with independent height ranges and colors
*
* The fog density depends on how much of the view ray passes through each fog volume,
* which varies based on camera height relative to volume boundaries.
*/
import {
createContext,
useContext,
useMemo,
useRef,
type ReactNode,
} from "react";
import { useFrame } from "@react-three/fiber";
import { Color } from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getProperty } from "../mission";
/** Maximum number of fog volumes supported (matches Torque) */
export const MAX_FOG_VOLUMES = 3;
/** Floats per fog volume in shader uniform: [visDist, minH, maxH, percentage] */
const FLOATS_PER_VOLUME = 4;
/**
* A single fog volume with height boundaries and visibility settings.
*
* Note: Per-volume colors are NOT used in Tribes 2 ($specialFog defaults to false).
* All fog uses the global fogColor regardless of fogVolumeColor values in mission files.
*/
export interface FogVolume {
/** Distance at which objects are fully obscured within this volume */
visibleDistance: number;
/** Bottom height boundary of the fog volume */
minHeight: number;
/** Top height boundary of the fog volume */
maxHeight: number;
/** Fog density percentage (0-1), can be animated for storm effects */
percentage: number;
}
/** Complete fog state parsed from a Sky object */
export interface FogState {
/** Distance at which fog starts (near plane) */
fogDistance: number;
/** Distance at which fog is fully opaque (far plane) */
visibleDistance: number;
/** Color for distance-based haze */
fogColor: Color;
/** Height-based fog volumes (up to 3) */
fogVolumes: FogVolume[];
/** Highest point of any fog volume (used for optimization) */
fogLine: number;
/** Whether fog is enabled */
enabled: boolean;
}
/** Fog uniforms to pass to shaders */
export interface FogUniforms {
/** Distance fog near plane */
fogNear: number;
/** Distance fog far plane */
fogFar: number;
/** Distance fog color (linear color space) */
fogColor: Color;
/** Fog volume data as flat array for shader: [visDist, minH, maxH, percentage] x 3 = 12 floats */
fogVolumeData: Float32Array;
/** Current camera Y position */
cameraHeight: number;
/** Whether volumetric fog is active */
hasVolumetricFog: boolean;
}
const FogContext = createContext<FogState | null>(null);
const FogUniformsContext =
createContext<React.MutableRefObject<FogUniforms> | null>(null);
/**
* Parse a Tribes 2 color string (space-separated RGB or RGBA values 0-1).
*
* Torque (2001) worked in gamma space - colors were specified as they should
* appear on screen. Three.js expects linear colors (it converts to sRGB on output).
* We convert sRGB->linear so the final output matches the intended appearance.
*/
function parseColor(colorString: string | undefined): Color {
if (!colorString) return new Color(0.5, 0.5, 0.5);
const parts = colorString.split(" ").map((s) => parseFloat(s));
const [r, g, b] = parts;
// Convert from sRGB (how Torque specified colors) to linear (what Three.js expects)
return new Color().setRGB(r, g, b).convertSRGBToLinear();
}
/**
* Parse a fog volume property string.
* Format: "visibleDistance minHeight maxHeight"
*
* Note: fogVolumeColor is intentionally not parsed - per-volume colors are
* NOT used in Tribes 2 ($specialFog defaults to false). All fog uses fogColor.
*/
function parseFogVolume(
volumeStr: string | undefined,
percentage: number = 1.0,
): FogVolume | null {
if (!volumeStr) return null;
const parts = volumeStr.split(" ").map((s) => parseFloat(s));
if (parts.length < 3) return null;
const [visibleDistance, minHeight, maxHeight] = parts;
// Volume is invalid if visibleDistance is 0 or heights are equal
if (visibleDistance <= 0 || maxHeight <= minHeight) return null;
return {
visibleDistance,
minHeight,
maxHeight,
percentage: Math.max(0, Math.min(1, percentage)),
};
}
/**
* Parse fog state from a Sky TorqueObject.
* @param object - The Sky TorqueObject containing fog properties
* @param highQuality - If true, use high_ fog distance variants when available
*/
export function parseFogState(
object: TorqueObject,
highQuality: boolean = true,
): FogState {
// Distance-based fog parameters
const fogDistanceBase = getFloat(object, "fogDistance") ?? 0;
const visibleDistanceBase = getFloat(object, "visibleDistance") ?? 1000;
const highFogDistance = getFloat(object, "high_fogDistance");
const highVisibleDistance = getFloat(object, "high_visibleDistance");
// Use high_ variants if highQuality is enabled and they're available
const fogDistance =
highQuality && highFogDistance != null && highFogDistance > 0
? highFogDistance
: fogDistanceBase;
const visibleDistance =
highQuality && highVisibleDistance != null && highVisibleDistance > 0
? highVisibleDistance
: visibleDistanceBase;
const fogColor = parseColor(getProperty(object, "fogColor"));
// Parse fog volumes (up to 3)
// Note: fogVolumeColor is intentionally not parsed - see parseFogVolume comment
const fogVolumes: FogVolume[] = [];
for (let i = 1; i <= MAX_FOG_VOLUMES; i++) {
const volume = parseFogVolume(
getProperty(object, `fogVolume${i}`),
1.0, // Default percentage, could parse from storm fog state
);
if (volume) {
fogVolumes.push(volume);
}
}
// Calculate fog line (highest point of any fog volume)
const fogLine = fogVolumes.reduce(
(max, vol) => Math.max(max, vol.maxHeight),
0,
);
// Fog is enabled if we have valid distance parameters
const enabled = visibleDistance > fogDistance;
return {
fogDistance,
visibleDistance,
fogColor,
fogVolumes,
fogLine,
enabled,
};
}
/**
* Create initial fog uniforms structure.
*/
function createFogUniforms(): FogUniforms {
return {
fogNear: 0,
fogFar: 1000,
fogColor: new Color(0.5, 0.5, 0.5),
fogVolumeData: new Float32Array(MAX_FOG_VOLUMES * FLOATS_PER_VOLUME),
cameraHeight: 0,
hasVolumetricFog: false,
};
}
/**
* Update fog uniforms from fog state.
*/
function updateFogUniforms(
uniforms: FogUniforms,
state: FogState,
cameraY: number,
): void {
uniforms.fogNear = state.fogDistance;
uniforms.fogFar = state.visibleDistance;
uniforms.fogColor.copy(state.fogColor);
uniforms.cameraHeight = cameraY;
uniforms.hasVolumetricFog = state.fogVolumes.length > 0;
// Pack fog volume data for shader: [visDist, minH, maxH, percentage] x 3
for (let i = 0; i < MAX_FOG_VOLUMES; i++) {
const offset = i * FLOATS_PER_VOLUME;
const vol = state.fogVolumes[i];
if (vol) {
uniforms.fogVolumeData[offset + 0] = vol.visibleDistance;
uniforms.fogVolumeData[offset + 1] = vol.minHeight;
uniforms.fogVolumeData[offset + 2] = vol.maxHeight;
uniforms.fogVolumeData[offset + 3] = vol.percentage;
} else {
// Mark as inactive with visibleDistance = 0
uniforms.fogVolumeData[offset + 0] = 0;
uniforms.fogVolumeData[offset + 1] = 0;
uniforms.fogVolumeData[offset + 2] = 0;
uniforms.fogVolumeData[offset + 3] = 0;
}
}
}
interface FogProviderProps {
object: TorqueObject;
enabled?: boolean;
children: ReactNode;
}
/**
* Provides fog state and uniforms to the scene.
* Updates fog uniforms each frame based on camera position.
*
* Note: Shader materials get fog uniforms from globalFogUniforms (updated by Sky).
* This provider is for React components that need fog state or the FogUniforms object.
*/
export function FogProvider({
object,
enabled = true,
children,
}: FogProviderProps) {
const fogState = useMemo(() => {
const state = parseFogState(object);
state.enabled = state.enabled && enabled;
return state;
}, [object, enabled]);
const uniformsRef = useRef<FogUniforms>(createFogUniforms());
// Update uniforms each frame with current camera position
useFrame(({ camera }) => {
if (fogState.enabled) {
updateFogUniforms(uniformsRef.current, fogState, camera.position.y);
}
});
// Initial update
useMemo(() => {
updateFogUniforms(uniformsRef.current, fogState, 0);
}, [fogState]);
return (
<FogContext.Provider value={fogState}>
<FogUniformsContext.Provider value={uniformsRef}>
{children}
</FogUniformsContext.Provider>
</FogContext.Provider>
);
}
/**
* Hook to access the current fog state.
*/
export function useFogState(): FogState | null {
return useContext(FogContext);
}
/**
* Hook to access fog uniforms ref (for shader updates).
*/
export function useFogUniforms(): React.MutableRefObject<FogUniforms> | null {
return useContext(FogUniformsContext);
}
/**
* Get the fog color at a given height.
* Used for skybox and background color blending.
*
* Note: Per-volume colors are not used in Tribes 2, so this always
* returns the global fog color regardless of height.
*/
export function getFogColorAtHeight(state: FogState, _height: number): Color {
return state.fogColor;
}

View file

@ -6,7 +6,7 @@ import {
BoxGeometry,
Color,
DoubleSide,
LinearSRGBColorSpace,
NoColorSpace,
RepeatWrapping,
Texture,
} from "three";
@ -47,8 +47,9 @@ function parseColor(colorStr: string): [number, number, number] {
function setupForceFieldTexture(texture: Texture) {
texture.wrapS = texture.wrapT = RepeatWrapping;
// Linear color space - gamma correction is applied in the shader
texture.colorSpace = LinearSRGBColorSpace;
// NoColorSpace - values pass through directly to display without conversion,
// matching how WaterBlock handles textures in custom ShaderMaterial.
texture.colorSpace = NoColorSpace;
texture.flipY = false;
texture.needsUpdate = true;
}
@ -155,21 +156,16 @@ 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),
),
// Use color directly - no gamma correction needed to match main shader
const fallbackColor = useMemo(
() => new Color(color[0], color[1], color[2]),
[color],
);
return (
<mesh geometry={geometry} renderOrder={1}>
<meshBasicMaterial
color={gammaColor}
color={fallbackColor}
transparent
opacity={baseTranslucency * OPACITY_FACTOR}
blending={AdditiveBlending}

View file

@ -16,6 +16,9 @@ import { useDebug } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { FloatingLabel } from "./FloatingLabel";
import { useIflTexture } from "./useIflTexture";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectShapeLighting } from "../shapeMaterial";
/** Shared props for texture rendering components */
interface TextureProps {
@ -43,6 +46,21 @@ type MaterialResult =
| SingleMaterial
| [MeshLambertMaterial, MeshLambertMaterial];
/**
* Helper to apply volumetric fog and lighting multipliers to a material
*/
function applyShapeShaderModifications(
mat: MeshBasicMaterial | MeshLambertMaterial,
): void {
mat.onBeforeCompile = (shader) => {
injectCustomFog(shader, globalFogUniforms);
// Only inject lighting for Lambert materials (Basic materials are unlit)
if (mat instanceof MeshLambertMaterial) {
injectShapeLighting(shader);
}
};
}
function createMaterialFromFlags(
baseMaterial: MeshStandardMaterial,
texture: Texture,
@ -64,6 +82,7 @@ function createMaterialFromFlags(
blending: isAdditive ? AdditiveBlending : undefined,
fog: true,
});
applyShapeShaderModifications(mat);
return mat;
}
@ -90,6 +109,8 @@ function createMaterialFromFlags(
...baseProps,
side: 0, // FrontSide
});
applyShapeShaderModifications(backMat);
applyShapeShaderModifications(frontMat);
return [backMat, frontMat];
}
@ -100,6 +121,7 @@ function createMaterialFromFlags(
side: 2, // DoubleSide
reflectivity: 0,
});
applyShapeShaderModifications(mat);
return mat;
}

View file

@ -1,4 +1,4 @@
import { memo, Suspense, useMemo } from "react";
import { memo, Suspense, useMemo, useCallback } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Mesh, Material, MeshStandardMaterial, Texture } from "three";
import { useGLTF, useTexture } from "@react-three/drei";
@ -8,8 +8,16 @@ import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { setupColor } from "../textureUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectInteriorLighting } from "../interiorMaterial";
const LIGHTMAP_INTENSITY = 4;
/**
* Lightmap intensity multiplier.
* Lightmaps contain baked lighting from interior-specific lights only
* (not scene sun/ambient - that's applied in real-time).
*/
const LIGHTMAP_INTENSITY = 2.5;
/**
* Load a .gltf file that was converted from a .dif, used for "interior" models.
@ -36,19 +44,35 @@ function InteriorTexture({
const flagNames = new Set<string>(material?.userData?.flag_names ?? []);
const isSelfIlluminating = flagNames.has("SelfIlluminating");
// Self-illuminating materials are fullbright (unlit)
// Inject volumetric fog and lighting multipliers into materials
const onBeforeCompile = useCallback((shader: any) => {
injectCustomFog(shader, globalFogUniforms);
injectInteriorLighting(shader);
}, []);
// Self-illuminating materials are fullbright (unlit), no lightmap
if (isSelfIlluminating) {
return <meshBasicMaterial map={texture} side={2} toneMapped={false} />;
return (
<meshBasicMaterial
map={texture}
side={2}
toneMapped={false}
onBeforeCompile={onBeforeCompile}
/>
);
}
// Use lightMap if available (baked lighting from DIF files)
// Three.js MeshLambertMaterial automatically uses uv2 for lightMap
// Use MeshLambertMaterial for diffuse-only lighting (matches Tribes 2's GL pipeline)
// Interiors respond to scene sun + ambient (from Sky object) in real-time
// Lightmaps contain baked lighting from interior-specific lights only
// DIF files are reusable across missions with different sun settings
return (
<meshLambertMaterial
map={texture}
lightMap={lightMap ?? undefined}
lightMapIntensity={lightMap ? LIGHTMAP_INTENSITY : undefined}
side={2}
onBeforeCompile={onBeforeCompile}
/>
);
}
@ -56,13 +80,22 @@ function InteriorTexture({
/**
* Extract lightmap texture from a glTF material.
* The io_dif Blender addon stores lightmaps in the emissive channel for transport.
*
* Note: Torque used lightmaps directly as linear data (no gamma correction in
* the engine). The glTF loader preserves the original PNG data. We explicitly
* set colorSpace to linear to match Torque's behavior.
*/
function getLightMap(material: Material | null): Texture | null {
if (!material) return null;
// glTF materials come through as MeshStandardMaterial
const stdMat = material as MeshStandardMaterial;
// Lightmap is stored in emissiveMap with 0 strength (just for glTF transport)
return stdMat.emissiveMap ?? null;
const lightMap = stdMat.emissiveMap;
if (lightMap) {
// Use linear color space to match Torque's direct multiply behavior
lightMap.colorSpace = "srgb-linear";
}
return lightMap ?? null;
}
function InteriorMesh({ node }: { node: Mesh }) {
@ -70,7 +103,7 @@ function InteriorMesh({ node }: { node: Mesh }) {
const lightMaps = useMemo(() => {
if (!node.material) return [];
if (Array.isArray(node.material)) {
return node.material.map(getLightMap);
return node.material.map((m) => getLightMap(m));
}
return [getLightMap(node.material)];
}, [node.material]);

View file

@ -13,6 +13,8 @@ type StateSetter<T> = ReturnType<typeof useState<T>>[1];
type SettingsContext = {
fogEnabled: boolean;
setFogEnabled: StateSetter<boolean>;
highQualityFog: boolean;
setHighQualityFog: StateSetter<boolean>;
fov: number;
setFov: StateSetter<number>;
audioEnabled: boolean;
@ -37,6 +39,7 @@ const ControlsContext = createContext<ControlsContext | null>(null);
type PersistedSettings = {
fogEnabled?: boolean;
highQualityFog?: boolean;
speedMultiplier?: number;
fov?: number;
audioEnabled?: boolean;
@ -58,6 +61,7 @@ export function useControls() {
export function SettingsProvider({ children }: { children: ReactNode }) {
const [fogEnabled, setFogEnabled] = useState(true);
const [highQualityFog, setHighQualityFog] = useState(false);
const [speedMultiplier, setSpeedMultiplier] = useState(1);
const [fov, setFov] = useState(90);
const [audioEnabled, setAudioEnabled] = useState(false);
@ -68,6 +72,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
() => ({
fogEnabled,
setFogEnabled,
highQualityFog,
setHighQualityFog,
fov,
setFov,
audioEnabled,
@ -75,7 +81,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
animationEnabled,
setAnimationEnabled,
}),
[fogEnabled, speedMultiplier, fov, audioEnabled, animationEnabled],
[fogEnabled, highQualityFog, fov, audioEnabled, animationEnabled],
);
const debugContext: DebugContext = useMemo(
@ -108,6 +114,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
if (savedSettings.fogEnabled != null) {
setFogEnabled(savedSettings.fogEnabled);
}
if (savedSettings.highQualityFog != null) {
setHighQualityFog(savedSettings.highQualityFog);
}
if (savedSettings.speedMultiplier != null) {
setSpeedMultiplier(savedSettings.speedMultiplier);
}
@ -129,6 +138,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
saveTimerRef.current = setTimeout(() => {
const settingsToSave: PersistedSettings = {
fogEnabled,
highQualityFog,
speedMultiplier,
fov,
audioEnabled,
@ -149,6 +159,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
};
}, [
fogEnabled,
highQualityFog,
speedMultiplier,
fov,
audioEnabled,

View file

@ -1,49 +1,26 @@
import { Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useThree, useFrame } from "@react-three/fiber";
import { useCubeTexture } from "@react-three/drei";
import { Color, ShaderMaterial, BackSide, ShaderChunk } from "three";
import { Color, Fog } from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getInt, getProperty } from "../mission";
import { getInt, getProperty } from "../mission";
import { useSettings } from "./SettingsProvider";
import { BASE_URL, loadDetailMapList, textureToUrl } from "../loaders";
import { CloudLayers } from "./CloudLayers";
import { parseFogState, type FogState, type FogVolume } from "./FogProvider";
import { installCustomFogShader } from "../fogShader";
import {
globalFogUniforms,
updateGlobalFogUniforms,
packFogVolumeData,
resetGlobalFogUniforms,
} from "../globalFogUniforms";
const FALLBACK_TEXTURE_URL = `${BASE_URL}/black.png`;
/**
* Tribes 2 fog formula (from sceneState.cc getHaze):
* fogScale = 1.0 / (visibleDistance - fogDistance)
* distFactor = (dist - fogDistance) * fogScale - 1.0
* haze = 1.0 - distFactor * distFactor
*
* This creates an "ease-in" quadratic curve where fog builds slowly at first,
* then accelerates toward visibleDistance.
*
* Set USE_QUADRATIC_FOG to true to use this formula, false to use Three.js linear fog.
*/
const USE_QUADRATIC_FOG = false;
function installQuadraticFogShader() {
ShaderChunk.fog_fragment = `
#ifdef USE_FOG
float fogFactor = 0.0;
if (vFogDepth > fogNear) {
if (vFogDepth >= fogFar) {
fogFactor = 1.0;
} else {
float fogScale = 1.0 / (fogFar - fogNear);
float distFactor = (vFogDepth - fogNear) * fogScale - 1.0;
fogFactor = 1.0 - distFactor * distFactor;
}
}
gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, fogFactor);
#endif
`;
}
if (USE_QUADRATIC_FOG) {
installQuadraticFogShader();
}
// Track if fog shader has been installed (idempotent installation)
let fogShaderInstalled = false;
/**
* Parse a Tribes 2 color string (space-separated RGB or RGBA values 0-1).
@ -70,12 +47,225 @@ function useDetailMapList(name: string) {
});
}
/**
* Inner component that renders the skybox once texture URLs are known.
* Separated so useCubeTexture only runs with valid URLs.
*/
// Torque sky constants (from sky.cc)
// OFFSET_HEIGHT = 60.0 - height of the horizon fog band in world units
const HORIZON_FOG_HEIGHT = 60.0;
function SkyBoxTexture({
skyBoxFiles,
fogColor,
fogState,
}: {
skyBoxFiles: string[];
fogColor?: Color;
fogState?: FogState;
}) {
const { camera } = useThree();
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
const enableFog = !!fogColor;
const inverseProjectionMatrix = useMemo(() => {
return camera.projectionMatrixInverse;
}, [camera]);
const fogVolumeData = useMemo(
() =>
fogState ? packFogVolumeData(fogState.fogVolumes) : new Float32Array(12),
[fogState],
);
// Calculate the horizon fog cutoff based on visible distance
// In Torque's sky.cc:
// mRadius = visibleDistance * 0.95
// tpt = (1,1,1).normalize(mRadius) -> each component = mRadius / sqrt(3)
// mSkyBoxPt.x = mSkyBoxPt.z = mRadius / sqrt(3) (corner of cube)
//
// The fog band is rendered as geometry from height 0 to OFFSET_HEIGHT (60)
// on a skybox where the horizontal distance to the edge is mSkyBoxPt.x
//
// For a ray direction, direction.y corresponds to the vertical component
// The fog should cover directions where:
// height / horizontal_dist = direction.y / sqrt(1 - direction.y^2) < 60 / skyBoxPt.x
//
// Simplifying: direction.y < OFFSET_HEIGHT / sqrt(skyBoxPt.x^2 + OFFSET_HEIGHT^2)
const horizonFogHeight = useMemo(() => {
if (!fogState) return 0.18; // Default fallback
const mRadius = fogState.visibleDistance * 0.95;
const skyBoxPtX = mRadius / Math.sqrt(3); // Corner coordinate
// For direction vector (horizontal, y), y / horizontal = height / skyBoxPtX
// At the fog boundary: y / sqrt(1-y^2) = 60 / skyBoxPtX
// Solving for y: y = 60 / sqrt(skyBoxPtX^2 + 60^2)
return HORIZON_FOG_HEIGHT / Math.sqrt(skyBoxPtX * skyBoxPtX + HORIZON_FOG_HEIGHT * HORIZON_FOG_HEIGHT);
}, [fogState]);
return (
<mesh renderOrder={-1000} frustumCulled={false}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
array={new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0])}
count={3}
itemSize={3}
/>
<bufferAttribute
attach="attributes-uv"
array={new Float32Array([0, 0, 2, 0, 0, 2])}
count={3}
itemSize={2}
/>
</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 },
}}
vertexShader={`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.9999, 1.0);
}
`}
fragmentShader={`
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;
// Convert linear to sRGB for display
// shaderMaterial does NOT get automatic linear->sRGB output conversion
// Use proper sRGB transfer function (not simplified gamma 2.2) to match Three.js
vec3 linearToSRGB(vec3 linear) {
vec3 low = linear * 12.92;
vec3 high = 1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055;
return mix(low, high, step(vec3(0.0031308), linear));
}
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) {
vec3 effectiveFogColor = fogColor;
// 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);
finalColor = mix(skyColor.rgb, effectiveFogColor, finalFogFactor);
} else {
finalColor = skyColor.rgb;
}
// Convert linear result to sRGB for display
gl_FragColor = vec4(linearToSRGB(finalColor), 1.0);
}
`}
depthWrite={false}
depthTest={false}
/>
</mesh>
);
}
export function SkyBox({
materialList,
fogColor,
fogState,
}: {
materialList: string;
fogColor?: Color;
fogState?: FogState;
}) {
const { data: detailMapList } = useDetailMapList(materialList);
@ -90,89 +280,121 @@ export function SkyBox({
textureToUrl(detailMapList[0]), // +z
textureToUrl(detailMapList[2]), // -z
]
: [
FALLBACK_TEXTURE_URL,
FALLBACK_TEXTURE_URL,
FALLBACK_TEXTURE_URL,
FALLBACK_TEXTURE_URL,
FALLBACK_TEXTURE_URL,
FALLBACK_TEXTURE_URL,
],
: null,
[detailMapList],
);
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
const materialRef = useRef<ShaderMaterial>(null!);
const shaderMaterial = useMemo(() => {
// Always use a shader to apply the X-axis mirror transformation.
// Optionally blend fog toward the horizon.
return new ShaderMaterial({
uniforms: {
skybox: { value: skyBox },
fogColor: { value: fogColor ?? new Color(0, 0, 0) },
enableFog: { value: !!fogColor },
},
vertexShader: `
varying vec3 vDirection;
void main() {
vDirection = position;
vec4 pos = projectionMatrix * mat4(mat3(modelViewMatrix)) * vec4(position, 1.0);
gl_Position = pos.xyww;
}
`,
fragmentShader: `
uniform samplerCube skybox;
uniform vec3 fogColor;
uniform bool enableFog;
varying vec3 vDirection;
void main() {
vec3 direction = normalize(vDirection);
// Swap X and Z, negate X to mirror across X axis
direction = vec3(direction.z, direction.y, -direction.x);
vec4 skyColor = textureCube(skybox, direction);
if (enableFog) {
// Fog increases toward and below horizon
// direction.y: -1 = straight down, 0 = horizon, 1 = straight up
// Use smoothstep for gradual transition (matches Three.js linear fog feel)
float fogFactor = 1.0 - smoothstep(-0.1, 0.5, direction.y);
vec3 finalColor = mix(skyColor.rgb, fogColor, fogFactor);
gl_FragColor = vec4(finalColor, 1.0);
} else {
gl_FragColor = skyColor;
}
}
`,
side: BackSide,
depthWrite: false,
});
}, [skyBox, fogColor]);
// Update uniforms when props change (ensures reactivity)
useEffect(() => {
if (materialRef.current) {
materialRef.current.uniforms.skybox.value = skyBox;
materialRef.current.uniforms.fogColor.value =
fogColor ?? new Color(0, 0, 0);
materialRef.current.uniforms.enableFog.value = !!fogColor;
}
}, [skyBox, fogColor]);
// Don't render until we have real texture URLs
if (!skyBoxFiles) {
return null;
}
return (
<mesh scale={5000} frustumCulled={false}>
<sphereGeometry args={[1, 60, 40]} />
<primitive ref={materialRef} object={shaderMaterial} attach="material" />
</mesh>
<SkyBoxTexture
skyBoxFiles={skyBoxFiles}
fogColor={fogColor}
fogState={fogState}
/>
);
}
/**
* Get fog near/far parameters for the distance-based haze.
*
* IMPORTANT: In Torque, the distance-based haze ALWAYS uses the global
* fogDistance and visibleDistance parameters. Per-volume fog contributions
* are calculated separately in the volumetric fog shader and ADDED to haze.
*
* The shader's haze formula reads fogNear/fogFar from scene.fog, so these
* must be the global parameters, NOT per-volume adjusted values.
*
* @returns [near, far] distances for haze (always global values)
*/
function calculateFogParameters(
fogState: FogState,
_cameraHeight: number,
): [number, number] {
const { fogDistance, visibleDistance } = fogState;
// Always return global fog parameters for the haze calculation.
// Volumetric fog from fog volumes is computed separately in the shader
// and added to the haze value.
return [fogDistance, visibleDistance];
}
/**
* Dynamic fog component that manages Torque-style fog rendering.
*
* This component:
* - Sets up Three.js Fog with global fogDistance/visibleDistance for haze
* - Updates cameraHeight uniform each frame for volumetric fog shaders
* - Manages global fog uniforms lifecycle (reset on mount, cleanup on unmount)
*
* The custom fog shader (fogFragmentShader) handles:
* 1. Haze: Distance-based quadratic fog using global parameters
* 2. Volume fog: Height-based fog using per-volume parameters
* Both are combined additively, matching Torque's getHazeAndFog function.
*/
function DynamicFog({ fogState }: { fogState: FogState }) {
const { scene, camera } = useThree();
const fogRef = useRef<Fog | null>(null);
// Pack fog volume data once (it doesn't change during runtime)
const fogVolumeData = useMemo(
() => packFogVolumeData(fogState.fogVolumes),
[fogState.fogVolumes],
);
// Install custom fog shader (idempotent - only runs once globally)
useEffect(() => {
if (!fogShaderInstalled) {
installCustomFogShader();
fogShaderInstalled = true;
}
}, []);
// Create fog object on mount
useEffect(() => {
// Reset global fog uniforms to ensure clean state for new mission
resetGlobalFogUniforms();
const [near, far] = calculateFogParameters(fogState, camera.position.y);
const fog = new Fog(fogState.fogColor, near, far);
scene.fog = fog;
fogRef.current = fog;
// Initial update of global fog uniforms
updateGlobalFogUniforms(camera.position.y, fogVolumeData);
return () => {
scene.fog = null;
fogRef.current = null;
// Reset fog uniforms on unmount so next mission starts clean
resetGlobalFogUniforms();
};
}, [scene, camera, fogState, fogVolumeData]);
// Update fog parameters each frame based on camera height
useFrame(() => {
const fog = fogRef.current;
if (!fog) return;
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);
// Update global fog uniforms for volumetric fog shaders
updateGlobalFogUniforms(cameraHeight, fogVolumeData);
});
return null;
}
export function Sky({ object }: { object: TorqueObject }) {
const { fogEnabled } = useSettings();
const { fogEnabled, highQualityFog } = useSettings();
// Skybox textures
const materialList = getProperty(object, "materialList");
@ -184,50 +406,13 @@ export function Sky({ object }: { object: TorqueObject }) {
const useSkyTextures = getInt(object, "useSkyTextures") ?? 1;
// Fog parameters - Tribes 2 uses fogDistance (near) and visibleDistance (far)
// high_* variants are used for high quality settings (-1 or 0 means use normal)
const fogDistanceBase = getFloat(object, "fogDistance");
const visibleDistanceBase = getFloat(object, "visibleDistance");
const highFogDistance = getFloat(object, "high_fogDistance");
const highVisibleDistance = getFloat(object, "high_visibleDistance");
// Parse fog volumes - format: "visibleDistance minHeight maxHeight"
// These define height-based fog bands with different densities
const fogVolume1 = useMemo(() => {
const value = getProperty(object, "fogVolume1");
if (value) {
const [visibleDistance, minHeight, maxHeight] = value
.split(" ")
.map((s: string) => parseFloat(s));
// Only valid if visibleDistance > 0 and has a height range
if (visibleDistance > 0 && maxHeight > minHeight) {
return { visibleDistance, minHeight, maxHeight };
}
}
return null;
}, [object]);
// Use high quality values if available and valid (> 0)
const baseFogNear =
highFogDistance != null && highFogDistance > 0
? highFogDistance
: fogDistanceBase;
const baseFogFar =
highVisibleDistance != null && highVisibleDistance > 0
? highVisibleDistance
: visibleDistanceBase;
// If fogVolume1 is defined, use denser fog
// Torque's fog volumes ADD density on top of base fog - objects inside
// a fog volume get significantly more haze. We approximate this by
// using a fraction of the volume's visibleDistance.
const fogNear = fogVolume1
? Math.min(baseFogNear ?? Infinity, fogVolume1.visibleDistance * 0.25)
: baseFogNear;
const fogFar = fogVolume1
? Math.min(baseFogFar ?? Infinity, fogVolume1.visibleDistance * 0.9)
: baseFogFar;
// Parse full fog state from Sky object using FogProvider's parser
const fogState = useMemo(
() => parseFogState(object, highQualityFog),
[object, highQualityFog],
);
// Get sRGB fog color for background
const fogColor = useMemo(
() => parseColorString(getProperty(object, "fogColor")),
[object],
@ -235,34 +420,52 @@ export function Sky({ object }: { object: TorqueObject }) {
const skyColor = skySolidColor || fogColor;
const backgroundColor = skyColor ? (
<color attach="background" args={[skyColor[0]]} />
) : null;
// Only enable fog if we have valid distance parameters
const hasFogParams = fogState.enabled && fogEnabled;
// Only enable fog if we have valid near/far distances
const hasFogParams = fogNear != null && fogFar != null && fogFar > fogNear;
// Use the linear fog color from fogState - Three.js will handle display conversion
const effectiveFogColor = fogState.fogColor;
// Set scene background color directly using useThree
// This ensures the gap between fogged terrain and skybox blends correctly
const { scene, gl } = useThree();
useEffect(() => {
if (hasFogParams) {
// Use effective fog color for background (matches terrain fog)
const bgColor = effectiveFogColor.clone();
scene.background = bgColor;
// Also set the renderer clear color as a fallback
gl.setClearColor(bgColor);
} else if (skyColor) {
const bgColor = skyColor[0].clone();
scene.background = bgColor;
gl.setClearColor(bgColor);
} else {
scene.background = null;
}
return () => {
scene.background = null;
};
}, [scene, gl, hasFogParams, effectiveFogColor, skyColor]);
return (
<>
{materialList && useSkyTextures ? (
<Suspense fallback={backgroundColor}>
<Suspense fallback={null}>
{/* Key forces remount when mission changes to clear texture caches */}
<SkyBox
key={materialList}
materialList={materialList}
fogColor={fogEnabled && hasFogParams ? fogColor?.[1] : undefined}
fogColor={hasFogParams ? effectiveFogColor : undefined}
fogState={hasFogParams ? fogState : undefined}
/>
</Suspense>
) : (
// If there's no material list or skybox textures are disabled,
// render solid background
backgroundColor
)}
) : null}
{/* Cloud layers render independently of skybox textures */}
<Suspense>
<CloudLayers object={object} />
</Suspense>
{fogEnabled && hasFogParams && fogColor ? (
<fog attach="fog" color={fogColor[1]} near={fogNear!} far={fogFar!} />
) : null}
{hasFogParams ? <DynamicFog fogState={fogState} /> : null}
</>
);
}

View file

@ -43,8 +43,9 @@ export function Sun({ object }: { object: TorqueObject }) {
return new Color(r, g, b);
}, [object]);
// Lighting intensities - terrain and shapes need good directional + ambient balance
const directionalIntensity = 1.8;
// Base lighting intensities - neutral baseline, each object type applies its own multipliers
// See lightingConfig.ts for per-object-type adjustments
const directionalIntensity = 1.0;
const ambientIntensity = 1.0;
// Shadow camera covers the entire terrain (Tribes 2 terrains are typically 2048+ units)
@ -58,15 +59,16 @@ export function Sun({ object }: { object: TorqueObject }) {
color={color}
intensity={directionalIntensity}
castShadow
shadow-mapSize-width={4096}
shadow-mapSize-height={4096}
shadow-mapSize-width={8192}
shadow-mapSize-height={8192}
shadow-camera-left={-shadowCameraSize}
shadow-camera-right={shadowCameraSize}
shadow-camera-top={shadowCameraSize}
shadow-camera-bottom={-shadowCameraSize}
shadow-camera-near={100}
shadow-camera-far={12000}
shadow-bias={-0.001}
shadow-bias={-0.0003}
shadow-normalBias={0.5}
/>
{/* Ambient fill light - prevents pure black shadows */}
<ambientLight color={ambient} intensity={ambientIntensity} />

View file

@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import {
DataTexture,
FloatType,
LinearFilter,
NearestFilter,
NoColorSpace,
ClampToEdgeWrapping,
@ -11,6 +12,7 @@ import {
RedFormat,
RepeatWrapping,
UnsignedByteType,
Vector3,
} from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getInt, getPosition, getProperty } from "../mission";
@ -23,6 +25,238 @@ import { useSceneObject } from "./useSceneObject";
const DEFAULT_SQUARE_SIZE = 8;
const DEFAULT_VISIBLE_DISTANCE = 600;
const TERRAIN_SIZE = 256;
const LIGHTMAP_SIZE = 512; // Match Tribes 2's 512x512 lightmap
const HEIGHT_SCALE = 2048; // Matches displacementScale for terrain
/**
* Displace terrain vertices on CPU and compute smooth normals from heightmap gradients.
*
* Height sampling uses NEAREST filtering to match the GPU DataTexture default:
* texel = floor(uv * textureWidth), clamped to valid range.
*
* Normals use bilinear interpolation for smooth gradients, preventing banding
* that would occur with face normals from computeVertexNormals().
*/
function displaceTerrainAndComputeNormals(
geometry: PlaneGeometry,
heightMap: Uint16Array,
squareSize: number,
): void {
const posAttr = geometry.attributes.position;
const uvAttr = geometry.attributes.uv;
const normalAttr = geometry.attributes.normal;
const positions = posAttr.array as Float32Array;
const uvs = uvAttr.array as Float32Array;
const normals = normalAttr.array as Float32Array;
const vertexCount = posAttr.count;
// Helper to get height at heightmap coordinates with clamping (integer coords)
const getHeightInt = (col: number, row: number): number => {
col = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
row = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
return (heightMap[row * TERRAIN_SIZE + col] / 65535) * HEIGHT_SCALE;
};
// Helper to get bilinearly interpolated height (matches GPU texture sampling)
const getHeight = (col: number, row: number): number => {
col = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
row = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
const col0 = Math.floor(col);
const row0 = Math.floor(row);
const col1 = Math.min(col0 + 1, TERRAIN_SIZE - 1);
const row1 = Math.min(row0 + 1, TERRAIN_SIZE - 1);
const fx = col - col0;
const fy = row - row0;
const h00 = (heightMap[row0 * TERRAIN_SIZE + col0] / 65535) * HEIGHT_SCALE;
const h10 = (heightMap[row0 * TERRAIN_SIZE + col1] / 65535) * HEIGHT_SCALE;
const h01 = (heightMap[row1 * TERRAIN_SIZE + col0] / 65535) * HEIGHT_SCALE;
const h11 = (heightMap[row1 * TERRAIN_SIZE + col1] / 65535) * HEIGHT_SCALE;
// Bilinear interpolation
const h0 = h00 * (1 - fx) + h10 * fx;
const h1 = h01 * (1 - fx) + h11 * fx;
return h0 * (1 - fy) + h1 * fy;
};
// Process each vertex
for (let i = 0; i < vertexCount; i++) {
const u = uvs[i * 2];
const v = uvs[i * 2 + 1];
// Map UV to heightmap coordinates - must match Torque's terrain sampling.
// Torque formula: floor(worldPos / squareSize) & BlockMask
// UV 0→1 maps to world 0→2048, squareSize=8, so: floor(UV * 256) & 255
// This wraps at edges for seamless terrain tiling.
const col = Math.floor(u * TERRAIN_SIZE) & (TERRAIN_SIZE - 1);
const row = Math.floor(v * TERRAIN_SIZE) & (TERRAIN_SIZE - 1);
// Use direct integer sampling to match GPU nearest-neighbor filtering
const height = getHeightInt(col, row);
positions[i * 3 + 1] = height;
// Compute normal using central differences on heightmap with smooth interpolation.
// Use fractional coordinates for gradient sampling to get smooth normals.
const colF = u * (TERRAIN_SIZE - 1);
const rowF = v * (TERRAIN_SIZE - 1);
const hL = getHeight(colF - 1, rowF); // left
const hR = getHeight(colF + 1, rowF); // right
const hD = getHeight(colF, rowF + 1); // down (increasing row)
const hU = getHeight(colF, rowF - 1); // up (decreasing row)
// Gradients in heightmap space (col increases = +U, row increases = +V)
const dCol = (hR - hL) / 2; // height change per column
const dRow = (hD - hU) / 2; // height change per row
// Now map heightmap gradients to world-space normal
// After rotateX(-PI/2) and rotateY(-PI/2):
// - U direction (col) maps to world +Z
// - V direction (row) maps to world +X
//
// For heightfield normal: n = normalize(-dh/dx, 1, -dh/dz) in world space
// But we need the normal to face outward (toward the viewer), so use positive signs
let nx = dRow;
let ny = squareSize;
let nz = dCol;
// Normalize
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (len > 0) {
nx /= len;
ny /= len;
nz /= len;
} else {
nx = 0;
ny = 1;
nz = 0;
}
normals[i * 3] = nx;
normals[i * 3 + 1] = ny;
normals[i * 3 + 2] = nz;
}
posAttr.needsUpdate = true;
normalAttr.needsUpdate = true;
}
/**
* Generate a terrain lightmap texture with smooth normals.
*
* The key insight: banding occurs because vertex normals are computed from
* discrete heightmap samples, creating discontinuities at grid boundaries.
*
* Solution: Compute normals from BILINEARLY INTERPOLATED heights at each
* lightmap pixel. This produces smooth gradients because the interpolated
* height surface is C0 continuous (no discontinuities).
*
* @param heightMap - Uint16 heightmap data (256x256)
* @param sunDirection - Normalized sun direction vector (points FROM sun TO scene)
* @param squareSize - World units per heightmap cell
* @returns DataTexture with lighting intensity values
*/
function generateTerrainLightmap(
heightMap: Uint16Array,
sunDirection: Vector3,
squareSize: number,
): DataTexture {
// Helper to get bilinearly interpolated height at any fractional position
// Supports negative and out-of-range coordinates via wrapping
const getInterpolatedHeight = (col: number, row: number): number => {
// Wrap to valid range using modulo (handles negative values correctly)
const wrappedCol = ((col % TERRAIN_SIZE) + TERRAIN_SIZE) % TERRAIN_SIZE;
const wrappedRow = ((row % TERRAIN_SIZE) + TERRAIN_SIZE) % TERRAIN_SIZE;
const col0 = Math.floor(wrappedCol);
const row0 = Math.floor(wrappedRow);
const col1 = (col0 + 1) & (TERRAIN_SIZE - 1); // Wrap at edge
const row1 = (row0 + 1) & (TERRAIN_SIZE - 1);
const fx = wrappedCol - col0;
const fy = wrappedRow - row0;
const h00 = heightMap[row0 * TERRAIN_SIZE + col0] / 65535;
const h10 = heightMap[row0 * TERRAIN_SIZE + col1] / 65535;
const h01 = heightMap[row1 * TERRAIN_SIZE + col0] / 65535;
const h11 = heightMap[row1 * TERRAIN_SIZE + col1] / 65535;
// Bilinear interpolation
const h0 = h00 * (1 - fx) + h10 * fx;
const h1 = h01 * (1 - fx) + h11 * fx;
return (h0 * (1 - fy) + h1 * fy) * HEIGHT_SCALE;
};
// Light direction (negate sun direction since it points FROM sun)
const lightDir = new Vector3(
-sunDirection.x,
-sunDirection.y,
-sunDirection.z,
).normalize();
const lightmapData = new Uint8Array(LIGHTMAP_SIZE * LIGHTMAP_SIZE);
// Epsilon for gradient sampling (in heightmap units)
// Use 0.5 to sample across a reasonable distance for smooth gradients
const eps = 0.5;
// Generate lightmap by computing normal from interpolated heights at each pixel
for (let lRow = 0; lRow < LIGHTMAP_SIZE; lRow++) {
for (let lCol = 0; lCol < LIGHTMAP_SIZE; lCol++) {
// Generate texel for terrain position matching Torque's relight():
// Torque starts at halfStep (0.25) within each square, not at corner.
// With 2 lightmap pixels per terrain square: pos = lCol/2 + 0.25
const col = lCol / 2 + 0.25;
const row = lRow / 2 + 0.25;
// Compute gradient using central differences on interpolated heights
const hL = getInterpolatedHeight(col - eps, row);
const hR = getInterpolatedHeight(col + eps, row);
const hU = getInterpolatedHeight(col, row - eps);
const hD = getInterpolatedHeight(col, row + eps);
// Gradient in heightmap units
const dCol = (hR - hL) / (2 * eps);
const dRow = (hD - hU) / (2 * eps);
// Convert to world-space normal - must match displaceTerrainAndComputeNormals
// After geometry rotations: U (col) → +Z, V (row) → +X
const nx = -dRow;
const ny = squareSize;
const nz = -dCol;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
// Compute NdotL
const NdotL = Math.max(
0,
(nx / len) * lightDir.x +
(ny / len) * lightDir.y +
(nz / len) * lightDir.z,
);
lightmapData[lRow * LIGHTMAP_SIZE + lCol] = Math.floor(NdotL * 255);
}
}
const texture = new DataTexture(
lightmapData,
LIGHTMAP_SIZE,
LIGHTMAP_SIZE,
RedFormat,
UnsignedByteType,
);
texture.colorSpace = NoColorSpace;
texture.generateMipmaps = true;
texture.wrapS = ClampToEdgeWrapping;
texture.wrapT = ClampToEdgeWrapping;
texture.magFilter = LinearFilter;
texture.minFilter = LinearFilter;
texture.needsUpdate = true;
return texture;
}
/**
* Load a .ter file, used for terrain heightmap and texture info.
@ -112,16 +346,45 @@ export const TerrainBlock = memo(function TerrainBlock({
return value ? value.split(" ").map((s: string) => parseInt(s, 10)) : [];
}, [object]);
// Shared geometry for all tiles
const { data: terrain } = useTerrain(terrainFile);
// Shared geometry for all tiles - with smooth normals computed from heightmap
const sharedGeometry = useMemo(() => {
if (!terrain) return null;
const size = squareSize * 256;
const geometry = new PlaneGeometry(size, size, 256, 256);
geometry.rotateX(-Math.PI / 2);
geometry.rotateY(-Math.PI / 2);
return geometry;
}, [squareSize]);
const { data: terrain } = useTerrain(terrainFile);
// Displace vertices on CPU and compute smooth normals
displaceTerrainAndComputeNormals(geometry, terrain.heightMap, squareSize);
return geometry;
}, [squareSize, terrain]);
// Get sun direction for lightmap generation
const sun = useSceneObject("Sun");
const sunDirection = useMemo(() => {
if (!sun) return new Vector3(0.57735, -0.57735, 0.57735); // Default diagonal
const directionStr =
getProperty(sun, "direction") ?? "0.57735 0.57735 -0.57735";
const [tx, ty, tz] = directionStr
.split(" ")
.map((s: string) => parseFloat(s));
// Convert Torque (X, Y, Z) to Three.js: swap Y/Z
const x = tx;
const y = tz;
const z = ty;
const len = Math.sqrt(x * x + y * y + z * z);
return new Vector3(x / len, y / len, z / len);
}, [sun]);
// Generate terrain lightmap for smooth per-pixel lighting
const terrainLightmap = useMemo(() => {
if (!terrain) return null;
return generateTerrainLightmap(terrain.heightMap, sunDirection, squareSize);
}, [terrain, sunDirection, squareSize]);
// Shared displacement map from heightmap - created once for all tiles
const sharedDisplacementMap = useMemo(() => {
@ -218,7 +481,12 @@ export const TerrainBlock = memo(function TerrainBlock({
setTileAssignments(newAssignments);
});
if (!terrain || !sharedDisplacementMap || !sharedAlphaTextures) {
if (
!terrain ||
!sharedGeometry ||
!sharedDisplacementMap ||
!sharedAlphaTextures
) {
return null;
}
@ -236,6 +504,7 @@ export const TerrainBlock = memo(function TerrainBlock({
visibilityMask={primaryVisibilityMask}
alphaTextures={sharedAlphaTextures}
detailTextureName={detailTexture}
lightmap={terrainLightmap}
/>
{/* Pooled tiles - stable keys, always mounted */}
{poolIndices.map((poolIndex) => {
@ -253,6 +522,7 @@ export const TerrainBlock = memo(function TerrainBlock({
visibilityMask={pooledVisibilityMask}
alphaTextures={sharedAlphaTextures}
detailTextureName={detailTexture}
lightmap={terrainLightmap}
visible={assignment !== null}
/>
);

View file

@ -15,6 +15,8 @@ import {
import { setupColor } from "../textureUtils";
import { updateTerrainTextureShader } from "../terrainMaterial";
import { useDebug } from "./SettingsProvider";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
const DEFAULT_SQUARE_SIZE = 8;
@ -39,6 +41,7 @@ interface TerrainTileProps {
visibilityMask: DataTexture;
alphaTextures: DataTexture[];
detailTextureName?: string;
lightmap?: DataTexture;
visible?: boolean;
}
@ -48,12 +51,14 @@ function BlendedTerrainTextures({
textureNames,
alphaTextures,
detailTextureName,
lightmap,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaTextures: DataTexture[];
detailTextureName?: string;
lightmap?: DataTexture;
}) {
const { debugMode } = useDebug();
@ -86,7 +91,11 @@ function BlendedTerrainTextures({
tiling: TILING,
debugMode,
detailTexture: detailTextureUrl ? detailTexture : null,
lightmap,
});
// Inject volumetric fog using global uniforms
injectCustomFog(shader, globalFogUniforms);
},
[
baseTextures,
@ -95,22 +104,25 @@ function BlendedTerrainTextures({
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
const materialKey = `${debugMode ? "debug" : "normal"}-${detailTextureUrl ? "detail" : "nodetail"}`;
// - lightmap: affects shader structure (uses lightmap for NdotL instead of vertex normals)
const materialKey = `${debugMode ? "debug" : "normal"}-${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
key={materialKey}
displacementMap={displacementMap}
map={displacementMap}
displacementScale={2048}
depthWrite
side={debugMode ? DoubleSide : FrontSide}
side={DoubleSide}
onBeforeCompile={onBeforeCompile}
/>
);
@ -122,12 +134,14 @@ function TerrainMaterial({
textureNames,
alphaTextures,
detailTextureName,
lightmap,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaTextures: DataTexture[];
detailTextureName?: string;
lightmap?: DataTexture;
}) {
return (
<Suspense
@ -146,6 +160,7 @@ function TerrainMaterial({
textureNames={textureNames}
alphaTextures={alphaTextures}
detailTextureName={detailTextureName}
lightmap={lightmap}
/>
</Suspense>
);
@ -162,6 +177,7 @@ export const TerrainTile = memo(function TerrainTile({
visibilityMask,
alphaTextures,
detailTextureName,
lightmap,
visible = true,
}: TerrainTileProps) {
const position = useMemo(() => {
@ -189,6 +205,7 @@ export const TerrainTile = memo(function TerrainTile({
textureNames={textureNames}
alphaTextures={alphaTextures}
detailTextureName={detailTextureName}
lightmap={lightmap}
/>
</mesh>
);

View file

@ -1,7 +1,7 @@
import { memo, Suspense, useEffect, useMemo, useRef } from "react";
import { useTexture } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { DoubleSide, PlaneGeometry, RepeatWrapping } from "three";
import { DoubleSide, NoColorSpace, PlaneGeometry, RepeatWrapping } from "three";
import { textureToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
@ -67,6 +67,10 @@ export function WaterSurfaceMaterial({
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;
});

322
src/fogShader.ts Normal file
View file

@ -0,0 +1,322 @@
/**
* Custom fog shader code for Tribes 2-style fog rendering.
*
* Based on the V12/Torque engine fog system used in Tribes 2 (circa 2001).
* See Tribes2_Fog_System.md for complete documentation.
*
* Implements:
* - Quadratic distance-based haze (Torque's getHaze formula)
* - Height-based fog volumes with ray-marching accumulation
*
* Key insight from Torque source: Fog volumes ADD fog based on distance
* traveled through each volume, they don't replace the global fog parameters.
*/
import { ShaderChunk } from "three";
/**
* Fog uniform declarations for fragment shaders.
* Add this to the top of fragment shaders that need fog.
*/
export const fogUniformsDeclaration = `
#ifdef USE_FOG
uniform vec3 fogColor;
uniform float fogNear;
uniform float fogFar;
// Volumetric fog: 3 volumes, 4 floats each
// [visDist, minHeight, maxHeight, percentage]
// Note: Per-volume colors not used ($specialFog = false), all fog uses fogColor
uniform float fogVolumeData[12];
uniform float cameraHeight;
uniform bool hasVolumetricFog;
#endif
`;
/**
* Custom fog fragment shader that implements Torque's fog system.
* Replaces Three.js default fog_fragment chunk.
*
* Torque fog algorithm (from sceneState.cc getHazeAndFog):
*
* 1. HAZE (distance-based):
* - No fog if dist <= fogDistance
* - Full fog if dist > visibleDistance
* - Otherwise: quadratic curve using formula:
* distFactor = (dist - fogDistance) * fogScale - 1.0
* haze = 1.0 - distFactor * distFactor
* where fogScale = 1.0 / (visibleDistance - fogDistance)
*
* 2. FOG VOLUMES (height-based):
* - Each volume has a fog factor = (1 / visibleDistance) * percentage
* - Ray-march from camera to fragment, accumulating fog through each volume
* - Use similar triangles: subDist = dist * (heightInVolume / totalDeltaZ)
* - Fog contribution = subDist * factor
* - Sum all volume contributions
*
* 3. Final fog = clamp(haze + volumeFog, 0, 1)
*/
export const fogFragmentShader = `
#ifdef USE_FOG
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 = fogVolumeData[offset + 0];
float volMinH = fogVolumeData[offset + 1];
float volMaxH = fogVolumeData[offset + 2];
float volPct = fogVolumeData[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 = fogVolumeData[offset + 0];
float volMinH = fogVolumeData[offset + 1];
float volMaxH = fogVolumeData[offset + 2];
float volPct = fogVolumeData[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
`;
/**
* Vertex shader code to pass world position for fog calculation.
*/
export const fogVertexShader = `
#ifdef USE_FOG
#define USE_FOG_WORLD_POSITION
varying vec3 vFogWorldPosition;
#endif
`;
export const fogVertexShaderWorldPos = `
#ifdef USE_FOG
vFogWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
#endif
`;
/**
* Install custom fog shaders globally.
* Call this once at app startup to replace Three.js default fog.
*/
export function installCustomFogShader(): void {
// Note: This modifies global shader chunks, affecting all materials
// For more control, use onBeforeCompile on individual materials
ShaderChunk.fog_pars_fragment = `
#ifdef USE_FOG
uniform vec3 fogColor;
varying float vFogDepth;
#ifdef FOG_EXP2
uniform float fogDensity;
#else
uniform float fogNear;
uniform float fogFar;
#endif
// Custom volumetric fog uniforms (only defined when USE_VOLUMETRIC_FOG is set)
// Format: [visDist, minH, maxH, percentage] x 3 volumes = 12 floats
#ifdef USE_VOLUMETRIC_FOG
uniform float fogVolumeData[12];
uniform float cameraHeight;
#endif
#ifdef USE_FOG_WORLD_POSITION
varying vec3 vFogWorldPosition;
#endif
#endif
`;
ShaderChunk.fog_fragment = fogFragmentShader;
// Add world position output to vertex shader
ShaderChunk.fog_pars_vertex = `
#ifdef USE_FOG
varying float vFogDepth;
#ifdef USE_FOG_WORLD_POSITION
varying vec3 vFogWorldPosition;
#endif
#endif
`;
ShaderChunk.fog_vertex = `
#ifdef USE_FOG
// Use Euclidean distance from camera, not view-space z-depth
// This ensures fog doesn't change when rotating the camera
vFogDepth = length(mvPosition.xyz);
#ifdef USE_FOG_WORLD_POSITION
vFogWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
#endif
#endif
`;
}
/**
* Shared fog shader uniform objects interface.
* These objects are passed directly to shaders so uniform values can be updated per-frame.
*/
export interface FogShaderUniformObjects {
fogVolumeData: { value: Float32Array };
cameraHeight: { value: number };
}
/**
* Add fog uniforms to a shader via onBeforeCompile.
* Use this for materials that need custom fog without modifying global chunks.
*
* @param shader - The shader object from onBeforeCompile
* @param fogUniforms - Shared uniform objects (pass the objects, not values)
*/
export function addFogUniformsToShader(
shader: { uniforms: Record<string, { value: unknown }> },
fogUniforms: FogShaderUniformObjects,
): void {
// Pass the uniform objects directly so they stay linked to FogProvider updates
shader.uniforms.fogVolumeData = fogUniforms.fogVolumeData;
shader.uniforms.cameraHeight = fogUniforms.cameraHeight;
}
/**
* Inject custom fog code into a material's shader.
* Call this in material's onBeforeCompile callback.
* This enables full volumetric fog support for the material.
*
* @param shader - The shader object from onBeforeCompile
* @param fogUniforms - Shared uniform objects from globalFogUniforms
*/
export function injectCustomFog(
shader: {
uniforms: Record<string, { value: unknown }>;
vertexShader: string;
fragmentShader: string;
},
fogUniforms: FogShaderUniformObjects,
): void {
// Add uniforms - pass objects directly so they stay linked
addFogUniformsToShader(shader, fogUniforms);
// Add world position varying to vertex shader
shader.vertexShader = shader.vertexShader.replace(
"#include <fog_pars_vertex>",
`#include <fog_pars_vertex>
#ifdef USE_FOG
#define USE_FOG_WORLD_POSITION
#define USE_VOLUMETRIC_FOG
varying vec3 vFogWorldPosition;
#endif`,
);
shader.vertexShader = shader.vertexShader.replace(
"#include <fog_vertex>",
`#include <fog_vertex>
#ifdef USE_FOG
vFogWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
#endif`,
);
// Add volumetric fog uniforms to fragment shader
shader.fragmentShader = shader.fragmentShader.replace(
"#include <fog_pars_fragment>",
`#include <fog_pars_fragment>
#ifdef USE_FOG
#define USE_VOLUMETRIC_FOG
uniform float fogVolumeData[12];
uniform float cameraHeight;
#define USE_FOG_WORLD_POSITION
varying vec3 vFogWorldPosition;
#endif`,
);
// Replace fog fragment with custom implementation
shader.fragmentShader = shader.fragmentShader.replace(
"#include <fog_fragment>",
fogFragmentShader,
);
}

View file

@ -22,9 +22,10 @@ import {
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;
// Opacity multiplier - set to 1.0 to match Tribes 2's baseTranslucency directly.
// Previously 0.5 to compensate for DoubleSide, but this made force fields too dim.
// Tribes 2 used the full baseTranslucency value even though back faces could render.
export const OPACITY_FACTOR = 1.0;
// Vertex shader
const vertexShader = `
@ -78,13 +79,10 @@ void main() {
}
// Tribes 2 GL_MODULATE: output = texture * vertexColor
// No gamma correction - textures use NoColorSpace and values pass through
// directly to display, matching how WaterBlock handles sRGB textures.
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);
@ -92,12 +90,19 @@ void main() {
// 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.
// Uses Torque's quadratic haze formula for consistency.
#ifdef USE_FOG
#ifdef FOG_EXP2
float fogFactor = 1.0 - exp(-fogDensity * fogDensity * vFogDepth * vFogDepth);
#else
float fogFactor = smoothstep(fogNear, fogFar, vFogDepth);
#endif
float dist = vFogDepth;
float fogFactor = 0.0;
if (dist > fogNear) {
if (dist >= fogFar) {
fogFactor = 1.0;
} else {
float fogScale = 1.0 / (fogFar - fogNear);
float distFactor = (dist - fogNear) * fogScale - 1.0;
fogFactor = 1.0 - distFactor * distFactor;
}
}
gl_FragColor.a *= 1.0 - fogFactor;
#endif
}

79
src/globalFogUniforms.ts Normal file
View file

@ -0,0 +1,79 @@
/**
* Global fog shader uniforms that can be shared across all materials.
*
* This module provides a singleton set of fog uniforms that:
* 1. Sky component updates each frame with camera height and fog volume data
* 2. Materials reference directly via import (avoiding React context issues)
*
* The uniform objects themselves are stable - only their .value properties change.
* This allows Three.js materials to reference them once and get automatic updates.
*/
const MAX_FOG_VOLUMES = 3;
/** Floats per fog volume: [visDist, minH, maxH, percentage] */
const FLOATS_PER_VOLUME = 4;
/**
* Shared fog shader uniform objects.
* Materials should import and use these directly in onBeforeCompile.
*/
export const globalFogUniforms = {
fogVolumeData: {
value: new Float32Array(MAX_FOG_VOLUMES * FLOATS_PER_VOLUME),
},
cameraHeight: { value: 0 },
};
/**
* Update the global fog uniforms with new values.
* Called by Sky component each frame.
*/
export function updateGlobalFogUniforms(
cameraHeight: number,
fogVolumeData: Float32Array,
): void {
globalFogUniforms.cameraHeight.value = cameraHeight;
globalFogUniforms.fogVolumeData.value.set(fogVolumeData);
}
/**
* Reset global fog uniforms to default values.
* Called when Sky unmounts to clean up fog state for next mission.
*/
export function resetGlobalFogUniforms(): void {
globalFogUniforms.cameraHeight.value = 0;
globalFogUniforms.fogVolumeData.value.fill(0);
}
/**
* Pack fog volume data into a flat array for shaders.
* Format: [visDist, minH, maxH, percentage] x 3 = 12 floats
*
* Note: Per-volume colors are NOT used in Tribes 2 ($specialFog defaults to false).
* All fog uses the global fogColor, so we don't pack color data.
*/
export function packFogVolumeData(
fogVolumes: Array<{
visibleDistance: number;
minHeight: number;
maxHeight: number;
percentage: number;
}>,
): Float32Array {
const data = new Float32Array(MAX_FOG_VOLUMES * FLOATS_PER_VOLUME);
for (let i = 0; i < MAX_FOG_VOLUMES; i++) {
const offset = i * FLOATS_PER_VOLUME;
const vol = fogVolumes[i];
if (vol) {
data[offset + 0] = vol.visibleDistance;
data[offset + 1] = vol.minHeight;
data[offset + 2] = vol.maxHeight;
data[offset + 3] = vol.percentage;
}
// Inactive volumes default to 0 (Float32Array is zero-initialized)
}
return data;
}

37
src/interiorMaterial.ts Normal file
View file

@ -0,0 +1,37 @@
/**
* Interior material shader modifications.
* Injects per-object-type lighting multipliers into MeshLambertMaterial.
*/
import { INTERIOR_LIGHTING } from "./lightingConfig";
/**
* Inject lighting multipliers into a MeshLambertMaterial shader.
* Call this from onBeforeCompile after other shader modifications (e.g., fog).
*/
export function injectInteriorLighting(shader: any): void {
// Add lighting multiplier uniforms
shader.uniforms.interiorDirectionalFactor = {
value: INTERIOR_LIGHTING.directional,
};
shader.uniforms.interiorAmbientFactor = { value: INTERIOR_LIGHTING.ambient };
// Declare uniforms in fragment shader
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`#include <common>
uniform float interiorDirectionalFactor;
uniform float interiorAmbientFactor;
`,
);
// Scale directional light contribution
shader.fragmentShader = shader.fragmentShader.replace(
"#include <lights_fragment_end>",
`#include <lights_fragment_end>
// Apply interior-specific lighting multipliers
reflectedLight.directDiffuse *= interiorDirectionalFactor;
reflectedLight.indirectDiffuse *= interiorAmbientFactor;
`,
);
}

33
src/lightingConfig.ts Normal file
View file

@ -0,0 +1,33 @@
/**
* Per-object-type lighting intensity multipliers.
*
* These allow fine-tuning of directional and ambient light contributions
* for each object type to match Tribes 2's original appearance.
*
* The Torque engine uses distinctly different lighting systems for each object type:
* - Terrain: Pre-baked lightmaps with directional sun dot-product calculation
* - Interiors: Vertex colors or lightmaps with animated light states
* - DTS Shapes: OpenGL hardware lighting with per-material flags
*
* Values are multipliers applied to the global Sun intensity (which is set to 1.0/1.0):
* - directional: Multiplier for directional (sun) light contribution
* - ambient: Multiplier for ambient light contribution (affects shadow darkness)
*/
export const TERRAIN_LIGHTING = {
directional: 4,
ambient: 1.5,
};
export const INTERIOR_LIGHTING = {
directional: 3,
ambient: 1,
};
export const SHAPE_LIGHTING = {
directional: 1,
ambient: 1.5,
};
// Note: Water does not use lighting - Tribes 2's Phase 2 (lightmap) is disabled.
// Water textures are rendered directly without scene lighting.

View file

@ -1,3 +1,7 @@
/**
* Shape material utilities and shader modifications.
*/
import {
MeshStandardMaterial,
Texture,
@ -6,6 +10,38 @@ import {
LinearMipmapLinearFilter,
SRGBColorSpace,
} from "three";
import { SHAPE_LIGHTING } from "./lightingConfig";
/**
* Inject lighting multipliers into a MeshLambertMaterial or MeshBasicMaterial shader.
* Call this from onBeforeCompile after other shader modifications (e.g., fog).
*/
export function injectShapeLighting(shader: any): void {
// Add lighting multiplier uniforms
shader.uniforms.shapeDirectionalFactor = {
value: SHAPE_LIGHTING.directional,
};
shader.uniforms.shapeAmbientFactor = { value: SHAPE_LIGHTING.ambient };
// Declare uniforms in fragment shader
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`#include <common>
uniform float shapeDirectionalFactor;
uniform float shapeAmbientFactor;
`,
);
// Scale directional and ambient light contributions
shader.fragmentShader = shader.fragmentShader.replace(
"#include <lights_fragment_end>",
`#include <lights_fragment_end>
// Apply shape-specific lighting multipliers
reflectedLight.directDiffuse *= shapeDirectionalFactor;
reflectedLight.indirectDiffuse *= shapeAmbientFactor;
`,
);
}
// Shared shader modification function to avoid duplication
const alphaAsRoughnessShaderModifier = (shader: any) => {

View file

@ -3,6 +3,15 @@
* Handles multi-layer texture blending for Tribes 2 terrain rendering.
*/
import { TERRAIN_LIGHTING } from "./lightingConfig";
// Terrain and texture dimensions (must match TerrainBlock.tsx constants)
const TERRAIN_SIZE = 256; // Terrain grid size in squares
const LIGHTMAP_SIZE = 512; // Lightmap texture size (2 pixels per terrain square)
// Texture brightness scale to prevent clipping and preserve shadow visibility
const TEXTURE_BRIGHTNESS_SCALE = 0.7;
// Detail texture tiling factor.
// Torque uses world-space generation: U = worldX * (62.0 / textureWidth)
// For 256px texture across 2048 world units, this gives ~496 repeats mathematically.
@ -24,6 +33,7 @@ export function updateTerrainTextureShader({
tiling,
debugMode = false,
detailTexture = null,
lightmap = null,
}: {
shader: any;
baseTextures: any[];
@ -32,9 +42,16 @@ export function updateTerrainTextureShader({
tiling: Record<number, number>;
debugMode?: boolean;
detailTexture?: any;
lightmap?: any;
}) {
const layerCount = baseTextures.length;
// Add terrain lighting multiplier uniforms
shader.uniforms.terrainDirectionalFactor = {
value: TERRAIN_LIGHTING.directional,
};
shader.uniforms.terrainAmbientFactor = { value: TERRAIN_LIGHTING.ambient };
baseTextures.forEach((tex, i) => {
shader.uniforms[`albedo${i}`] = { value: tex };
});
@ -60,6 +77,11 @@ 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) {
shader.uniforms.terrainLightmap = { value: lightmap };
}
// Add detail texture uniforms
if (detailTexture) {
shader.uniforms.detailTexture = { value: detailTexture };
@ -82,6 +104,8 @@ vTerrainWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;`,
// Declare our uniforms at the top of the fragment shader
shader.fragmentShader =
`
uniform float terrainDirectionalFactor;
uniform float terrainAmbientFactor;
uniform sampler2D albedo0;
uniform sampler2D albedo1;
uniform sampler2D albedo2;
@ -101,6 +125,7 @@ uniform float tiling4;
uniform float tiling5;
uniform float debugMode;
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
${lightmap ? "uniform sampler2D terrainLightmap;" : ""}
${
detailTexture
? `uniform sampler2D detailTexture;
@ -169,11 +194,14 @@ float getWireframe(vec2 uv, float gridSize, float lineWidth) {
}
// 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;` : ""}
// Add +0.5 texel offset: Torque samples alpha at grid corners (integer indices),
// but GPU linear filtering samples at texel centers. This offset aligns them.
vec2 alphaUv = baseUv + vec2(0.5 / ${TERRAIN_SIZE}.0);
float a1 = texture2D(mask1, alphaUv).r;
${layerCount > 1 ? `float a2 = texture2D(mask2, alphaUv).r;` : ""}
${layerCount > 2 ? `float a3 = texture2D(mask3, alphaUv).r;` : ""}
${layerCount > 3 ? `float a4 = texture2D(mask4, alphaUv).r;` : ""}
${layerCount > 4 ? `float a5 = texture2D(mask5, alphaUv).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));` : ""}
@ -203,25 +231,83 @@ float getWireframe(vec2 uv, float gridSize, float lineWidth) {
: ""
}
// Debug mode wireframe handling
// Apply texture color or debug mode solid gray
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);
}
// Solid gray to visualize lighting only (without texture influence)
diffuseColor.rgb = vec3(0.5);
} else {
diffuseColor.rgb = textureColor;
// Scale texture to prevent clipping, preserving shadow visibility
diffuseColor.rgb = textureColor * ${TEXTURE_BRIGHTNESS_SCALE};
}
`,
);
// When lightmap is available, replace vertex normal-based lighting with smooth lightmap
// This eliminates banding by using pre-computed per-pixel NdotL values
if (lightmap) {
// Override the RE_Direct_Lambert function to use our lightmap NdotL
// instead of computing dotNL from vertex normals
shader.fragmentShader = shader.fragmentShader.replace(
"#include <lights_lambert_pars_fragment>",
`#include <lights_lambert_pars_fragment>
// Override RE_Direct to use terrain lightmap for smooth NdotL
#undef RE_Direct
void RE_Direct_TerrainLightmap( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) {
// Sample pre-computed terrain lightmap (smooth NdotL values)
// Add +0.5 texel offset to align GPU texel-center sampling with Torque's corner sampling
vec2 lightmapUv = vMapUv + vec2(0.5 / ${LIGHTMAP_SIZE}.0);
float lightmapNdotL = texture2D(terrainLightmap, lightmapUv).r;
// Use lightmap NdotL instead of dot(geometryNormal, directLight.direction)
// directLight.color already has shadow factor applied from getShadow()
// Apply terrain-specific directional intensity multiplier
vec3 directIrradiance = lightmapNdotL * directLight.color * terrainDirectionalFactor;
// Debug mode: visualize raw lightmap values (no textures)
if (debugMode > 0.5) {
reflectedLight.directDiffuse = directIrradiance;
} else {
reflectedLight.directDiffuse += directIrradiance * BRDF_Lambert( material.diffuseColor );
}
}
#define RE_Direct RE_Direct_TerrainLightmap
`,
);
// Override lights_fragment_begin to fix hemisphere light irradiance calculation
// The default uses geometryNormal which causes banding
shader.fragmentShader = shader.fragmentShader.replace(
"#include <lights_fragment_begin>",
`#include <lights_fragment_begin>
// Fix: Recalculate irradiance without using vertex normals (causes banding)
// Use flat upward normal for hemisphere/light probe calculations
#if defined( RE_IndirectDiffuse )
{
vec3 flatNormal = vec3(0.0, 1.0, 0.0);
irradiance = getAmbientLightIrradiance( ambientLightColor );
#if defined( USE_LIGHT_PROBES )
irradiance += getLightProbeIrradiance( lightProbe, flatNormal );
#endif
#if ( NUM_HEMI_LIGHTS > 0 )
for ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {
irradiance += getHemisphereLightIrradiance( hemisphereLights[i], flatNormal );
}
#endif
}
#endif
`,
);
}
// Scale ambient/indirect lighting to darken shadows on terrain
shader.fragmentShader = shader.fragmentShader.replace(
"#include <lights_fragment_end>",
`#include <lights_fragment_end>
// Scale indirect (ambient) light to increase shadow contrast on terrain
reflectedLight.indirectDiffuse *= terrainAmbientFactor;
`,
);
}

View file

@ -1,4 +1,6 @@
import { ShaderMaterial, Texture, DoubleSide, Color } from "three";
import { globalFogUniforms } from "./globalFogUniforms";
import { fogFragmentShader } from "./fogShader";
/**
* Tribes 2 WaterBlock shader material
@ -21,6 +23,11 @@ import { ShaderMaterial, Texture, DoubleSide, Color } from "three";
const vertexShader = /* glsl */ `
#include <fog_pars_vertex>
#ifdef USE_FOG
#define USE_FOG_WORLD_POSITION
varying vec3 vFogWorldPosition;
#endif
uniform float uTime;
uniform float uWaveMagnitude;
@ -46,6 +53,12 @@ const vertexShader = /* glsl */ `
vec3 displaced = position;
displaced.y += getWaveHeight(worldPos.xyz);
// Calculate final world position after displacement for fog
#ifdef USE_FOG
vec4 displacedWorldPos = modelMatrix * vec4(displaced, 1.0);
vFogWorldPosition = displacedWorldPos.xyz;
#endif
// Calculate view vector for environment mapping
vViewVector = cameraPosition - worldPos.xyz;
vDistance = length(vViewVector);
@ -53,19 +66,36 @@ const vertexShader = /* glsl */ `
vec4 mvPosition = viewMatrix * modelMatrix * vec4(displaced, 1.0);
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
// Set fog depth (distance from camera) - normally done by fog_vertex include
// but we can't use that include because it references 'transformed' which we don't have
#ifdef USE_FOG
vFogDepth = length(mvPosition.xyz);
#endif
}
`;
const fragmentShader = /* glsl */ `
#include <fog_pars_fragment>
// Enable volumetric fog (must be defined before fog uniforms)
#ifdef USE_FOG
#define USE_VOLUMETRIC_FOG
#define USE_FOG_WORLD_POSITION
#endif
uniform float uTime;
uniform float uOpacity;
uniform float uEnvMapIntensity;
uniform sampler2D uBaseTexture;
uniform sampler2D uEnvMapTexture;
// Volumetric fog uniforms
#ifdef USE_FOG
uniform float fogVolumeData[12];
uniform float cameraHeight;
varying vec3 vFogWorldPosition;
#endif
varying vec3 vWorldPosition;
varying vec3 vViewVector;
varying float vDistance;
@ -95,8 +125,6 @@ const fragmentShader = /* glsl */ `
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)
@ -110,21 +138,14 @@ const fragmentShader = /* glsl */ `
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;
@ -132,53 +153,38 @@ const fragmentShader = /* glsl */ `
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)
// Combined alpha and color
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 ===
// Reflection UV calculation from engine (fluidQuadTree.cc lines 910-962)
// Engine uses eye-to-point vector (point - eye), unnormalized.
// vViewVector is camera - worldPos (point-to-eye), so we negate it.
// Torque Z-up maps XY to UV; Three.js Y-up maps XZ to UV.
vec3 reflectVec = -vViewVector;
reflectVec.y = abs(reflectVec.y); // Y is vertical in Three.js (was Z in Torque)
reflectVec.y = abs(reflectVec.y);
if (reflectVec.y < 0.001) reflectVec.y = 0.001;
vec2 envUV;
if (vDistance < 0.001) {
envUV = vec2(0.0);
} else {
// Standard UV reflection mapping with adjustment to reduce edge emphasis
float value = (vDistance - reflectVec.y) / (vDistance * vDistance);
envUV.x = reflectVec.x * value;
envUV.y = reflectVec.z * value; // Z maps to V in Three.js Y-up
envUV.y = reflectVec.z * 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)
// Engine uses GL_MODULATE with color (1,1,1,envMapIntensity), so texture alpha
// is multiplied with intensity before additive blend.
vec3 finalColor = baseColor + envColor.rgb * envColor.a * uEnvMapIntensity;
// Note: Tribes 2 water does NOT use lighting - Phase 2 (lightmap) is disabled
// in the original engine. Water colors come directly from textures.
gl_FragColor = vec4(finalColor, combinedAlpha);
// Apply scene fog (integrated with Three.js fog system)
#include <fog_fragment>
// Apply volumetric fog using shared Torque-style fog shader
${fogFragmentShader}
}
`;
@ -201,6 +207,9 @@ export function createWaterMaterial(options?: {
fogColor: { value: new Color() },
fogNear: { value: 1 },
fogFar: { value: 2000 },
// Volumetric fog uniforms (shared with global fog system)
fogVolumeData: globalFogUniforms.fogVolumeData,
cameraHeight: globalFogUniforms.cameraHeight,
},
vertexShader,
fragmentShader,