mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-02 20:10:35 +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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue