t2-mapper/src/components/TerrainBlock.tsx

741 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}
/>
);
})}
</>
);
});