mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-03-22 22:00:59 +00:00
use instancing to improve performance
This commit is contained in:
parent
78e791f763
commit
cd2819d28a
33 changed files with 482 additions and 319 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ const BlendedTerrainTextures = memo(function BlendedTerrainTextures({
|
|||
);
|
||||
});
|
||||
|
||||
const TerrainMaterial = memo(function TerrainMaterial({
|
||||
export const TerrainMaterial = memo(function TerrainMaterial({
|
||||
displacementMap,
|
||||
visibilityMask,
|
||||
textureNames,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue