improve lighting, shadows, fix terrain triangle geometry

This commit is contained in:
Brian Beck 2025-12-10 14:14:51 -08:00
parent 4e5a0327a0
commit bcf4f4a1a5
1232 changed files with 629 additions and 207 deletions

View file

@ -300,32 +300,40 @@ const cloudFragmentShader = `
varying vec2 vUv;
varying float vAlpha;
// Debug grid using screen-space derivatives for sharp, anti-aliased lines
float debugGrid(vec2 uv, float gridSize, float lineWidth) {
vec2 scaledUV = uv * gridSize;
vec2 grid = abs(fract(scaledUV - 0.5) - 0.5) / fwidth(scaledUV);
float line = min(grid.x, grid.y);
return 1.0 - min(line / lineWidth, 1.0);
}
void main() {
vec4 texColor = texture2D(cloudTexture, vUv);
// Debug mode: show layer-colored clouds (red, green, blue for layers 0, 1, 2)
if (debugMode > 0.5) {
vec3 debugColor;
if (layerIndex == 0) {
debugColor = vec3(1.0, 0.3, 0.3); // Red
} else if (layerIndex == 1) {
debugColor = vec3(0.3, 1.0, 0.3); // Green
} else {
debugColor = vec3(0.3, 0.3, 1.0); // Blue
}
// Use same alpha calculation as normal mode
gl_FragColor = vec4(debugColor, texColor.a * vAlpha);
return;
}
// Tribes 2 uses GL_MODULATE: final = texture × vertex color
// Vertex color is white with varying alpha, so:
// Final RGB = Texture RGB × 1.0 = Texture RGB
// Final Alpha = Texture Alpha × Vertex Alpha
float finalAlpha = texColor.a * vAlpha;
vec3 color = texColor.rgb;
// Debug mode: overlay R/G/B grid for layers 0/1/2
if (debugMode > 0.5) {
float gridIntensity = debugGrid(vUv, 4.0, 1.5);
vec3 gridColor;
if (layerIndex == 0) {
gridColor = vec3(1.0, 0.0, 0.0); // Red
} else if (layerIndex == 1) {
gridColor = vec3(0.0, 1.0, 0.0); // Green
} else {
gridColor = vec3(0.0, 0.0, 1.0); // Blue
}
color = mix(color, gridColor, gridIntensity * 0.5);
}
// Output clouds with texture color and combined alpha
gl_FragColor = vec4(texColor.rgb, finalAlpha);
gl_FragColor = vec4(color, finalAlpha);
}
`;

View file

@ -18,17 +18,6 @@ import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
import { injectInteriorLighting } from "../interiorMaterial";
/**
* Lightmap intensity multiplier.
* Lightmaps contain baked lighting from interior-specific lights only
* (not scene sun/ambient - that's applied in real-time).
*
* Three.js's BRDF_Lambert divides by PI for energy conservation, but Torque
* (2001) used simple multiplication: base_texture * lightmap in gamma space.
* We multiply by PI to cancel out Three.js's division.
*/
const LIGHTMAP_INTENSITY = Math.PI;
/**
* Load a .gltf file that was converted from a .dif, used for "interior" models.
*/
@ -46,6 +35,8 @@ function InteriorTexture({
material?: Material;
lightMap?: Texture | null;
}) {
const debugContext = useDebug();
const debugMode = debugContext?.debugMode ?? false;
const url = textureToUrl(materialName);
const texture = useTexture(url, (texture) => setupColor(texture));
@ -54,34 +45,54 @@ function InteriorTexture({
const flagNames = new Set<string>(material?.userData?.flag_names ?? []);
const isSelfIlluminating = flagNames.has("SelfIlluminating");
// Check for SurfaceOutsideVisible flag (surfaces that receive scene ambient light)
const surfaceFlagNames = new Set<string>(
material?.userData?.surface_flag_names ?? [],
);
const isSurfaceOutsideVisible = surfaceFlagNames.has("SurfaceOutsideVisible");
// Inject volumetric fog and lighting multipliers into materials
const onBeforeCompile = useCallback((shader: any) => {
injectCustomFog(shader, globalFogUniforms);
injectInteriorLighting(shader);
}, []);
// NOTE: This hook must be called unconditionally (before any early returns)
const onBeforeCompile = useCallback(
(shader: any) => {
injectCustomFog(shader, globalFogUniforms);
injectInteriorLighting(shader, {
surfaceOutsideVisible: isSurfaceOutsideVisible,
debugMode,
});
},
[isSurfaceOutsideVisible, debugMode],
);
// Key includes shader-affecting props to force recompilation when they change
// (r3f doesn't reactively recompile shaders on prop changes)
const materialKey = `${isSurfaceOutsideVisible}-${debugMode}`;
// Self-illuminating materials are fullbright (unlit), no lightmap
if (isSelfIlluminating) {
return (
<meshBasicMaterial
key={materialKey}
map={texture}
side={2}
toneMapped={false}
onBeforeCompile={onBeforeCompile}
/>
);
}
// 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
// MeshLambertMaterial for diffuse-only lighting (matches Tribes 2's GL pipeline)
// Shader modifications in onBeforeCompile:
// - Outside surfaces (SurfaceOutsideVisible): scene lighting + additive lightmap
// - Inside surfaces (ZoneInside): additive lightmap only, no scene lighting
// Lightmap intensity is handled in the shader, not via material prop
// toneMapped={false} to match Torque's direct output (no HDR tone mapping)
// Using FrontSide (default) - normals are fixed in io_dif Blender export
return (
<meshLambertMaterial
key={materialKey}
map={texture}
lightMap={lightMap ?? undefined}
lightMapIntensity={lightMap ? LIGHTMAP_INTENSITY : undefined}
side={2}
toneMapped={false}
onBeforeCompile={onBeforeCompile}
/>
);
@ -154,7 +165,8 @@ function InteriorMesh({ node }: { node: Mesh }) {
export const InteriorModel = memo(
({ interiorFile }: { interiorFile: string }) => {
const { nodes } = useInterior(interiorFile);
const { debugMode } = useDebug();
const debugContext = useDebug();
const debugMode = debugContext?.debugMode ?? false;
return (
<group rotation={[0, -Math.PI / 2, 0]}>
@ -186,7 +198,8 @@ function InteriorPlaceholder({
}
function DebugInteriorPlaceholder({ label }: { label?: string }) {
const { debugMode } = useDebug();
const debugContext = useDebug();
const debugMode = debugContext?.debugMode ?? false;
return debugMode ? <InteriorPlaceholder color="red" label={label} /> : null;
}

View file

@ -94,7 +94,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
[speedMultiplier, setSpeedMultiplier],
);
// Read persisted settings from localStoarge.
// Read persisted settings from localStorage.
useEffect(() => {
let savedSettings: PersistedSettings = {};
try {

View file

@ -100,7 +100,10 @@ function SkyBoxTexture({
// 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);
return (
HORIZON_FOG_HEIGHT /
Math.sqrt(skyBoxPtX * skyBoxPtX + HORIZON_FOG_HEIGHT * HORIZON_FOG_HEIGHT)
);
}, [fogState]);
return (
@ -330,7 +333,10 @@ function SolidColorSky({
if (!fogState) return 0.18;
const mRadius = fogState.visibleDistance * 0.95;
const skyBoxPtX = mRadius / Math.sqrt(3);
return HORIZON_FOG_HEIGHT / Math.sqrt(skyBoxPtX * skyBoxPtX + HORIZON_FOG_HEIGHT * HORIZON_FOG_HEIGHT);
return (
HORIZON_FOG_HEIGHT /
Math.sqrt(skyBoxPtX * skyBoxPtX + HORIZON_FOG_HEIGHT * HORIZON_FOG_HEIGHT)
);
}, [fogState]);
return (

View file

@ -67,8 +67,8 @@ export function Sun({ object }: { object: TorqueObject }) {
shadow-camera-bottom={-shadowCameraSize}
shadow-camera-near={100}
shadow-camera-far={12000}
shadow-bias={-0.0003}
shadow-normalBias={0.5}
shadow-bias={-0.00001}
shadow-normalBias={0.4}
/>
{/* Ambient fill light - prevents pure black shadows */}
<ambientLight color={ambient} intensity={ambientIntensity} />

View file

@ -2,13 +2,15 @@ import { memo, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useQuery } from "@tanstack/react-query";
import {
BufferAttribute,
BufferGeometry,
DataTexture,
Float32BufferAttribute,
FloatType,
LinearFilter,
NearestFilter,
NoColorSpace,
ClampToEdgeWrapping,
PlaneGeometry,
RedFormat,
RepeatWrapping,
UnsignedByteType,
@ -28,6 +30,114 @@ const TERRAIN_SIZE = 256;
const LIGHTMAP_SIZE = 512; // Match Tribes 2's 512x512 lightmap
const HEIGHT_SCALE = 2048; // Matches displacementScale for terrain
/**
* Create terrain geometry with Torque-style alternating diagonal triangulation.
*
* Torque splits each grid square into two triangles, but alternates the diagonal
* direction in a checkerboard pattern: ((x ^ y) & 1) == 0 determines Split45.
*
* - Split45 (/): diagonal from (x,y) to (x+1,y+1) - bottom-left to top-right
* - Split135 (\): diagonal from (x+1,y) to (x,y+1) - bottom-right to top-left
*
* This creates more accurate terrain, especially on ridges and peaks where the
* zigzag pattern of alternating triangles better represents the heightmap data.
*
* Geometry is created in X-Y plane (like PlaneGeometry) then rotated to match
* terrain orientation.
*/
function createTerrainGeometry(size: number, segments: number): BufferGeometry {
const geometry = new BufferGeometry();
const vertexCount = (segments + 1) * (segments + 1);
const positions = new Float32Array(vertexCount * 3);
const normals = new Float32Array(vertexCount * 3);
const uvs = new Float32Array(vertexCount * 2);
// Pre-allocate index buffer: segments² squares × 2 triangles × 3 indices
// Use Uint32Array since vertex count (257² = 66049) exceeds Uint16 max (65535)
const indexCount = segments * segments * 6;
const indices = new Uint32Array(indexCount);
let indexOffset = 0;
const segmentSize = size / segments;
// Create vertices in X-Y plane (same as PlaneGeometry)
// PlaneGeometry goes from top-left to bottom-right with UVs 0→1
// X: -size/2 to +size/2 (left to right)
// Y: +size/2 to -size/2 (top to bottom in initial X-Y plane)
for (let row = 0; row <= segments; row++) {
for (let col = 0; col <= segments; col++) {
const idx = row * (segments + 1) + col;
// Position in X-Y plane (Z=0), centered at origin
positions[idx * 3] = col * segmentSize - size / 2; // X: -size/2 to +size/2
positions[idx * 3 + 1] = size / 2 - row * segmentSize; // Y: +size/2 to -size/2
positions[idx * 3 + 2] = 0; // Z: 0 (will be displaced after rotation)
// Default normal pointing +Z (out of plane, will become +Y after rotation)
normals[idx * 3] = 0;
normals[idx * 3 + 1] = 0;
normals[idx * 3 + 2] = 1;
// UV coordinates (0 to 1), matching PlaneGeometry
uvs[idx * 2] = col / segments;
uvs[idx * 2 + 1] = 1 - row / segments; // Flip V so row 0 is at V=1
}
}
// Create triangle indices with Torque-style alternating diagonals
// Using CCW winding for front face (Three.js default)
for (let row = 0; row < segments; row++) {
for (let col = 0; col < segments; col++) {
// In this layout (Y decreasing with row):
// a---b (row)
// | |
// c---d (row+1)
const a = row * (segments + 1) + col;
const b = a + 1;
const c = (row + 1) * (segments + 1) + col;
const d = c + 1;
// Torque's split decision: ((x ^ y) & 1) == 0 means Split45
const split45 = ((col ^ row) & 1) === 0;
if (split45) {
// Split45: diagonal from a to d (top-left to bottom-right in screen space)
// Triangle 1: a, c, d (CCW from front)
// Triangle 2: a, d, b (CCW from front)
indices[indexOffset++] = a;
indices[indexOffset++] = c;
indices[indexOffset++] = d;
indices[indexOffset++] = a;
indices[indexOffset++] = d;
indices[indexOffset++] = b;
} else {
// Split135: diagonal from b to c (top-right to bottom-left in screen space)
// Triangle 1: a, c, b (CCW from front)
// Triangle 2: b, c, d (CCW from front)
indices[indexOffset++] = a;
indices[indexOffset++] = c;
indices[indexOffset++] = b;
indices[indexOffset++] = b;
indices[indexOffset++] = c;
indices[indexOffset++] = d;
}
}
}
geometry.setIndex(new BufferAttribute(indices, 1));
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
// Apply same rotations as the original PlaneGeometry approach:
// rotateX(-90°) puts the X-Y plane into X-Z (horizontal), with +Y becoming up
// rotateY(-90°) rotates around Y axis to match terrain coordinate system
geometry.rotateX(-Math.PI / 2);
geometry.rotateY(-Math.PI / 2);
return geometry;
}
/**
* Displace terrain vertices on CPU and compute smooth normals from heightmap gradients.
*
@ -38,7 +148,7 @@ const HEIGHT_SCALE = 2048; // Matches displacementScale for terrain
* that would occur with face normals from computeVertexNormals().
*/
function displaceTerrainAndComputeNormals(
geometry: PlaneGeometry,
geometry: BufferGeometry,
heightMap: Uint16Array,
squareSize: number,
): void {
@ -143,7 +253,81 @@ function displaceTerrainAndComputeNormals(
}
/**
* Generate a terrain lightmap texture with smooth normals.
* Ray-march through heightmap to determine if a point is in shadow.
* Uses the same coordinate system as the terrain geometry.
*
* @param startCol - Starting column in heightmap coordinates
* @param startRow - Starting row in heightmap coordinates
* @param startHeight - Starting height in world units
* @param lightDir - Direction TOWARD the light (normalized)
* @param squareSize - World units per heightmap cell
* @param getHeight - Function to sample terrain height at a position
* @returns 1.0 if lit, 0.0 if in shadow
*/
function rayMarchShadow(
startCol: number,
startRow: number,
startHeight: number,
lightDir: Vector3,
squareSize: number,
getHeight: (col: number, row: number) => number,
): number {
// Convert light direction to heightmap coordinate steps
// World coordinate mapping (after geometry rotations):
// - col (U) → world +Z, so lightDir.z affects col
// - row (V) → world +X, so lightDir.x affects row
// - height → world +Y, so lightDir.y affects height
const stepCol = lightDir.z / squareSize;
const stepRow = lightDir.x / squareSize;
const stepHeight = lightDir.y;
// Normalize to step ~0.5 heightmap units per iteration for good sampling
const horizontalLen = Math.sqrt(stepCol * stepCol + stepRow * stepRow);
if (horizontalLen < 0.0001) {
// Light is nearly vertical - no self-shadowing possible
return 1.0;
}
const stepScale = 0.5 / horizontalLen;
const dCol = stepCol * stepScale;
const dRow = stepRow * stepScale;
const dHeight = stepHeight * stepScale;
let col = startCol;
let row = startRow;
let height = startHeight + 0.1; // Small offset to avoid self-intersection
// March until we exit terrain bounds or confirm we're lit
const maxSteps = TERRAIN_SIZE * 3; // Enough to cross terrain diagonally
for (let i = 0; i < maxSteps; i++) {
col += dCol;
row += dRow;
height += dHeight;
// Check if ray exited terrain bounds horizontally
if (col < 0 || col >= TERRAIN_SIZE || row < 0 || row >= TERRAIN_SIZE) {
return 1.0; // Exited terrain, not in shadow
}
// Check if ray is above max terrain height
if (height > HEIGHT_SCALE) {
return 1.0; // Above all terrain, not in shadow
}
// Sample terrain height at current position
const terrainHeight = getHeight(col, row);
// If ray is below terrain surface, we're in shadow
if (height < terrainHeight) {
return 0.0;
}
}
return 1.0; // Reached max steps, assume not in shadow
}
/**
* Generate a terrain lightmap texture with smooth normals and ray-traced shadows.
*
* The key insight: banding occurs because vertex normals are computed from
* discrete heightmap samples, creating discontinuities at grid boundaries.
@ -152,10 +336,14 @@ function displaceTerrainAndComputeNormals(
* lightmap pixel. This produces smooth gradients because the interpolated
* height surface is C0 continuous (no discontinuities).
*
* Shadows are computed by ray-marching through the heightmap toward the sun,
* checking if the terrain blocks the light path. This avoids shadow acne
* because it's a geometric intersection test, not a depth buffer comparison.
*
* @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
* @returns DataTexture with lighting intensity values (NdotL * shadow)
*/
function generateTerrainLightmap(
heightMap: Uint16Array,
@ -163,19 +351,19 @@ function generateTerrainLightmap(
squareSize: number,
): DataTexture {
// Helper to get bilinearly interpolated height at any fractional position
// Supports negative and out-of-range coordinates via wrapping
// Supports negative and out-of-range coordinates via clamping for shadow rays
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;
// Clamp to valid range (don't wrap for shadow rays)
const clampedCol = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
const clampedRow = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
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 col0 = Math.floor(clampedCol);
const row0 = Math.floor(clampedRow);
const col1 = Math.min(col0 + 1, TERRAIN_SIZE - 1);
const row1 = Math.min(row0 + 1, TERRAIN_SIZE - 1);
const fx = wrappedCol - col0;
const fy = wrappedRow - row0;
const fx = clampedCol - col0;
const fy = clampedRow - row0;
const h00 = heightMap[row0 * TERRAIN_SIZE + col0] / 65535;
const h10 = heightMap[row0 * TERRAIN_SIZE + col1] / 65535;
@ -201,7 +389,7 @@ function generateTerrainLightmap(
// 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
// Generate lightmap by computing normal and shadow 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():
@ -210,6 +398,9 @@ function generateTerrainLightmap(
const col = lCol / 2 + 0.25;
const row = lRow / 2 + 0.25;
// Get height at this position for shadow ray starting point
const surfaceHeight = getInterpolatedHeight(col, row);
// Compute gradient using central differences on interpolated heights
const hL = getInterpolatedHeight(col - eps, row);
const hR = getInterpolatedHeight(col + eps, row);
@ -236,7 +427,23 @@ function generateTerrainLightmap(
(nz / len) * lightDir.z,
);
lightmapData[lRow * LIGHTMAP_SIZE + lCol] = Math.floor(NdotL * 255);
// Ray-march to determine shadow (only if surface faces the light)
let shadow = 1.0;
if (NdotL > 0) {
shadow = rayMarchShadow(
col,
row,
surfaceHeight,
lightDir,
squareSize,
getInterpolatedHeight,
);
}
// Store NdotL * shadow in lightmap
lightmapData[lRow * LIGHTMAP_SIZE + lCol] = Math.floor(
NdotL * shadow * 255,
);
}
}
@ -349,13 +556,12 @@ export const TerrainBlock = memo(function TerrainBlock({
const { data: terrain } = useTerrain(terrainFile);
// Shared geometry for all tiles - with smooth normals computed from heightmap
// Uses Torque-style alternating diagonal triangulation for accurate terrain
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);
const geometry = createTerrainGeometry(size, TERRAIN_SIZE);
// Displace vertices on CPU and compute smooth normals
displaceTerrainAndComputeNormals(geometry, terrain.heightMap, squareSize);

View file

@ -1,11 +1,5 @@
import { memo, Suspense, useCallback, useMemo } from "react";
import {
DataTexture,
DoubleSide,
FrontSide,
MeshLambertMaterial,
type PlaneGeometry,
} from "three";
import { type BufferGeometry, DataTexture, FrontSide } from "three";
import { useTexture } from "@react-three/drei";
import {
FALLBACK_TEXTURE_URL,
@ -36,7 +30,7 @@ interface TerrainTileProps {
blockSize: number;
basePosition: { x: number; z: number };
textureNames: string[];
geometry: PlaneGeometry;
geometry: BufferGeometry;
displacementMap: DataTexture;
visibilityMask: DataTexture;
alphaTextures: DataTexture[];
@ -122,7 +116,7 @@ function BlendedTerrainTextures({
key={materialKey}
map={displacementMap}
depthWrite
side={DoubleSide}
side={FrontSide}
onBeforeCompile={onBeforeCompile}
/>
);
@ -146,12 +140,8 @@ function TerrainMaterial({
return (
<Suspense
fallback={
<meshLambertMaterial
color="rgb(0, 109, 56)"
displacementMap={displacementMap}
displacementScale={2048}
wireframe
/>
// Geometry is already CPU-displaced, so no displacementMap needed
<meshLambertMaterial color="rgb(0, 109, 56)" wireframe />
}
>
<BlendedTerrainTextures
@ -181,7 +171,7 @@ export const TerrainTile = memo(function TerrainTile({
visible = true,
}: TerrainTileProps) {
const position = useMemo(() => {
// PlaneGeometry is centered at origin, but Tribes 2 terrain origin is at
// Terrain geometry is centered at origin, but Tribes 2 terrain origin is at
// corner. The engine always uses the default square size (8) for positioning.
const geometryOffset = (DEFAULT_SQUARE_SIZE * 256) / 2;
return [
@ -195,8 +185,8 @@ export const TerrainTile = memo(function TerrainTile({
<mesh
position={position}
geometry={geometry}
receiveShadow
castShadow
receiveShadow
visible={visible}
>
<TerrainMaterial

View file

@ -1,37 +1,163 @@
/**
* Interior material shader modifications.
* Injects per-object-type lighting multipliers into MeshLambertMaterial.
*/
import { INTERIOR_LIGHTING } from "./lightingConfig";
import { Vector3 } from "three";
/**
* Inject lighting multipliers into a MeshLambertMaterial shader.
* Call this from onBeforeCompile after other shader modifications (e.g., fog).
* Interior material shader modifications for MeshLambertMaterial.
*
* Matches Torque's rendering formula (sceneLighting.cc + interiorRender.cc):
* output = clamp(lighting × texture, 0, 1)
*
* Where:
* - Outside surfaces: lighting = clamp(clamp(scene_lighting) + lightmap)
* - Inside surfaces: lighting = lightmap
* - scene_lighting = sun_color × N·L + ambient_color
* - All operations in sRGB/gamma space
*
* Key insights from Torque source:
* 1. Scene lighting is clamped to [0,1] BEFORE adding to lightmap (line 1785)
* 2. The sum is clamped per-channel to [0,1] (lines 1817-1827)
* 3. Mission sun/ambient colors ARE sRGB values - Torque used them directly
* in gamma space math. When we pass them to Three.js (which interprets them
* as linear), the numerical values are preserved, so extracted lighting
* gives us the sRGB values we need - NO conversion required.
*
* We only convert:
* - Texture: linearToSRGB (Three.js decoded from sRGB)
* - Lightmap: linearToSRGB (Three.js decoded from sRGB)
* - Result: sRGBToLinear (Three.js expects linear output)
*/
export function injectInteriorLighting(shader: any): void {
// Add lighting multiplier uniforms
shader.uniforms.interiorDirectionalFactor = {
value: INTERIOR_LIGHTING.directional,
export type InteriorLightingOptions = {
surfaceOutsideVisible?: boolean;
debugMode?: boolean;
};
// sRGB <-> Linear conversion functions (GLSL)
const colorSpaceFunctions = /* glsl */ `
vec3 interiorLinearToSRGB(vec3 linear) {
vec3 higher = pow(linear, vec3(1.0/2.4)) * 1.055 - 0.055;
vec3 lower = linear * 12.92;
return mix(lower, higher, step(vec3(0.0031308), linear));
}
vec3 interiorSRGBToLinear(vec3 srgb) {
vec3 higher = pow((srgb + 0.055) / 1.055, vec3(2.4));
vec3 lower = srgb / 12.92;
return mix(lower, higher, step(vec3(0.04045), srgb));
}
// Debug grid overlay function using screen-space derivatives for sharp, anti-aliased lines
// Returns 1.0 on grid lines, 0.0 elsewhere
float debugGrid(vec2 uv, float gridSize, float lineWidth) {
vec2 scaledUV = uv * gridSize;
vec2 grid = abs(fract(scaledUV - 0.5) - 0.5) / fwidth(scaledUV);
float line = min(grid.x, grid.y);
return 1.0 - min(line / lineWidth, 1.0);
}
`;
export function injectInteriorLighting(
shader: any,
options?: InteriorLightingOptions,
): void {
const isOutsideVisible = options?.surfaceOutsideVisible ?? false;
const isDebugMode = options?.debugMode ?? 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
shader.uniforms.interiorDebugColor = {
value: isOutsideVisible
? new Vector3(0.0, 0.4, 1.0)
: new Vector3(1.0, 0.2, 0.0),
};
shader.uniforms.interiorAmbientFactor = { value: INTERIOR_LIGHTING.ambient };
// Declare uniforms in fragment shader
// Add color space functions and uniform
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`#include <common>
uniform float interiorDirectionalFactor;
uniform float interiorAmbientFactor;
${colorSpaceFunctions}
uniform bool useSceneLighting;
uniform bool interiorDebugMode;
uniform vec3 interiorDebugColor;
`,
);
// Scale directional light contribution
// Disable default lightmap handling - we'll handle it in the output
// (MeshLambertMaterial doesn't use envmap/IBL, so we only need the lightmap texel)
shader.fragmentShader = shader.fragmentShader.replace(
"#include <lights_fragment_end>",
`#include <lights_fragment_end>
// Apply interior-specific lighting multipliers
reflectedLight.directDiffuse *= interiorDirectionalFactor;
reflectedLight.indirectDiffuse *= interiorAmbientFactor;
`,
"#include <lights_fragment_maps>",
`// Lightmap handled in custom output calculation
#ifdef USE_LIGHTMAP
vec4 lightMapTexel = texture2D( lightMap, vLightMapUv );
#endif`,
);
// Override outgoingLight with Torque-style gamma-space calculation
// Using #include <opaque_fragment> as a stable replacement target (it consumes outgoingLight)
shader.fragmentShader = shader.fragmentShader.replace(
"#include <opaque_fragment>",
`// Torque-style lighting: output = clamp(lighting × texture, 0, 1) in sRGB space
// Get texture in sRGB space (undo Three.js linear decode)
vec3 textureSRGB = interiorLinearToSRGB(diffuseColor.rgb);
// Compute lighting in sRGB space
vec3 lightingSRGB = vec3(0.0);
if (useSceneLighting) {
// Three.js computed: reflectedLight = lighting × texture_linear / PI
// Extract pure lighting: lighting = reflectedLight × PI / texture_linear
vec3 totalLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse;
vec3 safeTexLinear = max(diffuseColor.rgb, vec3(0.001));
vec3 extractedLighting = totalLight * PI / safeTexLinear;
// NOTE: extractedLighting is ALREADY sRGB values because mission sun/ambient colors
// are sRGB values (Torque used them directly in gamma space). Three.js treats them
// as linear but the numerical values are the same. DO NOT convert to sRGB here!
// IMPORTANT: Torque clamps scene lighting to [0,1] BEFORE adding to lightmap
// (sceneLighting.cc line 1785: tmp.clamp())
lightingSRGB = clamp(extractedLighting, 0.0, 1.0);
}
// Add lightmap contribution (for BOTH outside and inside surfaces)
// In Torque, scene lighting is ADDED to lightmaps for outside surfaces at mission load
// (stored in .ml files). Inside surfaces only have base lightmap. Both need lightmap here.
#ifdef USE_LIGHTMAP
// Lightmap is stored as linear in Three.js (decoded from sRGB texture), convert back
lightingSRGB += interiorLinearToSRGB(lightMapTexel.rgb);
#endif
// Torque clamps the sum to [0,1] per channel (sceneLighting.cc lines 1817-1827)
lightingSRGB = clamp(lightingSRGB, 0.0, 1.0);
// Torque formula: output = clamp(lighting × texture, 0, 1) in sRGB/gamma space
vec3 resultSRGB = clamp(lightingSRGB * textureSRGB, 0.0, 1.0);
// Convert back to linear for Three.js output pipeline
vec3 resultLinear = interiorSRGBToLinear(resultSRGB);
// Reassign outgoingLight before opaque_fragment consumes it
outgoingLight = resultLinear + totalEmissiveRadiance;
#include <opaque_fragment>`,
);
// Add debug grid overlay AFTER opaque_fragment sets gl_FragColor
// This ensures our debug visualization isn't affected by the Torque lighting calculations
shader.fragmentShader = shader.fragmentShader.replace(
"#include <tonemapping_fragment>",
`// 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) {
// 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>`,
);
}

View file

@ -14,15 +14,34 @@
* - ambient: Multiplier for ambient light contribution (affects shadow darkness)
*/
export const TERRAIN_LIGHTING = {
directional: 4,
ambient: 1.5,
};
/**
* Terrain lighting is handled via custom shader in terrainMaterial.ts.
*
* The shader implements Torque's gamma-space rendering formula (terrLighting.cc):
* output = clamp(lighting × texture, 0, 1)
*
* Where:
* - lighting = clamp(ambient + NdotL × shadowFactor × sunColor, 0, 1)
* - NdotL from pre-computed terrain lightmap (smooth per-pixel normals)
* - shadowFactor from Three.js real-time shadow maps
*
* No intensity multipliers are needed - the shader uses mission sun/ambient
* colors directly and performs gamma-space math to match Torque's output.
*/
export const INTERIOR_LIGHTING = {
directional: 3,
ambient: 1,
};
/**
* Interior lighting is handled via custom shader in interiorMaterial.ts.
*
* The shader implements Torque's gamma-space rendering formula:
* output = clamp((scene_lighting + lightmap) × texture, 0, 1)
*
* Where:
* - Outside surfaces (SurfaceOutsideVisible): scene lighting + lightmap
* - Inside surfaces: lightmap only
*
* No intensity multipliers are needed - the shader extracts lighting values
* from Three.js and performs gamma-space math to match Torque's output.
*/
export const SHAPE_LIGHTING = {
directional: 1,

View file

@ -1,30 +1,56 @@
/**
* Terrain material shader modifications.
* Handles multi-layer texture blending for Tribes 2 terrain rendering.
* Terrain material shader modifications for MeshLambertMaterial.
*
* Matches Torque's terrain rendering formula (terrLighting.cc + blender.cc):
* output = clamp(lighting × texture, 0, 1)
*
* Where:
* - lighting = clamp(ambient + NdotL × shadowFactor × sunColor, 0, 1)
* - NdotL and terrain self-shadows from pre-computed lightmap (ray-traced)
* - shadowFactor from Three.js real-time shadow maps (for building/object shadows)
* - All operations in sRGB/gamma space
*
* Key insights from Torque source (terrLighting.cc:471-483):
* 1. Lightmap bakes: ambient + max(0, N·L) × sunColor for lit areas
* 2. Shadowed areas get only ambient
* 3. Mission sun/ambient colors ARE sRGB values - Torque used them directly
* 4. Final output = lightmap × texture, all in gamma space
*/
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.
// However, this appears visually excessive. Using a moderate multiplier relative
// to base texture tiling (32x) - detail should be finer but not overwhelming.
const DETAIL_TILING = 64.0;
// Distance at which detail texture fully fades out (in world units)
// Torque: zeroDetailDistance = (squareSize * worldToScreenScale) / 64 - squareSize/2
// For squareSize=8 and typical worldToScreenScale (~800), this gives ~96 units.
// Using 150 for a slightly more gradual fade.
const DETAIL_FADE_DISTANCE = 150.0;
// sRGB <-> Linear conversion functions (GLSL)
const colorSpaceFunctions = /* glsl */ `
vec3 terrainLinearToSRGB(vec3 linear) {
vec3 higher = pow(linear, vec3(1.0/2.4)) * 1.055 - 0.055;
vec3 lower = linear * 12.92;
return mix(lower, higher, step(vec3(0.0031308), linear));
}
vec3 terrainSRGBToLinear(vec3 srgb) {
vec3 higher = pow((srgb + 0.055) / 1.055, vec3(2.4));
vec3 lower = srgb / 12.92;
return mix(lower, higher, step(vec3(0.04045), srgb));
}
// Debug grid overlay using screen-space derivatives for sharp, anti-aliased lines
// Returns 1.0 on grid lines, 0.0 elsewhere
float terrainDebugGrid(vec2 uv, float gridSize, float lineWidth) {
vec2 scaledUV = uv * gridSize;
vec2 grid = abs(fract(scaledUV - 0.5) - 0.5) / fwidth(scaledUV);
float line = min(grid.x, grid.y);
return 1.0 - min(line / lineWidth, 1.0);
}
`;
export function updateTerrainTextureShader({
shader,
baseTextures,
@ -46,12 +72,6 @@ export function updateTerrainTextureShader({
}) {
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 };
});
@ -101,11 +121,9 @@ vTerrainWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;`,
);
}
// Declare our uniforms at the top of the fragment shader
// Declare our uniforms and color space functions at the top of the fragment shader
shader.fragmentShader =
`
uniform float terrainDirectionalFactor;
uniform float terrainAmbientFactor;
uniform sampler2D albedo0;
uniform sampler2D albedo1;
uniform sampler2D albedo2;
@ -135,14 +153,10 @@ varying vec3 vTerrainWorldPos;`
: ""
}
// Wireframe edge detection for debug mode
float getWireframe(vec2 uv, float gridSize, float lineWidth) {
vec2 gridUv = uv * gridSize;
vec2 grid = abs(fract(gridUv - 0.5) - 0.5);
vec2 deriv = fwidth(gridUv);
vec2 edge = smoothstep(vec2(0.0), deriv * lineWidth, grid);
return 1.0 - min(edge.x, edge.y);
}
${colorSpaceFunctions}
// Global variable to store shadow factor from RE_Direct for use in output calculation
float terrainShadowFactor = 1.0;
` + shader.fragmentShader;
if (visibilityMask) {
@ -164,7 +178,7 @@ float getWireframe(vec2 uv, float gridSize, float lineWidth) {
shader.fragmentShader = shader.fragmentShader.replace(
"#include <map_fragment>",
`
// Sample base albedo layers (sRGB textures auto-decoded to linear)
// Sample base albedo layers (sRGB textures auto-decoded to linear by Three.js)
vec2 baseUv = vMapUv;
vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb;
${
@ -231,83 +245,114 @@ float getWireframe(vec2 uv, float gridSize, float lineWidth) {
: ""
}
// Apply texture color or debug mode solid gray
if (debugMode > 0.5) {
// Solid gray to visualize lighting only (without texture influence)
diffuseColor.rgb = vec3(0.5);
} else {
// Scale texture to prevent clipping, preserving shadow visibility
diffuseColor.rgb = textureColor * ${TEXTURE_BRIGHTNESS_SCALE};
}
// Store blended texture in diffuseColor (still in linear space here)
// We'll convert to sRGB in the output calculation
diffuseColor.rgb = textureColor;
`,
);
// When lightmap is available, replace vertex normal-based lighting with smooth lightmap
// This eliminates banding by using pre-computed per-pixel NdotL values
// When lightmap is available, override RE_Direct to extract shadow factor
// We don't compute lighting here - just capture the shadow for use in output
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
// Override RE_Direct to extract shadow factor for Torque-style gamma-space lighting
#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 );
}
void RE_Direct_TerrainShadow( 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 ) {
// directLight.color = sunColor * shadowFactor (shadow already applied by Three.js)
// Extract shadow factor by comparing to original sun color
#if ( NUM_DIR_LIGHTS > 0 )
vec3 originalSunColor = directionalLights[0].color;
float sunMax = max(max(originalSunColor.r, originalSunColor.g), originalSunColor.b);
float shadowedMax = max(max(directLight.color.r, directLight.color.g), directLight.color.b);
terrainShadowFactor = clamp(shadowedMax / max(sunMax, 0.001), 0.0, 1.0);
#endif
// Don't add to reflectedLight - we'll compute lighting in gamma space at output
}
#define RE_Direct RE_Direct_TerrainLightmap
#define RE_Direct RE_Direct_TerrainShadow
`,
);
// Override lights_fragment_begin to fix hemisphere light irradiance calculation
// The default uses geometryNormal which causes banding
// Override lights_fragment_begin to skip indirect diffuse calculation
// We'll handle ambient in gamma space
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
// Clear indirect diffuse - we'll compute ambient in gamma space
#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
}
irradiance = vec3(0.0);
#endif
`,
);
// Clear the indirect diffuse after lights_fragment_end
shader.fragmentShader = shader.fragmentShader.replace(
"#include <lights_fragment_end>",
`#include <lights_fragment_end>
// Clear Three.js lighting - we compute everything in gamma space
reflectedLight.directDiffuse = vec3(0.0);
reflectedLight.indirectDiffuse = vec3(0.0);
`,
);
}
// Scale ambient/indirect lighting to darken shadows on terrain
// Replace opaque_fragment with Torque-style gamma-space calculation
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;
`,
"#include <opaque_fragment>",
`// Torque-style terrain lighting: output = clamp(lighting × texture, 0, 1) in sRGB space
{
// Get texture in sRGB space (undo Three.js linear decode)
vec3 textureSRGB = terrainLinearToSRGB(diffuseColor.rgb);
${
lightmap
? `
// Sample terrain lightmap for smooth NdotL
vec2 lightmapUv = vMapUv + vec2(0.5 / ${LIGHTMAP_SIZE}.0);
float lightmapNdotL = texture2D(terrainLightmap, lightmapUv).r;
// Get sun and ambient colors from Three.js lights (these ARE sRGB values from mission file)
// Three.js interprets them as linear, but the numerical values are preserved
#if ( NUM_DIR_LIGHTS > 0 )
vec3 sunColorSRGB = directionalLights[0].color;
#else
vec3 sunColorSRGB = vec3(0.7);
#endif
vec3 ambientColorSRGB = ambientLightColor;
// Torque formula (terrLighting.cc:471-483):
// lighting = ambient + NdotL * shadowFactor * sunColor
// Clamp lighting to [0,1] before multiplying by texture
vec3 lightingSRGB = clamp(ambientColorSRGB + lightmapNdotL * terrainShadowFactor * sunColorSRGB, 0.0, 1.0);
`
: `
// No lightmap - use simple ambient lighting
vec3 lightingSRGB = ambientLightColor;
`
}
// Torque formula: output = clamp(lighting × texture, 0, 1) in sRGB/gamma space
vec3 resultSRGB = clamp(lightingSRGB * textureSRGB, 0.0, 1.0);
// Convert back to linear for Three.js output pipeline
outgoingLight = terrainSRGBToLinear(resultSRGB) + totalEmissiveRadiance;
}
#include <opaque_fragment>`,
);
// Add debug grid overlay AFTER opaque_fragment sets gl_FragColor
shader.fragmentShader = shader.fragmentShader.replace(
"#include <tonemapping_fragment>",
`// Debug mode: overlay green grid matching terrain grid squares (256x256)
if (debugMode > 0.5) {
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);
}
#include <tonemapping_fragment>`,
);
}