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

View file

@ -189,7 +189,11 @@ export const fogVertexShader = `
export const fogVertexShaderWorldPos = `
#ifdef USE_FOG
vFogWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
vec4 _fogPos = vec4(position, 1.0);
#ifdef USE_INSTANCING
_fogPos = instanceMatrix * _fogPos;
#endif
vFogWorldPosition = (modelMatrix * _fogPos).xyz;
#endif
`;
@ -243,7 +247,11 @@ export function installCustomFogShader(): void {
// This ensures fog doesn't change when rotating the camera
vFogDepth = length(mvPosition.xyz);
#ifdef USE_FOG_WORLD_POSITION
vFogWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
vec4 _fogPos2 = vec4(transformed, 1.0);
#ifdef USE_INSTANCING
_fogPos2 = instanceMatrix * _fogPos2;
#endif
vFogWorldPosition = (modelMatrix * _fogPos2).xyz;
#endif
#endif
`;
@ -310,7 +318,11 @@ export function injectCustomFog(
"#include <fog_vertex>",
`#include <fog_vertex>
#ifdef USE_FOG
vFogWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
vec4 _fogPos3 = vec4(transformed, 1.0);
#ifdef USE_INSTANCING
_fogPos3 = instanceMatrix * _fogPos3;
#endif
vFogWorldPosition = (modelMatrix * _fogPos3).xyz;
#endif`,
);

View file

@ -1,3 +1,4 @@
import { useMemo } from "react";
import { createStore } from "zustand/vanilla";
import { subscribeWithSelector } from "zustand/middleware";
import { useStoreWithEqualityFn } from "zustand/traditional";
@ -375,11 +376,11 @@ export function useRuntimeObjectById(
id == null ? -1 : (state.runtime.objectVersionById[id] ?? -1),
);
if (id == null || !runtime || version === -1) {
return undefined;
}
const object = runtime.state.objectsById.get(id);
return object ? { ...object } : undefined;
return useMemo(() => {
if (id == null || !runtime || version === -1) return undefined;
const object = runtime.state.objectsById.get(id);
return object ? { ...object } : undefined;
}, [id, runtime, version]);
}
export function useRuntimeObjectField<T = any>(
@ -431,11 +432,13 @@ export function useRuntimeObjectByName(
objectId == null ? -1 : (state.runtime.objectVersionById[objectId] ?? -1),
);
if (!runtime || !normalizedName || objectId == null || version === -1) {
return undefined;
}
const object = runtime.state.objectsById.get(objectId);
return object ? { ...object } : undefined;
return useMemo(() => {
if (!runtime || !normalizedName || objectId == null || version === -1) {
return undefined;
}
const object = runtime.state.objectsById.get(objectId);
return object ? { ...object } : undefined;
}, [runtime, normalizedName, objectId, version]);
}
export function useDatablockByName(
@ -452,11 +455,13 @@ export function useDatablockByName(
objectId == null ? -1 : (state.runtime.objectVersionById[objectId] ?? -1),
);
if (!runtime || !normalizedName || objectId == null || version === -1) {
return undefined;
}
const object = runtime.state.objectsById.get(objectId);
return object ? { ...object } : undefined;
return useMemo(() => {
if (!runtime || !normalizedName || objectId == null || version === -1) {
return undefined;
}
const object = runtime.state.objectsById.get(objectId);
return object ? { ...object } : undefined;
}, [runtime, normalizedName, objectId, version]);
}
export function useRuntimeChildIds(

View file

@ -29,6 +29,26 @@ export class LiveStreamAdapter extends StreamEngine {
private _snapshot: StreamSnapshot | null = null;
private _snapshotTick = -1;
private _ready = false;
// Generation counters for HUD caching in buildSnapshot() — avoids
// rebuilding arrays every tick when HUD state hasn't changed.
private _teamScoresGen = 0;
private _rosterGen = 0;
private _weaponsHudGen = 0;
private _inventoryHudGen = 0;
private _cachedHud: {
teamScoresGen: number;
rosterGen: number;
weaponsHudGen: number;
inventoryHudGen: number;
weaponsHud: StreamSnapshot["weaponsHud"];
inventoryHud: StreamSnapshot["inventoryHud"];
backpackPackIndex: number;
backpackActive: boolean;
backpackHud: StreamSnapshot["backpackHud"];
teamScores: StreamSnapshot["teamScores"];
playerRoster: StreamSnapshot["playerRoster"];
} | null = null;
/** Class names for datablocks, tracked from SimDataBlockEvents. */
private dataBlockClassNames = new Map<number, string>();
@ -101,6 +121,24 @@ export class LiveStreamAdapter extends StreamEngine {
return [...shapes];
}
// ── Generation counter hooks ──
protected onTeamScoresChanged(): void {
this._teamScoresGen++;
}
protected onRosterChanged(): void {
this._rosterGen++;
}
protected onWeaponsHudChanged(): void {
this._weaponsHudGen++;
}
protected onInventoryHudChanged(): void {
this._inventoryHudGen++;
}
// ── StreamingPlayback interface ──
reset(): void {
@ -109,6 +147,7 @@ export class LiveStreamAdapter extends StreamEngine {
this.currentTimeSec = 0;
this._snapshot = null;
this._snapshotTick = -1;
this._cachedHud = null;
this.dataBlockClassNames.clear();
this.observerMode = "fly";
this.missionName = null;
@ -206,6 +245,7 @@ export class LiveStreamAdapter extends StreamEngine {
this._ready = false;
this._snapshot = null;
this._snapshotTick = -1;
this._cachedHud = null;
this.observerMode = "fly";
this.lastMoveAck = 0;
// Clear stale mission info — new values arrive via MsgClientReady
@ -604,8 +644,46 @@ export class LiveStreamAdapter extends StreamEngine {
const entities = this.buildEntityList();
const timeSec = this.currentTimeSec;
const { chatMessages, audioEvents } = this.buildTimeFilteredEvents(timeSec);
const { weaponsHud, inventoryHud, backpackHud, teamScores, playerRoster } =
this.buildHudState();
// Reuse cached HUD arrays when generation counters haven't changed.
const prev = this._cachedHud;
let weaponsHud: StreamSnapshot["weaponsHud"];
let inventoryHud: StreamSnapshot["inventoryHud"];
let backpackHud: StreamSnapshot["backpackHud"];
let teamScores: StreamSnapshot["teamScores"];
let playerRoster: StreamSnapshot["playerRoster"];
if (
prev &&
prev.weaponsHudGen === this._weaponsHudGen &&
prev.inventoryHudGen === this._inventoryHudGen &&
prev.teamScoresGen === this._teamScoresGen &&
prev.rosterGen === this._rosterGen &&
prev.backpackPackIndex === this.backpackHud.packIndex &&
prev.backpackActive === this.backpackHud.active
) {
weaponsHud = prev.weaponsHud;
inventoryHud = prev.inventoryHud;
backpackHud = prev.backpackHud;
teamScores = prev.teamScores;
playerRoster = prev.playerRoster;
} else {
({ weaponsHud, inventoryHud, backpackHud, teamScores, playerRoster } =
this.buildHudState());
this._cachedHud = {
weaponsHudGen: this._weaponsHudGen,
inventoryHudGen: this._inventoryHudGen,
teamScoresGen: this._teamScoresGen,
rosterGen: this._rosterGen,
backpackPackIndex: this.backpackHud.packIndex,
backpackActive: this.backpackHud.active,
weaponsHud,
inventoryHud,
backpackHud,
teamScores,
playerRoster,
};
}
// Default observer camera if none exists
if (!this.camera) {

View file

@ -115,7 +115,11 @@ varying vec3 vTerrainWorldPos;`,
shader.vertexShader = shader.vertexShader.replace(
"#include <worldpos_vertex>",
`#include <worldpos_vertex>
vTerrainWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;`,
vec4 _terrainPos = vec4(transformed, 1.0);
#ifdef USE_INSTANCING
_terrainPos = instanceMatrix * _terrainPos;
#endif
vTerrainWorldPos = (modelMatrix * _terrainPos).xyz;`,
);
}

View file

@ -45,8 +45,15 @@ const vertexShader = /* glsl */ `
}
void main() {
// Apply instance transform when using InstancedMesh.
#ifdef USE_INSTANCING
mat4 localModel = modelMatrix * instanceMatrix;
#else
mat4 localModel = modelMatrix;
#endif
// Get world position for wave calculation
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vec4 worldPos = localModel * vec4(position, 1.0);
vWorldPosition = worldPos.xyz;
// Apply wave displacement to Y (vertical axis in Three.js)
@ -55,7 +62,7 @@ const vertexShader = /* glsl */ `
// Calculate final world position after displacement for fog
#ifdef USE_FOG
vec4 displacedWorldPos = modelMatrix * vec4(displaced, 1.0);
vec4 displacedWorldPos = localModel * vec4(displaced, 1.0);
vFogWorldPosition = displacedWorldPos.xyz;
#endif
@ -63,7 +70,7 @@ const vertexShader = /* glsl */ `
vViewVector = cameraPosition - worldPos.xyz;
vDistance = length(vViewVector);
vec4 mvPosition = viewMatrix * modelMatrix * vec4(displaced, 1.0);
vec4 mvPosition = viewMatrix * localModel * vec4(displaced, 1.0);
gl_Position = projectionMatrix * mvPosition;
// Set fog depth (distance from camera) - normally done by fog_vertex include