mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-10 16:00:54 +00:00
improve lighting, shadows, fix terrain triangle geometry
This commit is contained in:
parent
4e5a0327a0
commit
bcf4f4a1a5
1232 changed files with 629 additions and 207 deletions
|
|
@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue