From 71342a50404d07a201c9d340449c76d7f45f7f67 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Wed, 13 May 2026 04:30:06 +0200 Subject: [PATCH] Refactor spline repeater renderer to use InstancedMesh for optimized rendering --- .../spline-repeater-renderer.ts | 175 +++++++++++++++--- 1 file changed, 151 insertions(+), 24 deletions(-) diff --git a/src/spline-corridor/spline-repeater-renderer.ts b/src/spline-corridor/spline-repeater-renderer.ts index 5f263c75..e3349b5e 100644 --- a/src/spline-corridor/spline-repeater-renderer.ts +++ b/src/spline-corridor/spline-repeater-renderer.ts @@ -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(); 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; }