mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-02-23 16:43:58 +00:00
741 lines
25 KiB
TypeScript
741 lines
25 KiB
TypeScript
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,
|
||
RedFormat,
|
||
RepeatWrapping,
|
||
UnsignedByteType,
|
||
Vector3,
|
||
} from "three";
|
||
import type { TorqueObject } from "../torqueScript";
|
||
import { getFloat, getInt, getProperty } from "../mission";
|
||
import { loadTerrain } from "../loaders";
|
||
import { uint16ToFloat32 } from "../arrayUtils";
|
||
import { setupMask } from "../textureUtils";
|
||
import { TerrainTile } from "./TerrainTile";
|
||
import { useSceneObject } from "./useSceneObject";
|
||
|
||
const DEFAULT_SQUARE_SIZE = 8;
|
||
const DEFAULT_VISIBLE_DISTANCE = 600;
|
||
const TERRAIN_SIZE = 256;
|
||
const LIGHTMAP_SIZE = 512; // Match Tribes 2's 512x512 lightmap
|
||
const HEIGHT_SCALE = 2048; // Matches displacementScale for terrain
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* Height sampling uses NEAREST filtering to match the GPU DataTexture default:
|
||
* texel = floor(uv * textureWidth), clamped to valid range.
|
||
*
|
||
* Normals use bilinear interpolation for smooth gradients, preventing banding
|
||
* that would occur with face normals from computeVertexNormals().
|
||
*/
|
||
function displaceTerrainAndComputeNormals(
|
||
geometry: BufferGeometry,
|
||
heightMap: Uint16Array,
|
||
squareSize: number,
|
||
): void {
|
||
const posAttr = geometry.attributes.position;
|
||
const uvAttr = geometry.attributes.uv;
|
||
const normalAttr = geometry.attributes.normal;
|
||
const positions = posAttr.array as Float32Array;
|
||
const uvs = uvAttr.array as Float32Array;
|
||
const normals = normalAttr.array as Float32Array;
|
||
const vertexCount = posAttr.count;
|
||
|
||
// Helper to get height at heightmap coordinates with clamping (integer coords)
|
||
const getHeightInt = (col: number, row: number): number => {
|
||
col = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
|
||
row = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
|
||
return (heightMap[row * TERRAIN_SIZE + col] / 65535) * HEIGHT_SCALE;
|
||
};
|
||
|
||
// Helper to get bilinearly interpolated height (matches GPU texture sampling)
|
||
const getHeight = (col: number, row: number): number => {
|
||
col = Math.max(0, Math.min(TERRAIN_SIZE - 1, col));
|
||
row = Math.max(0, Math.min(TERRAIN_SIZE - 1, row));
|
||
|
||
const col0 = Math.floor(col);
|
||
const row0 = Math.floor(row);
|
||
const col1 = Math.min(col0 + 1, TERRAIN_SIZE - 1);
|
||
const row1 = Math.min(row0 + 1, TERRAIN_SIZE - 1);
|
||
|
||
const fx = col - col0;
|
||
const fy = row - row0;
|
||
|
||
const h00 = (heightMap[row0 * TERRAIN_SIZE + col0] / 65535) * HEIGHT_SCALE;
|
||
const h10 = (heightMap[row0 * TERRAIN_SIZE + col1] / 65535) * HEIGHT_SCALE;
|
||
const h01 = (heightMap[row1 * TERRAIN_SIZE + col0] / 65535) * HEIGHT_SCALE;
|
||
const h11 = (heightMap[row1 * TERRAIN_SIZE + col1] / 65535) * HEIGHT_SCALE;
|
||
|
||
// Bilinear interpolation
|
||
const h0 = h00 * (1 - fx) + h10 * fx;
|
||
const h1 = h01 * (1 - fx) + h11 * fx;
|
||
return h0 * (1 - fy) + h1 * fy;
|
||
};
|
||
|
||
// Process each vertex
|
||
for (let i = 0; i < vertexCount; i++) {
|
||
const u = uvs[i * 2];
|
||
const v = uvs[i * 2 + 1];
|
||
|
||
// Map UV to heightmap coordinates - must match Torque's terrain sampling.
|
||
// Torque formula: floor(worldPos / squareSize) & BlockMask
|
||
// UV 0→1 maps to world 0→2048, squareSize=8, so: floor(UV * 256) & 255
|
||
// This wraps at edges for seamless terrain tiling.
|
||
const col = Math.floor(u * TERRAIN_SIZE) & (TERRAIN_SIZE - 1);
|
||
const row = Math.floor(v * TERRAIN_SIZE) & (TERRAIN_SIZE - 1);
|
||
|
||
// Use direct integer sampling to match GPU nearest-neighbor filtering
|
||
const height = getHeightInt(col, row);
|
||
positions[i * 3 + 1] = height;
|
||
|
||
// Compute normal using central differences on heightmap with smooth interpolation.
|
||
// Use fractional coordinates for gradient sampling to get smooth normals.
|
||
const colF = u * (TERRAIN_SIZE - 1);
|
||
const rowF = v * (TERRAIN_SIZE - 1);
|
||
const hL = getHeight(colF - 1, rowF); // left
|
||
const hR = getHeight(colF + 1, rowF); // right
|
||
const hD = getHeight(colF, rowF + 1); // down (increasing row)
|
||
const hU = getHeight(colF, rowF - 1); // up (decreasing row)
|
||
|
||
// Gradients in heightmap space (col increases = +U, row increases = +V)
|
||
const dCol = (hR - hL) / 2; // height change per column
|
||
const dRow = (hD - hU) / 2; // height change per row
|
||
|
||
// Now map heightmap gradients to world-space normal
|
||
// After rotateX(-PI/2) and rotateY(-PI/2):
|
||
// - U direction (col) maps to world +Z
|
||
// - V direction (row) maps to world +X
|
||
//
|
||
// For heightfield normal: n = normalize(-dh/dx, 1, -dh/dz) in world space
|
||
// But we need the normal to face outward (toward the viewer), so use positive signs
|
||
let nx = dRow;
|
||
let ny = squareSize;
|
||
let nz = dCol;
|
||
|
||
// Normalize
|
||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||
if (len > 0) {
|
||
nx /= len;
|
||
ny /= len;
|
||
nz /= len;
|
||
} else {
|
||
nx = 0;
|
||
ny = 1;
|
||
nz = 0;
|
||
}
|
||
|
||
normals[i * 3] = nx;
|
||
normals[i * 3 + 1] = ny;
|
||
normals[i * 3 + 2] = nz;
|
||
}
|
||
|
||
posAttr.needsUpdate = true;
|
||
normalAttr.needsUpdate = true;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* Solution: Compute normals from BILINEARLY INTERPOLATED heights at each
|
||
* lightmap pixel. This produces smooth gradients because the interpolated
|
||
* height surface is C0 continuous (no discontinuities).
|
||
*
|
||
* 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 (NdotL * shadow)
|
||
*/
|
||
function generateTerrainLightmap(
|
||
heightMap: Uint16Array,
|
||
sunDirection: Vector3,
|
||
squareSize: number,
|
||
): DataTexture {
|
||
// Helper to get bilinearly interpolated height at any fractional position
|
||
// Supports negative and out-of-range coordinates via clamping for shadow rays
|
||
const getInterpolatedHeight = (col: number, row: number): number => {
|
||
// 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(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 = clampedCol - col0;
|
||
const fy = clampedRow - row0;
|
||
|
||
const h00 = heightMap[row0 * TERRAIN_SIZE + col0] / 65535;
|
||
const h10 = heightMap[row0 * TERRAIN_SIZE + col1] / 65535;
|
||
const h01 = heightMap[row1 * TERRAIN_SIZE + col0] / 65535;
|
||
const h11 = heightMap[row1 * TERRAIN_SIZE + col1] / 65535;
|
||
|
||
// Bilinear interpolation
|
||
const h0 = h00 * (1 - fx) + h10 * fx;
|
||
const h1 = h01 * (1 - fx) + h11 * fx;
|
||
return (h0 * (1 - fy) + h1 * fy) * HEIGHT_SCALE;
|
||
};
|
||
|
||
// Light direction (negate sun direction since it points FROM sun)
|
||
const lightDir = new Vector3(
|
||
-sunDirection.x,
|
||
-sunDirection.y,
|
||
-sunDirection.z,
|
||
).normalize();
|
||
|
||
const lightmapData = new Uint8Array(LIGHTMAP_SIZE * LIGHTMAP_SIZE);
|
||
|
||
// Epsilon for gradient sampling (in heightmap units)
|
||
// Use 0.5 to sample across a reasonable distance for smooth gradients
|
||
const eps = 0.5;
|
||
|
||
// Generate lightmap by computing normal 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():
|
||
// Torque starts at halfStep (0.25) within each square, not at corner.
|
||
// With 2 lightmap pixels per terrain square: pos = lCol/2 + 0.25
|
||
const col = lCol / 2 + 0.25;
|
||
const row = lRow / 2 + 0.25;
|
||
|
||
// 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);
|
||
const hU = getInterpolatedHeight(col, row - eps);
|
||
const hD = getInterpolatedHeight(col, row + eps);
|
||
|
||
// Gradient in heightmap units
|
||
const dCol = (hR - hL) / (2 * eps);
|
||
const dRow = (hD - hU) / (2 * eps);
|
||
|
||
// Convert to world-space normal - must match displaceTerrainAndComputeNormals
|
||
// After geometry rotations: U (col) → +Z, V (row) → +X
|
||
const nx = -dRow;
|
||
const ny = squareSize;
|
||
const nz = -dCol;
|
||
|
||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||
|
||
// Compute NdotL
|
||
const NdotL = Math.max(
|
||
0,
|
||
(nx / len) * lightDir.x +
|
||
(ny / len) * lightDir.y +
|
||
(nz / len) * lightDir.z,
|
||
);
|
||
|
||
// 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,
|
||
);
|
||
}
|
||
}
|
||
|
||
const texture = new DataTexture(
|
||
lightmapData,
|
||
LIGHTMAP_SIZE,
|
||
LIGHTMAP_SIZE,
|
||
RedFormat,
|
||
UnsignedByteType,
|
||
);
|
||
texture.colorSpace = NoColorSpace;
|
||
texture.generateMipmaps = true;
|
||
texture.wrapS = ClampToEdgeWrapping;
|
||
texture.wrapT = ClampToEdgeWrapping;
|
||
texture.magFilter = LinearFilter;
|
||
texture.minFilter = LinearFilter;
|
||
texture.needsUpdate = true;
|
||
|
||
return texture;
|
||
}
|
||
|
||
/**
|
||
* Load a .ter file, used for terrain heightmap and texture info.
|
||
*/
|
||
function useTerrain(terrainFile: string) {
|
||
return useQuery({
|
||
queryKey: ["terrain", terrainFile],
|
||
queryFn: () => loadTerrain(terrainFile),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Get visibleDistance from the Sky object, used to determine how far terrain
|
||
* tiles should render. This matches Tribes 2's terrain tiling behavior.
|
||
*/
|
||
function useVisibleDistance(): number {
|
||
const sky = useSceneObject("Sky");
|
||
if (!sky) return DEFAULT_VISIBLE_DISTANCE;
|
||
const highVisibleDistance = getFloat(sky, "high_visibleDistance");
|
||
if (highVisibleDistance != null && highVisibleDistance > 0) {
|
||
return highVisibleDistance;
|
||
}
|
||
return getFloat(sky, "visibleDistance") ?? DEFAULT_VISIBLE_DISTANCE;
|
||
}
|
||
|
||
interface TileAssignment {
|
||
tileX: number;
|
||
tileZ: number;
|
||
}
|
||
|
||
/**
|
||
* Create a visibility mask texture from emptySquares data.
|
||
*/
|
||
function createVisibilityMask(emptySquares: number[]): DataTexture {
|
||
const maskData = new Uint8Array(TERRAIN_SIZE * TERRAIN_SIZE);
|
||
maskData.fill(255); // Start with everything visible
|
||
|
||
for (const squareId of emptySquares) {
|
||
const x = squareId & 0xff;
|
||
const y = (squareId >> 8) & 0xff;
|
||
const count = squareId >> 16;
|
||
const rowOffset = y * TERRAIN_SIZE;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const index = rowOffset + x + i;
|
||
if (index < maskData.length) {
|
||
maskData[index] = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
const texture = new DataTexture(
|
||
maskData,
|
||
TERRAIN_SIZE,
|
||
TERRAIN_SIZE,
|
||
RedFormat,
|
||
UnsignedByteType,
|
||
);
|
||
texture.colorSpace = NoColorSpace;
|
||
texture.wrapS = texture.wrapT = ClampToEdgeWrapping;
|
||
texture.magFilter = NearestFilter;
|
||
texture.minFilter = NearestFilter;
|
||
texture.needsUpdate = true;
|
||
|
||
return texture;
|
||
}
|
||
|
||
export const TerrainBlock = memo(function TerrainBlock({
|
||
object,
|
||
}: {
|
||
object: TorqueObject;
|
||
}) {
|
||
const terrainFile = getProperty(object, "terrainFile");
|
||
const squareSize = getInt(object, "squareSize") ?? DEFAULT_SQUARE_SIZE;
|
||
const detailTexture = getProperty(object, "detailTexture");
|
||
const blockSize = squareSize * 256;
|
||
const visibleDistance = useVisibleDistance();
|
||
const camera = useThree((state) => state.camera);
|
||
|
||
// Torque ignores the mission's terrain position and always uses a fixed formula:
|
||
// setPosition(Point3F(-squareSize * (BlockSize >> 1), -squareSize * (BlockSize >> 1), 0));
|
||
// where BlockSize = 256. See tribes2-engine/terrain/terrData.cc:679
|
||
const basePosition = useMemo(() => {
|
||
const offset = -squareSize * (TERRAIN_SIZE / 2);
|
||
return { x: offset, z: offset };
|
||
}, [squareSize]);
|
||
|
||
const emptySquares = useMemo(() => {
|
||
const value = getProperty(object, "emptySquares");
|
||
return value ? value.split(" ").map((s: string) => parseInt(s, 10)) : [];
|
||
}, [object]);
|
||
|
||
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 = createTerrainGeometry(size, TERRAIN_SIZE);
|
||
|
||
// Displace vertices on CPU and compute smooth normals
|
||
displaceTerrainAndComputeNormals(geometry, terrain.heightMap, squareSize);
|
||
|
||
return geometry;
|
||
}, [squareSize, terrain]);
|
||
|
||
// Get sun direction for lightmap generation
|
||
const sun = useSceneObject("Sun");
|
||
const sunDirection = useMemo(() => {
|
||
if (!sun) return new Vector3(0.57735, -0.57735, 0.57735); // Default diagonal
|
||
const directionStr =
|
||
getProperty(sun, "direction") ?? "0.57735 0.57735 -0.57735";
|
||
const [tx, ty, tz] = directionStr
|
||
.split(" ")
|
||
.map((s: string) => parseFloat(s));
|
||
// Convert Torque (X, Y, Z) to Three.js: swap Y/Z
|
||
const x = tx;
|
||
const y = tz;
|
||
const z = ty;
|
||
const len = Math.sqrt(x * x + y * y + z * z);
|
||
return new Vector3(x / len, y / len, z / len);
|
||
}, [sun]);
|
||
|
||
// Generate terrain lightmap for smooth per-pixel lighting
|
||
const terrainLightmap = useMemo(() => {
|
||
if (!terrain) return null;
|
||
return generateTerrainLightmap(terrain.heightMap, sunDirection, squareSize);
|
||
}, [terrain, sunDirection, squareSize]);
|
||
|
||
// Shared displacement map from heightmap - created once for all tiles
|
||
const sharedDisplacementMap = useMemo(() => {
|
||
if (!terrain) return null;
|
||
const f32HeightMap = uint16ToFloat32(terrain.heightMap);
|
||
const texture = new DataTexture(
|
||
f32HeightMap,
|
||
TERRAIN_SIZE,
|
||
TERRAIN_SIZE,
|
||
RedFormat,
|
||
FloatType,
|
||
);
|
||
texture.colorSpace = NoColorSpace;
|
||
texture.generateMipmaps = false;
|
||
texture.wrapS = RepeatWrapping;
|
||
texture.wrapT = RepeatWrapping;
|
||
texture.needsUpdate = true;
|
||
return texture;
|
||
}, [terrain]);
|
||
|
||
// Visibility mask for primary tile (0,0) - may have empty squares
|
||
const primaryVisibilityMask = useMemo(
|
||
() => createVisibilityMask(emptySquares),
|
||
[emptySquares],
|
||
);
|
||
|
||
// Visibility mask for pooled tiles - all visible (no empty squares)
|
||
// This is a stable reference shared by all pooled tiles
|
||
const pooledVisibilityMask = useMemo(() => createVisibilityMask([]), []);
|
||
|
||
// Shared alpha textures from terrain alphaMaps - created once for all tiles
|
||
const sharedAlphaTextures = useMemo(() => {
|
||
if (!terrain) return null;
|
||
return terrain.alphaMaps.map((data) => setupMask(data));
|
||
}, [terrain]);
|
||
|
||
// Calculate the maximum number of tiles that can be visible at once.
|
||
const poolSize = useMemo(() => {
|
||
const extent = Math.ceil(visibleDistance / blockSize);
|
||
const gridSize = 2 * extent + 1;
|
||
return gridSize * gridSize - 1; // -1 because primary tile is separate
|
||
}, [visibleDistance, blockSize]);
|
||
|
||
// Create stable pool indices for React keys
|
||
const poolIndices = useMemo(
|
||
() => Array.from({ length: poolSize }, (_, i) => i),
|
||
[poolSize],
|
||
);
|
||
|
||
// Track which tile coordinate each pool slot is assigned to
|
||
const [tileAssignments, setTileAssignments] = useState<
|
||
(TileAssignment | null)[]
|
||
>(() => Array(poolSize).fill(null));
|
||
|
||
// Track previous tile bounds to avoid unnecessary state updates
|
||
const prevBoundsRef = useRef({ xStart: 0, xEnd: 0, zStart: 0, zEnd: 0 });
|
||
|
||
useFrame(() => {
|
||
const relativeCamX = camera.position.x - basePosition.x;
|
||
const relativeCamZ = camera.position.z - basePosition.z;
|
||
|
||
const xStart = Math.floor((relativeCamX - visibleDistance) / blockSize);
|
||
const xEnd = Math.ceil((relativeCamX + visibleDistance) / blockSize);
|
||
const zStart = Math.floor((relativeCamZ - visibleDistance) / blockSize);
|
||
const zEnd = Math.ceil((relativeCamZ + visibleDistance) / blockSize);
|
||
|
||
// Early exit if bounds haven't changed
|
||
const prev = prevBoundsRef.current;
|
||
if (
|
||
xStart === prev.xStart &&
|
||
xEnd === prev.xEnd &&
|
||
zStart === prev.zStart &&
|
||
zEnd === prev.zEnd
|
||
) {
|
||
return;
|
||
}
|
||
prev.xStart = xStart;
|
||
prev.xEnd = xEnd;
|
||
prev.zStart = zStart;
|
||
prev.zEnd = zEnd;
|
||
|
||
// Build new assignments array
|
||
const newAssignments: (TileAssignment | null)[] = [];
|
||
for (let x = xStart; x < xEnd; x++) {
|
||
for (let z = zStart; z < zEnd; z++) {
|
||
if (x === 0 && z === 0) continue;
|
||
newAssignments.push({ tileX: x, tileZ: z });
|
||
}
|
||
}
|
||
while (newAssignments.length < poolSize) {
|
||
newAssignments.push(null);
|
||
}
|
||
|
||
setTileAssignments(newAssignments);
|
||
});
|
||
|
||
if (
|
||
!terrain ||
|
||
!sharedGeometry ||
|
||
!sharedDisplacementMap ||
|
||
!sharedAlphaTextures
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* Primary tile at (0,0) with emptySquares applied */}
|
||
<TerrainTile
|
||
tileX={0}
|
||
tileZ={0}
|
||
blockSize={blockSize}
|
||
basePosition={basePosition}
|
||
textureNames={terrain.textureNames}
|
||
geometry={sharedGeometry}
|
||
displacementMap={sharedDisplacementMap}
|
||
visibilityMask={primaryVisibilityMask}
|
||
alphaTextures={sharedAlphaTextures}
|
||
detailTextureName={detailTexture}
|
||
lightmap={terrainLightmap}
|
||
/>
|
||
{/* Pooled tiles - stable keys, always mounted */}
|
||
{poolIndices.map((poolIndex) => {
|
||
const assignment = tileAssignments[poolIndex];
|
||
return (
|
||
<TerrainTile
|
||
key={poolIndex}
|
||
tileX={assignment?.tileX ?? 0}
|
||
tileZ={assignment?.tileZ ?? 0}
|
||
blockSize={blockSize}
|
||
basePosition={basePosition}
|
||
textureNames={terrain.textureNames}
|
||
geometry={sharedGeometry}
|
||
displacementMap={sharedDisplacementMap}
|
||
visibilityMask={pooledVisibilityMask}
|
||
alphaTextures={sharedAlphaTextures}
|
||
detailTextureName={detailTexture}
|
||
lightmap={terrainLightmap}
|
||
visible={assignment !== null}
|
||
/>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
});
|