Refactor spline repeater renderer to use InstancedMesh for optimized rendering

This commit is contained in:
2026-05-13 04:30:06 +02:00
parent 3f64a3f670
commit 71342a5040

View File

@@ -1,13 +1,22 @@
import { Group, MathUtils } from "three";
import {
BufferGeometry,
Group,
InstancedMesh,
Matrix4,
MathUtils,
Mesh,
Quaternion,
Vector3,
type Material
} from "three";
import { instantiateModelTemplate } from "../assets/gltf-model-import";
import { disposeModelInstance } from "../assets/model-instance-rendering";
import type { Terrain } from "../document/terrains";
import { applyRendererRenderCategoryFromMaterial } from "../rendering/render-layers";
import { loadBundledSplineCorridorModelTemplate } from "./bundled-spline-corridor-model-loader";
import {
deriveSplineRepeaterInstances,
type SplineRepeaterInstance,
type SplineRepeaterPathLike
} from "./spline-repeaters";
@@ -21,6 +30,13 @@ interface SplineRepeaterRendererOptions {
onDiagnostic?: (message: string) => void;
}
interface TemplateMeshSource {
name: string;
geometry: BufferGeometry;
material: Material | Material[];
matrixWorld: Matrix4;
}
export class SplineRepeaterRenderer {
readonly group = new Group();
@@ -57,10 +73,33 @@ export class SplineRepeaterRenderer {
}
this.group.remove(this.activeGroup);
disposeModelInstance(this.activeGroup);
this.disposeInstancedGroup(this.activeGroup);
this.activeGroup = null;
}
private disposeInstancedGroup(group: Group) {
group.traverse((object) => {
const maybeInstancedMesh = object as InstancedMesh & {
isInstancedMesh?: boolean;
};
if (maybeInstancedMesh.isInstancedMesh !== true) {
return;
}
maybeInstancedMesh.geometry.dispose();
if (Array.isArray(maybeInstancedMesh.material)) {
for (const material of maybeInstancedMesh.material) {
material.dispose();
}
return;
}
maybeInstancedMesh.material.dispose();
});
}
private emitDiagnostic(message: string) {
if (this.onDiagnostic !== undefined) {
this.onDiagnostic(message);
@@ -70,6 +109,87 @@ export class SplineRepeaterRenderer {
console.warn(message);
}
private cloneMaterial(material: Material | Material[]): Material | Material[] {
return Array.isArray(material)
? material.map((entry) => entry.clone())
: material.clone();
}
private collectTemplateMeshes(template: Group): TemplateMeshSource[] {
const meshSources: TemplateMeshSource[] = [];
template.updateMatrixWorld(true);
template.traverse((object) => {
const maybeMesh = object as Mesh & { isMesh?: boolean };
if (maybeMesh.isMesh !== true) {
return;
}
meshSources.push({
name: maybeMesh.name,
geometry: maybeMesh.geometry.clone(),
material: this.cloneMaterial(maybeMesh.material),
matrixWorld: maybeMesh.matrixWorld.clone()
});
});
return meshSources;
}
private createInstancedMeshesForAsset(options: {
assetId: string;
instances: readonly SplineRepeaterInstance[];
meshSources: readonly TemplateMeshSource[];
}): InstancedMesh[] {
const instancePosition = new Vector3();
const instanceScale = new Vector3();
const instanceQuaternion = new Quaternion();
const instanceMatrix = new Matrix4();
const finalMatrix = new Matrix4();
return options.meshSources.map((meshSource, meshIndex) => {
const instancedMesh = new InstancedMesh(
meshSource.geometry,
meshSource.material,
options.instances.length
);
instancedMesh.name =
meshSource.name.trim().length > 0
? `SplineRepeater:${options.assetId}:${meshSource.name}`
: `SplineRepeater:${options.assetId}:mesh-${meshIndex}`;
instancedMesh.userData.nonPickable = true;
instancedMesh.userData.splineCorridorAssetId = options.assetId;
instancedMesh.frustumCulled = true;
options.instances.forEach((instance, instanceIndex) => {
instancePosition.set(
instance.position.x,
instance.position.y,
instance.position.z
);
instanceQuaternion.setFromAxisAngle(
new Vector3(0, 1, 0),
MathUtils.degToRad(instance.yawDegrees)
);
instanceScale.set(instance.scale, instance.scale, instance.scale);
instanceMatrix.compose(
instancePosition,
instanceQuaternion,
instanceScale
);
finalMatrix.multiplyMatrices(instanceMatrix, meshSource.matrixWorld);
instancedMesh.setMatrixAt(instanceIndex, finalMatrix);
});
instancedMesh.instanceMatrix.needsUpdate = true;
instancedMesh.computeBoundingBox();
instancedMesh.computeBoundingSphere();
applyRendererRenderCategoryFromMaterial(instancedMesh);
return instancedMesh;
});
}
private async rebuildAsync(
requestId: number,
input: SplineRepeaterRendererInput
@@ -83,54 +203,61 @@ export class SplineRepeaterRenderer {
const nextGroup = new Group();
nextGroup.name = "splineRepeaterInstances";
nextGroup.userData.nonPickable = true;
const instancesByAssetId = new Map<string, SplineRepeaterInstance[]>();
for (const instance of instances) {
const assetInstances = instancesByAssetId.get(instance.asset.id) ?? [];
assetInstances.push(instance);
instancesByAssetId.set(instance.asset.id, assetInstances);
}
for (const [assetId, assetInstances] of instancesByAssetId) {
const asset = assetInstances[0]?.asset;
if (asset === undefined) {
continue;
}
let template: Group;
try {
template = await loadBundledSplineCorridorModelTemplate(
instance.asset.bundledPath
asset.bundledPath
);
} catch (error) {
this.emitDiagnostic(
error instanceof Error
? error.message
: `Bundled spline corridor model failed to load from ${instance.asset.bundledPath}.`
: `Bundled spline corridor model failed to load from ${asset.bundledPath}.`
);
continue;
}
if (requestId !== this.requestId) {
disposeModelInstance(nextGroup);
this.disposeInstancedGroup(nextGroup);
return;
}
const model = instantiateModelTemplate(template);
model.name = `SplineRepeater:${instance.asset.id}`;
model.position.set(
instance.position.x,
instance.position.y,
instance.position.z
);
model.rotation.set(0, MathUtils.degToRad(instance.yawDegrees), 0);
model.scale.setScalar(instance.scale);
model.userData.nonPickable = true;
model.userData.pathId = instance.pathId;
model.userData.pathRepeaterId = instance.repeaterId;
model.userData.splineCorridorAssetId = instance.asset.id;
applyRendererRenderCategoryFromMaterial(model);
nextGroup.add(model);
const instancedMeshes = this.createInstancedMeshesForAsset({
assetId,
instances: assetInstances,
meshSources: this.collectTemplateMeshes(template)
});
for (const instancedMesh of instancedMeshes) {
nextGroup.add(instancedMesh);
}
}
if (requestId !== this.requestId) {
disposeModelInstance(nextGroup);
this.disposeInstancedGroup(nextGroup);
return;
}
this.clearActiveGroup();
if (nextGroup.children.length === 0) {
disposeModelInstance(nextGroup);
this.disposeInstancedGroup(nextGroup);
this.onRebuilt?.();
return;
}