use instancing to improve performance

This commit is contained in:
Brian Beck 2026-03-14 23:04:25 -07:00
parent 78e791f763
commit cd2819d28a
33 changed files with 482 additions and 319 deletions

View file

@ -17,7 +17,7 @@ import {
BufferGeometry,
Group,
} from "three";
import type { AnimationAction } from "three";
import type { AnimationAction, Material } from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { setupTexture } from "../textureUtils";
import { useAnisotropy } from "./useAnisotropy";
@ -84,19 +84,27 @@ type MaterialResult =
| SingleMaterial
| [MeshLambertMaterial, MeshLambertMaterial];
// Stable onBeforeCompile callbacks — using shared function references lets
// Three.js's program cache match by identity rather than toString().
const lambertBeforeCompile: Material["onBeforeCompile"] = (shader) => {
injectCustomFog(shader, globalFogUniforms);
injectShapeLighting(shader);
};
const basicBeforeCompile: Material["onBeforeCompile"] = (shader) => {
injectCustomFog(shader, globalFogUniforms);
};
/**
* Helper to apply volumetric fog and lighting multipliers to a material
* Helper to apply volumetric fog and lighting multipliers to a material.
*/
export function applyShapeShaderModifications(
mat: MeshBasicMaterial | MeshLambertMaterial,
): void {
mat.onBeforeCompile = (shader) => {
injectCustomFog(shader, globalFogUniforms);
// Only inject lighting for Lambert materials (Basic materials are unlit)
if (mat instanceof MeshLambertMaterial) {
injectShapeLighting(shader);
}
};
mat.onBeforeCompile =
mat instanceof MeshLambertMaterial
? lambertBeforeCompile
: basicBeforeCompile;
}
export function createMaterialFromFlags(

View file

@ -57,22 +57,23 @@ export function ScoreScreen({ onClose }: { onClose: () => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
const dataSource = useDataSource();
const isLive = dataSource === "live";
const connectedClientId = useEngineSelector(
(state) => state.playback.streamSnapshot?.connectedClientId,
);
const teamScores = useEngineSelector(
(state) => state.playback.streamSnapshot?.teamScores,
);
const playerRoster = useEngineSelector(
(state) => state.playback.streamSnapshot?.playerRoster,
);
const playerSensorGroup = useEngineSelector(
(state) => state.playback.streamSnapshot?.playerSensorGroup,
);
const matchClockMs = useEngineSelector(
(state) => state.playback.streamSnapshot?.matchClockMs,
);
const { connectedClientId, teamScores, playerRoster, matchClockMs } =
useEngineSelector(
(state) => {
const snap = state.playback.streamSnapshot;
return {
connectedClientId: snap?.connectedClientId,
teamScores: snap?.teamScores,
playerRoster: snap?.playerRoster,
matchClockMs: snap?.matchClockMs,
};
},
(a, b) =>
a.connectedClientId === b.connectedClientId &&
a.teamScores === b.teamScores &&
a.playerRoster === b.playerRoster &&
a.matchClockMs === b.matchClockMs,
);
// Focus and exit pointer lock on open
useEffect(() => {

View file

@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { memo, useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useQuery } from "@tanstack/react-query";
import {
@ -7,7 +7,9 @@ import {
DataTexture,
Float32BufferAttribute,
FloatType,
InstancedMesh as ThreeInstancedMesh,
LinearFilter,
Matrix4,
NearestFilter,
NoColorSpace,
ClampToEdgeWrapping,
@ -25,7 +27,7 @@ import { useSceneSky, useSceneSun } from "../state/gameEntityStore";
import { loadTerrain } from "../loaders";
import { uint16ToFloat32 } from "../arrayUtils";
import { setupMask } from "../textureUtils";
import { TerrainTile } from "./TerrainTile";
import { TerrainTile, TerrainMaterial } from "./TerrainTile";
import {
createTerrainHeightSampler,
setTerrainHeightSampler,
@ -447,10 +449,6 @@ function useVisibleDistance(): number {
? sky.visibleDistance
: DEFAULT_VISIBLE_DISTANCE;
}
interface TileAssignment {
tileX: number;
tileZ: number;
}
/**
* Create a visibility mask texture from emptySquares data.
*/
@ -574,27 +572,33 @@ export const TerrainBlock = memo(function TerrainBlock({
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 });
// InstancedMesh ref and reusable matrix for pooled terrain tiles.
const pooledMeshRef = useRef<ThreeInstancedMesh>(null);
const _tileMatrix = useMemo(() => new Matrix4(), []);
// Track previous tile bounds to avoid unnecessary instance matrix updates.
const prevBoundsRef = useRef({
xStart: Infinity,
xEnd: -Infinity,
zStart: Infinity,
zEnd: -Infinity,
});
// Track which mesh instance we last updated — when r3f recreates the
// InstancedMesh (e.g. poolSize changes), we must force a full update
// even if the camera bounds haven't changed.
const lastMeshRef = useRef<ThreeInstancedMesh | null>(null);
useFrame(() => {
const mesh = pooledMeshRef.current;
if (!mesh) return;
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
// Early exit if bounds haven't changed AND we're still on the same mesh.
const prev = prevBoundsRef.current;
if (
mesh === lastMeshRef.current &&
xStart === prev.xStart &&
xEnd === prev.xEnd &&
zStart === prev.zStart &&
@ -602,22 +606,27 @@ export const TerrainBlock = memo(function TerrainBlock({
) {
return;
}
lastMeshRef.current = mesh;
prev.xStart = xStart;
prev.xEnd = xEnd;
prev.zStart = zStart;
prev.zEnd = zEnd;
// Build new assignments array
const newAssignments: (TileAssignment | null)[] = [];
const geometryOffset = blockSize / 2;
let count = 0;
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 });
_tileMatrix.makeTranslation(
basePosition.x + x * blockSize + geometryOffset,
0,
basePosition.z + z * blockSize + geometryOffset,
);
mesh.setMatrixAt(count, _tileMatrix);
count++;
}
}
while (newAssignments.length < poolSize) {
newAssignments.push(null);
}
setTileAssignments(newAssignments);
mesh.count = count;
mesh.instanceMatrix.needsUpdate = true;
});
if (
!terrain ||
@ -650,27 +659,25 @@ export const TerrainBlock = memo(function TerrainBlock({
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}
/>
);
})}
{/* Pooled tiles single InstancedMesh, matrices updated in useFrame.
All pooled tiles share the same geometry, material, and visibility mask.
Only position differs (set via instance matrices). */}
<instancedMesh
ref={pooledMeshRef}
args={[sharedGeometry, undefined, poolSize]}
castShadow
receiveShadow
frustumCulled={false}
>
<TerrainMaterial
displacementMap={sharedDisplacementMap}
visibilityMask={pooledVisibilityMask}
textureNames={terrain.textureNames}
alphaTextures={sharedAlphaTextures}
detailTextureName={detailTexture}
lightmap={terrainLightmap}
/>
</instancedMesh>
</>
);
});

View file

@ -140,7 +140,7 @@ const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
);
});
const TerrainMaterial = memo(function TerrainMaterial({
export const TerrainMaterial = memo(function TerrainMaterial({
displacementMap,
visibilityMask,
textureNames,

View file

@ -1,7 +1,14 @@
import { memo, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { Box, useTexture } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { DoubleSide, NoColorSpace, PlaneGeometry, RepeatWrapping } from "three";
import {
DoubleSide,
InstancedMesh as ThreeInstancedMesh,
Matrix4,
NoColorSpace,
PlaneGeometry,
RepeatWrapping,
} from "three";
import { textureToUrl } from "../loaders";
import {
torqueToThree,
@ -191,7 +198,10 @@ export const WaterBlock = memo(function WaterBlock({
// Only update state if reps actually changed (avoid unnecessary re-renders)
setReps((prevReps) => {
if (JSON.stringify(prevReps) === JSON.stringify(newReps)) {
if (
prevReps.length === newReps.length &&
prevReps.every((r, i) => r[0] === newReps[i][0] && r[1] === newReps[i][1])
) {
return prevReps;
}
return newReps;
@ -335,8 +345,14 @@ const WaterReps = memo(function WaterReps({
});
}, [opacity, waveMagnitude, envMapIntensity, baseTexture, envTexture]);
// Single animation loop for the shared material
// Single animation loop for the shared material + instance matrix updates.
const elapsedRef = useRef(0);
const meshRef = useRef<ThreeInstancedMesh>(null);
const matrixRef = useRef(new Matrix4());
// Track which mesh + reps we last populated — forces a full update when
// r3f recreates the InstancedMesh or when reps change (camera moves).
const lastMeshRef = useRef<ThreeInstancedMesh | null>(null);
const lastRepsRef = useRef<Array<[number, number]> | null>(null);
useFrame((_, delta) => {
if (!animationEnabled) {
@ -346,6 +362,23 @@ const WaterReps = memo(function WaterReps({
elapsedRef.current += delta;
material.uniforms.uTime.value = elapsedRef.current;
}
// Update instance matrices when reps change or mesh is recreated.
const mesh = meshRef.current;
if (!mesh) return;
if (mesh === lastMeshRef.current && reps === lastRepsRef.current) return;
lastMeshRef.current = mesh;
lastRepsRef.current = reps;
const mat = matrixRef.current;
for (let i = 0; i < reps.length; i++) {
const [repX, repZ] = reps[i];
const worldX = basePosition[0] + repX * REP_SIZE - TERRAIN_OFFSET;
const worldZ = basePosition[2] + repZ * REP_SIZE - TERRAIN_OFFSET;
mat.makeTranslation(worldX, basePosition[1], worldZ);
mesh.setMatrixAt(i, mat);
}
mesh.count = reps.length;
mesh.instanceMatrix.needsUpdate = true;
});
useEffect(() => {
@ -355,21 +388,10 @@ const WaterReps = memo(function WaterReps({
}, [material]);
return (
<>
{reps.map(([repX, repZ]) => {
// Convert from terrain space to world space by subtracting TERRAIN_OFFSET
// Matches Torque's L2Wm transform: L2Wv = (-1024, -1024, 0)
const worldX = basePosition[0] + repX * REP_SIZE - TERRAIN_OFFSET;
const worldZ = basePosition[2] + repZ * REP_SIZE - TERRAIN_OFFSET;
return (
<mesh
key={`${repX},${repZ}`}
geometry={surfaceGeometry}
material={material}
position={[worldX, basePosition[1], worldZ]}
/>
);
})}
</>
<instancedMesh
ref={meshRef}
args={[surfaceGeometry, material, 9]}
frustumCulled={false}
/>
);
});