diff --git a/src/assets/AssetManager.ts b/src/assets/AssetManager.ts index aa86f727..0aa36fcd 100644 --- a/src/assets/AssetManager.ts +++ b/src/assets/AssetManager.ts @@ -534,16 +534,143 @@ function applyScaleAndGround(root: THREE.Object3D, assetId: string, scale: numbe } /** - * Clone a model properly - uses SkeletonUtils for animated/skinned models + * Deep clone geometry with fresh TypedArrays for WebGPU safety. + * Creates completely independent buffers that won't be affected if the original is disposed. + */ +function deepCloneGeometry(source: THREE.BufferGeometry): THREE.BufferGeometry { + const cloned = new THREE.BufferGeometry(); + + // Copy all attributes with fresh TypedArrays + for (const name of Object.keys(source.attributes)) { + const srcAttr = source.attributes[name]; + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + cloned.setAttribute(name, newAttr); + } + + // Copy index with fresh TypedArray if present + if (source.index) { + const srcIndex = source.index; + const newIndexArray = srcIndex.array.slice(0); + const newIndex = new THREE.BufferAttribute(newIndexArray, srcIndex.itemSize, srcIndex.normalized); + newIndex.needsUpdate = true; + cloned.setIndex(newIndex); + } + + // Copy morph attributes if present + if (source.morphAttributes) { + for (const name of Object.keys(source.morphAttributes)) { + const srcMorphArray = source.morphAttributes[name]; + cloned.morphAttributes[name] = srcMorphArray.map(srcAttr => { + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + return newAttr; + }); + } + } + + // Copy bounding volumes if computed + if (source.boundingBox) { + cloned.boundingBox = source.boundingBox.clone(); + } + if (source.boundingSphere) { + cloned.boundingSphere = source.boundingSphere.clone(); + } + + // Copy groups + for (const group of source.groups) { + cloned.addGroup(group.start, group.count, group.materialIndex); + } + + return cloned; +} + +/** + * Deep clone a model hierarchy, ensuring all geometries have fresh TypedArrays. + * This prevents WebGPU "setIndexBuffer" crashes when original geometry is disposed. + */ +function deepCloneModelHierarchy(node: THREE.Object3D): THREE.Object3D { + let clonedNode: THREE.Object3D; + + if (node instanceof THREE.Mesh) { + // Deep clone geometry to ensure independent GPU buffers + const clonedGeometry = deepCloneGeometry(node.geometry); + // Clone material (shallow is fine, materials don't cause GPU buffer issues) + const clonedMaterial = Array.isArray(node.material) + ? node.material.map(m => m.clone()) + : node.material.clone(); + clonedNode = new THREE.Mesh(clonedGeometry, clonedMaterial); + } else if (node instanceof THREE.SkinnedMesh) { + // SkinnedMesh needs special handling - deep clone geometry + const clonedGeometry = deepCloneGeometry(node.geometry); + const clonedMaterial = Array.isArray(node.material) + ? node.material.map(m => m.clone()) + : node.material.clone(); + // Create new SkinnedMesh with cloned geometry + clonedNode = new THREE.SkinnedMesh(clonedGeometry, clonedMaterial); + // Note: Skeleton binding happens through SkeletonUtils.clone for animated models + } else if (node instanceof THREE.Points) { + const clonedGeometry = deepCloneGeometry(node.geometry); + const clonedMaterial = Array.isArray(node.material) + ? node.material.map(m => m.clone()) + : node.material.clone(); + clonedNode = new THREE.Points(clonedGeometry, clonedMaterial); + } else if (node instanceof THREE.Line) { + const clonedGeometry = deepCloneGeometry(node.geometry); + const clonedMaterial = Array.isArray(node.material) + ? node.material.map(m => m.clone()) + : node.material.clone(); + clonedNode = new THREE.Line(clonedGeometry, clonedMaterial); + } else { + // Group or other Object3D - just clone the node itself + clonedNode = node.clone(false); // false = don't clone children (we'll do it manually) + } + + // Copy transform and properties + clonedNode.position.copy(node.position); + clonedNode.rotation.copy(node.rotation); + clonedNode.scale.copy(node.scale); + clonedNode.name = node.name; + clonedNode.visible = node.visible; + clonedNode.castShadow = node.castShadow; + clonedNode.receiveShadow = node.receiveShadow; + clonedNode.frustumCulled = node.frustumCulled; + clonedNode.renderOrder = node.renderOrder; + if (node.userData) { + clonedNode.userData = { ...node.userData }; + } + + // Recursively clone children + for (const child of node.children) { + clonedNode.add(deepCloneModelHierarchy(child)); + } + + return clonedNode; +} + +/** + * Clone a model properly - uses SkeletonUtils for animated/skinned models, + * and deep clones geometry for static models to ensure WebGPU buffer safety. * Per threejs-builder skill: SkeletonUtils.clone() is REQUIRED for animated models */ function cloneModel(original: THREE.Object3D, assetId: string): THREE.Object3D { if (animatedAssets.has(assetId)) { - // Animated/skinned model - must use SkeletonUtils - return SkeletonUtils.clone(original) as THREE.Object3D; + // Animated/skinned model - must use SkeletonUtils for proper skeleton binding + // Then deep clone geometries to ensure GPU buffer independence + const skeletonClone = SkeletonUtils.clone(original) as THREE.Object3D; + // Deep clone all geometries in the skeleton clone + skeletonClone.traverse((node) => { + if (node instanceof THREE.Mesh || node instanceof THREE.SkinnedMesh) { + const mesh = node as THREE.Mesh; + mesh.geometry = deepCloneGeometry(mesh.geometry); + } + }); + return skeletonClone; } - // Static model - regular clone is fine - return original.clone(); + // Static model - use deep clone to ensure GPU buffer independence + return deepCloneModelHierarchy(original); } // Standard materials library diff --git a/src/rendering/BuildingRenderer.ts b/src/rendering/BuildingRenderer.ts index d0e8e075..014bad73 100644 --- a/src/rendering/BuildingRenderer.ts +++ b/src/rendering/BuildingRenderer.ts @@ -103,23 +103,58 @@ const MAX_BUILDING_INSTANCES_PER_TYPE = BUILDING_RENDERER.MAX_INSTANCES_PER_TYPE const MAX_SELECTION_RING_INSTANCES = BUILDING_RENDERER.MAX_SELECTION_RING_INSTANCES; /** - * Clone geometry with proper GPU buffer initialization for WebGPU. - * Setting needsUpdate on cloned attributes forces WebGPU to create fresh GPU buffers. - * Without this, WebGPU may lazily share buffers with the source geometry, which - * become invalid when the source is disposed, causing "setIndexBuffer" crashes. + * Clone geometry with completely fresh GPU buffers for WebGPU. + * Creates new TypedArrays for all attributes and index to ensure zero shared state + * with the source geometry. This prevents "setIndexBuffer" crashes when source + * geometry is disposed while clones are still being rendered. * Also ensures required attributes (like UVs) exist to prevent "Vertex buffer slot" errors. */ function cloneGeometryForGPU(source: THREE.BufferGeometry): THREE.BufferGeometry { - const cloned = source.clone(); + const cloned = new THREE.BufferGeometry(); + + // Copy all attributes with fresh TypedArrays (no shared references) + for (const name of Object.keys(source.attributes)) { + const srcAttr = source.attributes[name]; + // Create a completely new TypedArray by slicing (creates a copy) + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + cloned.setAttribute(name, newAttr); + } + + // Copy index with fresh TypedArray if present + if (source.index) { + const srcIndex = source.index; + const newIndexArray = srcIndex.array.slice(0); + const newIndex = new THREE.BufferAttribute(newIndexArray, srcIndex.itemSize, srcIndex.normalized); + newIndex.needsUpdate = true; + cloned.setIndex(newIndex); + } + + // Copy morph attributes if present + if (source.morphAttributes) { + for (const name of Object.keys(source.morphAttributes)) { + const srcMorphArray = source.morphAttributes[name]; + cloned.morphAttributes[name] = srcMorphArray.map(srcAttr => { + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + return newAttr; + }); + } + } - // Mark all attributes as needing GPU buffer upload - for (const name of Object.keys(cloned.attributes)) { - cloned.attributes[name].needsUpdate = true; + // Copy bounding volumes if computed + if (source.boundingBox) { + cloned.boundingBox = source.boundingBox.clone(); + } + if (source.boundingSphere) { + cloned.boundingSphere = source.boundingSphere.clone(); } - // Mark index buffer as needing GPU buffer upload if present - if (cloned.index) { - cloned.index.needsUpdate = true; + // Copy groups + for (const group of source.groups) { + cloned.addGroup(group.start, group.count, group.materialIndex); } // Ensure UV coordinates exist - required by many shaders (slot 1) diff --git a/src/rendering/InstancedDecorations.ts b/src/rendering/InstancedDecorations.ts index 1680f700..0a873ba2 100644 --- a/src/rendering/InstancedDecorations.ts +++ b/src/rendering/InstancedDecorations.ts @@ -25,24 +25,67 @@ let _maxDistanceSq = 10000; // Squared distance for faster comparison // Distance culling multiplier from config const DISTANCE_CULL_MULTIPLIER = DECORATIONS.DISTANCE_CULL_MULTIPLIER; +// Geometry disposal quarantine - prevents WebGPU "setIndexBuffer" crashes +// by delaying geometry disposal until GPU has finished pending draw commands. +// WebGPU typically has 2-3 frames in flight; 4 frames provides safety margin. +const GEOMETRY_QUARANTINE_FRAMES = 4; + +// Shared frame counter for quarantine timing (updated by updateDecorationFrustum) +let _frameCount = 0; + /** - * Clone geometry with proper GPU buffer initialization for WebGPU. - * Setting needsUpdate on cloned attributes forces WebGPU to create fresh GPU buffers. - * Without this, WebGPU may lazily share buffers with the source geometry, which - * become invalid when the source is disposed, causing "setIndexBuffer" crashes. + * Clone geometry with completely fresh GPU buffers for WebGPU. + * Creates new TypedArrays for all attributes and index to ensure zero shared state + * with the source geometry. This prevents "setIndexBuffer" crashes when source + * geometry is disposed while clones are still being rendered. * Also ensures required attributes (like UVs) exist to prevent "Vertex buffer slot" errors. */ function cloneGeometryForGPU(source: THREE.BufferGeometry): THREE.BufferGeometry { - const cloned = source.clone(); + const cloned = new THREE.BufferGeometry(); + + // Copy all attributes with fresh TypedArrays (no shared references) + for (const name of Object.keys(source.attributes)) { + const srcAttr = source.attributes[name]; + // Create a completely new TypedArray by slicing (creates a copy) + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + cloned.setAttribute(name, newAttr); + } + + // Copy index with fresh TypedArray if present + if (source.index) { + const srcIndex = source.index; + const newIndexArray = srcIndex.array.slice(0); + const newIndex = new THREE.BufferAttribute(newIndexArray, srcIndex.itemSize, srcIndex.normalized); + newIndex.needsUpdate = true; + cloned.setIndex(newIndex); + } + + // Copy morph attributes if present + if (source.morphAttributes) { + for (const name of Object.keys(source.morphAttributes)) { + const srcMorphArray = source.morphAttributes[name]; + cloned.morphAttributes[name] = srcMorphArray.map(srcAttr => { + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + return newAttr; + }); + } + } - // Mark all attributes as needing GPU buffer upload - for (const name of Object.keys(cloned.attributes)) { - cloned.attributes[name].needsUpdate = true; + // Copy bounding volumes if computed + if (source.boundingBox) { + cloned.boundingBox = source.boundingBox.clone(); + } + if (source.boundingSphere) { + cloned.boundingSphere = source.boundingSphere.clone(); } - // Mark index buffer as needing GPU buffer upload if present - if (cloned.index) { - cloned.index.needsUpdate = true; + // Copy groups + for (const group of source.groups) { + cloned.addGroup(group.start, group.count, group.materialIndex); } // Ensure UV coordinates exist - required by many shaders (slot 1) @@ -81,6 +124,9 @@ export function updateDecorationFrustum(camera: THREE.Camera): void { // Higher camera = see more = larger distance threshold const maxDist = Math.max(DECORATIONS.MIN_CULL_DISTANCE, _cameraY * DISTANCE_CULL_MULTIPLIER); _maxDistanceSq = maxDist * maxDist; + + // Increment frame counter for quarantine timing + _frameCount++; } /** @@ -229,6 +275,13 @@ export class InstancedTrees { private materials: THREE.Material[] = []; private treeCollisions: Array<{ x: number; z: number; radius: number }> = []; + // Geometry disposal quarantine for WebGPU safety + private geometryQuarantine: Array<{ + geometry: THREE.BufferGeometry; + materials: THREE.Material[]; + frameQueued: number; + }> = []; + constructor( mapData: MapData, _biome: BiomeConfig, @@ -349,6 +402,9 @@ export class InstancedTrees { } public update(): void { + // Process quarantine first + this.processGeometryQuarantine(); + for (const { mesh, instances, maxCount } of this.instancedMeshes) { let visibleCount = 0; @@ -370,20 +426,71 @@ export class InstancedTrees { } } + private queueGeometryForDisposal(geometry: THREE.BufferGeometry, materials: THREE.Material[]): void { + this.geometryQuarantine.push({ + geometry, + materials, + frameQueued: _frameCount, + }); + } + + private processGeometryQuarantine(): void { + let writeIndex = 0; + for (let i = 0; i < this.geometryQuarantine.length; i++) { + const entry = this.geometryQuarantine[i]; + const framesInQuarantine = _frameCount - entry.frameQueued; + + if (framesInQuarantine >= GEOMETRY_QUARANTINE_FRAMES) { + // Safe to dispose now + entry.geometry.dispose(); + for (const material of entry.materials) { + material.dispose(); + } + } else { + // Keep in quarantine + this.geometryQuarantine[writeIndex++] = entry; + } + } + this.geometryQuarantine.length = writeIndex; + } + public getTreeCollisions(): Array<{ x: number; z: number; radius: number }> { return this.treeCollisions; } public dispose(): void { + // Remove meshes from scene for (const { mesh } of this.instancedMeshes) { - mesh.dispose(); + this.group.remove(mesh); } + + // Queue geometries and materials for delayed disposal (WebGPU safety) for (const geometry of this.geometries) { - geometry.dispose(); + this.queueGeometryForDisposal(geometry, []); } for (const material of this.materials) { - material.dispose(); + // Queue materials with empty geometry (they'll be disposed with the quarantine) + this.geometryQuarantine.push({ + geometry: new THREE.BufferGeometry(), // Dummy geometry + materials: [material], + frameQueued: _frameCount, + }); } + + // Clear references + this.instancedMeshes = []; + this.geometries = []; + this.materials = []; + + // Force process quarantine immediately if scene is being destroyed + // This is safe because no more rendering will happen + for (const entry of this.geometryQuarantine) { + entry.geometry.dispose(); + for (const mat of entry.materials) { + mat.dispose(); + } + } + this.geometryQuarantine = []; } } @@ -399,6 +506,13 @@ export class InstancedRocks { private materials: THREE.Material[] = []; private rockCollisions: Array<{ x: number; z: number; radius: number }> = []; + // Geometry disposal quarantine for WebGPU safety + private geometryQuarantine: Array<{ + geometry: THREE.BufferGeometry; + materials: THREE.Material[]; + frameQueued: number; + }> = []; + constructor( mapData: MapData, _biome: BiomeConfig, @@ -522,6 +636,9 @@ export class InstancedRocks { } public update(): void { + // Process quarantine first + this.processGeometryQuarantine(); + for (const { mesh, instances, maxCount } of this.instancedMeshes) { let visibleCount = 0; @@ -543,20 +660,64 @@ export class InstancedRocks { } } + private queueGeometryForDisposal(geometry: THREE.BufferGeometry, materials: THREE.Material[]): void { + this.geometryQuarantine.push({ + geometry, + materials, + frameQueued: _frameCount, + }); + } + + private processGeometryQuarantine(): void { + let writeIndex = 0; + for (let i = 0; i < this.geometryQuarantine.length; i++) { + const entry = this.geometryQuarantine[i]; + const framesInQuarantine = _frameCount - entry.frameQueued; + + if (framesInQuarantine >= GEOMETRY_QUARANTINE_FRAMES) { + entry.geometry.dispose(); + for (const material of entry.materials) { + material.dispose(); + } + } else { + this.geometryQuarantine[writeIndex++] = entry; + } + } + this.geometryQuarantine.length = writeIndex; + } + public getRockCollisions(): Array<{ x: number; z: number; radius: number }> { return this.rockCollisions; } public dispose(): void { for (const { mesh } of this.instancedMeshes) { - mesh.dispose(); + this.group.remove(mesh); } + for (const geometry of this.geometries) { - geometry.dispose(); + this.queueGeometryForDisposal(geometry, []); } for (const material of this.materials) { - material.dispose(); + this.geometryQuarantine.push({ + geometry: new THREE.BufferGeometry(), + materials: [material], + frameQueued: _frameCount, + }); + } + + this.instancedMeshes = []; + this.geometries = []; + this.materials = []; + + // Force process quarantine immediately if scene is being destroyed + for (const entry of this.geometryQuarantine) { + entry.geometry.dispose(); + for (const mat of entry.materials) { + mat.dispose(); + } } + this.geometryQuarantine = []; } } @@ -573,6 +734,13 @@ export class InstancedCrystals { private instances: InstanceData[] = []; private maxCount = 0; + // Geometry disposal quarantine for WebGPU safety + private geometryQuarantine: Array<{ + geometry: THREE.BufferGeometry; + materials: THREE.Material[]; + frameQueued: number; + }> = []; + constructor( mapData: MapData, biome: BiomeConfig, @@ -645,6 +813,9 @@ export class InstancedCrystals { } public update(): void { + // Process quarantine first + this.processGeometryQuarantine(); + if (!this.instancedMesh) return; let visibleCount = 0; @@ -665,14 +836,54 @@ export class InstancedCrystals { this.instancedMesh.instanceMatrix.needsUpdate = true; } + private processGeometryQuarantine(): void { + let writeIndex = 0; + for (let i = 0; i < this.geometryQuarantine.length; i++) { + const entry = this.geometryQuarantine[i]; + const framesInQuarantine = _frameCount - entry.frameQueued; + + if (framesInQuarantine >= GEOMETRY_QUARANTINE_FRAMES) { + entry.geometry.dispose(); + for (const material of entry.materials) { + material.dispose(); + } + } else { + this.geometryQuarantine[writeIndex++] = entry; + } + } + this.geometryQuarantine.length = writeIndex; + } + public getInstancedMesh(): THREE.InstancedMesh | null { return this.instancedMesh; } public dispose(): void { - this.instancedMesh?.dispose(); - this.geometry?.dispose(); - this.material?.dispose(); + if (this.instancedMesh) { + this.group.remove(this.instancedMesh); + } + + // Queue for delayed disposal + if (this.geometry) { + this.geometryQuarantine.push({ + geometry: this.geometry, + materials: this.material ? [this.material] : [], + frameQueued: _frameCount, + }); + } + + this.instancedMesh = null; + this.geometry = null; + this.material = null; + + // Force process quarantine immediately if scene is being destroyed + for (const entry of this.geometryQuarantine) { + entry.geometry.dispose(); + for (const mat of entry.materials) { + mat.dispose(); + } + } + this.geometryQuarantine = []; } } @@ -688,6 +899,13 @@ export class InstancedGrass { private instances: Array<{ x: number; y: number; z: number; scale: number; rotation: number }> = []; private maxCount = 0; + // Geometry disposal quarantine for WebGPU safety + private geometryQuarantine: Array<{ + geometry: THREE.BufferGeometry; + materials: THREE.Material[]; + frameQueued: number; + }> = []; + constructor( mapData: MapData, biome: BiomeConfig, @@ -762,6 +980,9 @@ export class InstancedGrass { } public update(): void { + // Process quarantine first + this.processGeometryQuarantine(); + if (!this.instancedMesh) return; let visibleCount = 0; @@ -782,10 +1003,49 @@ export class InstancedGrass { this.instancedMesh.instanceMatrix.needsUpdate = true; } + private processGeometryQuarantine(): void { + let writeIndex = 0; + for (let i = 0; i < this.geometryQuarantine.length; i++) { + const entry = this.geometryQuarantine[i]; + const framesInQuarantine = _frameCount - entry.frameQueued; + + if (framesInQuarantine >= GEOMETRY_QUARANTINE_FRAMES) { + entry.geometry.dispose(); + for (const material of entry.materials) { + material.dispose(); + } + } else { + this.geometryQuarantine[writeIndex++] = entry; + } + } + this.geometryQuarantine.length = writeIndex; + } + public dispose(): void { - this.instancedMesh?.dispose(); - this.geometry?.dispose(); - this.material?.dispose(); + if (this.instancedMesh) { + this.group.remove(this.instancedMesh); + } + + if (this.geometry) { + this.geometryQuarantine.push({ + geometry: this.geometry, + materials: this.material ? [this.material] : [], + frameQueued: _frameCount, + }); + } + + this.instancedMesh = null; + this.geometry = null; + this.material = null; + + // Force process quarantine immediately if scene is being destroyed + for (const entry of this.geometryQuarantine) { + entry.geometry.dispose(); + for (const mat of entry.materials) { + mat.dispose(); + } + } + this.geometryQuarantine = []; } } @@ -801,6 +1061,13 @@ export class InstancedPebbles { private instances: Array<{ x: number; y: number; z: number; scale: number; rotation: number }> = []; private maxCount = 0; + // Geometry disposal quarantine for WebGPU safety + private geometryQuarantine: Array<{ + geometry: THREE.BufferGeometry; + materials: THREE.Material[]; + frameQueued: number; + }> = []; + constructor( mapData: MapData, biome: BiomeConfig, @@ -866,6 +1133,9 @@ export class InstancedPebbles { } public update(): void { + // Process quarantine first + this.processGeometryQuarantine(); + if (!this.instancedMesh) return; let visibleCount = 0; @@ -886,9 +1156,48 @@ export class InstancedPebbles { this.instancedMesh.instanceMatrix.needsUpdate = true; } + private processGeometryQuarantine(): void { + let writeIndex = 0; + for (let i = 0; i < this.geometryQuarantine.length; i++) { + const entry = this.geometryQuarantine[i]; + const framesInQuarantine = _frameCount - entry.frameQueued; + + if (framesInQuarantine >= GEOMETRY_QUARANTINE_FRAMES) { + entry.geometry.dispose(); + for (const material of entry.materials) { + material.dispose(); + } + } else { + this.geometryQuarantine[writeIndex++] = entry; + } + } + this.geometryQuarantine.length = writeIndex; + } + public dispose(): void { - this.instancedMesh?.dispose(); - this.geometry?.dispose(); - this.material?.dispose(); + if (this.instancedMesh) { + this.group.remove(this.instancedMesh); + } + + if (this.geometry) { + this.geometryQuarantine.push({ + geometry: this.geometry, + materials: this.material ? [this.material] : [], + frameQueued: _frameCount, + }); + } + + this.instancedMesh = null; + this.geometry = null; + this.material = null; + + // Force process quarantine immediately if scene is being destroyed + for (const entry of this.geometryQuarantine) { + entry.geometry.dispose(); + for (const mat of entry.materials) { + mat.dispose(); + } + } + this.geometryQuarantine = []; } } diff --git a/src/rendering/ResourceRenderer.ts b/src/rendering/ResourceRenderer.ts index f2b1895b..82f69b2c 100644 --- a/src/rendering/ResourceRenderer.ts +++ b/src/rendering/ResourceRenderer.ts @@ -13,23 +13,58 @@ import { distance } from '@/utils/math'; // Velocity node returns zero for meshes without velocity attributes /** - * Clone geometry with proper GPU buffer initialization for WebGPU. - * Setting needsUpdate on cloned attributes forces WebGPU to create fresh GPU buffers. - * Without this, WebGPU may lazily share buffers with the source geometry, which - * become invalid when the source is disposed, causing "setIndexBuffer" crashes. + * Clone geometry with completely fresh GPU buffers for WebGPU. + * Creates new TypedArrays for all attributes and index to ensure zero shared state + * with the source geometry. This prevents "setIndexBuffer" crashes when source + * geometry is disposed while clones are still being rendered. * Also ensures required attributes (like UVs) exist to prevent "Vertex buffer slot" errors. */ function cloneGeometryForGPU(source: THREE.BufferGeometry): THREE.BufferGeometry { - const cloned = source.clone(); + const cloned = new THREE.BufferGeometry(); + + // Copy all attributes with fresh TypedArrays (no shared references) + for (const name of Object.keys(source.attributes)) { + const srcAttr = source.attributes[name]; + // Create a completely new TypedArray by slicing (creates a copy) + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + cloned.setAttribute(name, newAttr); + } + + // Copy index with fresh TypedArray if present + if (source.index) { + const srcIndex = source.index; + const newIndexArray = srcIndex.array.slice(0); + const newIndex = new THREE.BufferAttribute(newIndexArray, srcIndex.itemSize, srcIndex.normalized); + newIndex.needsUpdate = true; + cloned.setIndex(newIndex); + } + + // Copy morph attributes if present + if (source.morphAttributes) { + for (const name of Object.keys(source.morphAttributes)) { + const srcMorphArray = source.morphAttributes[name]; + cloned.morphAttributes[name] = srcMorphArray.map(srcAttr => { + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + return newAttr; + }); + } + } - // Mark all attributes as needing GPU buffer upload - for (const name of Object.keys(cloned.attributes)) { - cloned.attributes[name].needsUpdate = true; + // Copy bounding volumes if computed + if (source.boundingBox) { + cloned.boundingBox = source.boundingBox.clone(); + } + if (source.boundingSphere) { + cloned.boundingSphere = source.boundingSphere.clone(); } - // Mark index buffer as needing GPU buffer upload if present - if (cloned.index) { - cloned.index.needsUpdate = true; + // Copy groups + for (const group of source.groups) { + cloned.addGroup(group.start, group.count, group.materialIndex); } // Ensure UV coordinates exist - required by many shaders (slot 1) diff --git a/src/rendering/UnitRenderer.ts b/src/rendering/UnitRenderer.ts index 08f6adf4..f569264b 100644 --- a/src/rendering/UnitRenderer.ts +++ b/src/rendering/UnitRenderer.ts @@ -36,23 +36,58 @@ import { LODConfig } from './compute/UnifiedCullingCompute'; import { GPUIndirectRenderer } from './compute/GPUIndirectRenderer'; /** - * Clone geometry with proper GPU buffer initialization for WebGPU. - * Setting needsUpdate on cloned attributes forces WebGPU to create fresh GPU buffers. - * Without this, WebGPU may lazily share buffers with the source geometry, which - * become invalid when the source is disposed, causing "setIndexBuffer" crashes. + * Clone geometry with completely fresh GPU buffers for WebGPU. + * Creates new TypedArrays for all attributes and index to ensure zero shared state + * with the source geometry. This prevents "setIndexBuffer" crashes when source + * geometry is disposed while clones are still being rendered. * Also ensures required attributes (like UVs) exist to prevent "Vertex buffer slot" errors. */ function cloneGeometryForGPU(source: THREE.BufferGeometry): THREE.BufferGeometry { - const cloned = source.clone(); + const cloned = new THREE.BufferGeometry(); + + // Copy all attributes with fresh TypedArrays (no shared references) + for (const name of Object.keys(source.attributes)) { + const srcAttr = source.attributes[name]; + // Create a completely new TypedArray by slicing (creates a copy) + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + cloned.setAttribute(name, newAttr); + } + + // Copy index with fresh TypedArray if present + if (source.index) { + const srcIndex = source.index; + const newIndexArray = srcIndex.array.slice(0); + const newIndex = new THREE.BufferAttribute(newIndexArray, srcIndex.itemSize, srcIndex.normalized); + newIndex.needsUpdate = true; + cloned.setIndex(newIndex); + } + + // Copy morph attributes if present + if (source.morphAttributes) { + for (const name of Object.keys(source.morphAttributes)) { + const srcMorphArray = source.morphAttributes[name]; + cloned.morphAttributes[name] = srcMorphArray.map(srcAttr => { + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + return newAttr; + }); + } + } - // Mark all attributes as needing GPU buffer upload - for (const name of Object.keys(cloned.attributes)) { - cloned.attributes[name].needsUpdate = true; + // Copy bounding volumes if computed + if (source.boundingBox) { + cloned.boundingBox = source.boundingBox.clone(); + } + if (source.boundingSphere) { + cloned.boundingSphere = source.boundingSphere.clone(); } - // Mark index buffer as needing GPU buffer upload if present - if (cloned.index) { - cloned.index.needsUpdate = true; + // Copy groups + for (const group of source.groups) { + cloned.addGroup(group.start, group.count, group.materialIndex); } // Ensure UV coordinates exist - required by many shaders (slot 1) diff --git a/src/rendering/compute/GPUIndirectRenderer.ts b/src/rendering/compute/GPUIndirectRenderer.ts index a42fe295..5603c7a6 100644 --- a/src/rendering/compute/GPUIndirectRenderer.ts +++ b/src/rendering/compute/GPUIndirectRenderer.ts @@ -193,23 +193,51 @@ export class GPUIndirectRenderer { const instancedGeometry = new THREE.InstancedBufferGeometry(); instancedGeometry.instanceCount = MAX_UNITS; - // Clone attributes from source geometry to avoid sharing disposal lifecycle. - // Setting by reference causes WebGPU "setIndexBuffer" errors when the source - // geometry is disposed elsewhere while this geometry is still in use. - // CRITICAL: Mark cloned attributes as needsUpdate to force WebGPU to create - // fresh GPU buffers. Without this, WebGPU may lazily share buffers with the - // source geometry, which become invalid when the source is disposed. + // Clone attributes from source geometry with completely fresh TypedArrays. + // This ensures zero shared state with the source geometry, preventing + // "setIndexBuffer" crashes when source geometry is disposed. for (const name of Object.keys(geometry.attributes)) { - const clonedAttr = geometry.attributes[name].clone(); - clonedAttr.needsUpdate = true; - instancedGeometry.setAttribute(name, clonedAttr); + const srcAttr = geometry.attributes[name]; + // Create a completely new TypedArray by slicing (creates a copy) + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + instancedGeometry.setAttribute(name, newAttr); } - // Clone index if present - critical to avoid shared buffer disposal issues + // Clone index with fresh TypedArray if present if (geometry.index) { - const clonedIndex = geometry.index.clone(); - clonedIndex.needsUpdate = true; - instancedGeometry.setIndex(clonedIndex); + const srcIndex = geometry.index; + const newIndexArray = srcIndex.array.slice(0); + const newIndex = new THREE.BufferAttribute(newIndexArray, srcIndex.itemSize, srcIndex.normalized); + newIndex.needsUpdate = true; + instancedGeometry.setIndex(newIndex); + } + + // Copy morph attributes if present + if (geometry.morphAttributes) { + for (const name of Object.keys(geometry.morphAttributes)) { + const srcMorphArray = geometry.morphAttributes[name]; + instancedGeometry.morphAttributes[name] = srcMorphArray.map(srcAttr => { + const newArray = srcAttr.array.slice(0); + const newAttr = new THREE.BufferAttribute(newArray, srcAttr.itemSize, srcAttr.normalized); + newAttr.needsUpdate = true; + return newAttr; + }); + } + } + + // Copy bounding volumes if computed + if (geometry.boundingBox) { + instancedGeometry.boundingBox = geometry.boundingBox.clone(); + } + if (geometry.boundingSphere) { + instancedGeometry.boundingSphere = geometry.boundingSphere.clone(); + } + + // Copy groups + for (const group of geometry.groups) { + instancedGeometry.addGroup(group.start, group.count, group.materialIndex); } // Ensure UV coordinates exist - required by many shaders (slot 1)