UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in

643 lines 29.1 kB
import { BatchedMesh, Color, Matrix4, MeshStandardMaterial, RawShaderMaterial } from "three"; import { isDevEnvironment, showBalloonError } from "../engine/debug/index.js"; import { $instancingAutoUpdateBounds, $instancingRenderer, NEED_UPDATE_INSTANCE_KEY } from "../engine/engine_instancing.js"; import { getParam, makeIdFromRandomWords } from "../engine/engine_utils.js"; import { NEEDLE_progressive } from "../engine/extensions/index.js"; import { GameObject } from "./Component.js"; const debugInstancing = getParam("debuginstancing"); ; export class InstancingHandler { static instance = new InstancingHandler(); objs = []; setup(renderer, obj, context, handlesArray, args, level = 0) { // make sure setting casting settings are applied so when we add the mesh to the InstancedMesh we can ask for the correct cast shadow setting renderer.applySettings(obj); const res = this.tryCreateOrAddInstance(obj, context, args); if (res) { if (handlesArray === null) handlesArray = []; handlesArray.push(res); // load texture lods NEEDLE_progressive.assignTextureLOD(res.renderer.material, 0); // Load mesh lods for (const mesh of renderer.sharedMeshes) { const geometry = mesh.geometry; NEEDLE_progressive.assignMeshLOD(mesh, 0).then(lod => { if (lod && renderer.activeAndEnabled && geometry != lod) { res.setGeometry(lod); } }); } } else if (level <= 0 && obj.type !== "Mesh") { const nextLevel = level + 1; for (const ch of obj.children) { handlesArray = this.setup(renderer, ch, context, handlesArray, args, nextLevel); } } if (level === 0) { // For multi material objects we only want to track the root object's matrix if (args.useMatrixWorldAutoUpdate && handlesArray && handlesArray.length >= 0) { this.autoUpdateInstanceMatrix(obj); } } return handlesArray; } tryCreateOrAddInstance(obj, context, args) { if (obj.type === "Mesh") { const index = args.foundMeshes; args.foundMeshes += 1; if (!args.rend.enableInstancing) return null; if (args.rend.enableInstancing === true) { // instancing is enabled globally // continue.... } else { if (index >= args.rend.enableInstancing.length) { if (debugInstancing) console.error("Something is wrong with instance setup", obj, args.rend.enableInstancing, index); return null; } if (!args.rend.enableInstancing[index]) { // instancing is disabled // console.log("Instancing is disabled", obj); return null; } } // instancing is enabled: const mesh = obj; // const geo = mesh.geometry as BufferGeometry; const mat = mesh.material; for (const i of this.objs) { if (!i.canAdd(mesh.geometry, mat)) continue; const handle = i.addInstance(mesh); return handle; } const maxInstances = 16; let name = obj.name; if (!name?.length) name = makeIdFromRandomWords(); const i = new InstancedMeshRenderer(name, mesh.geometry, mat, maxInstances, context); this.objs.push(i); const handle = i.addInstance(mesh); return handle; } return null; } autoUpdateInstanceMatrix(obj) { const original = obj.matrixWorld["multiplyMatrices"].bind(obj.matrixWorld); const previousMatrix = obj.matrixWorld.clone(); const matrixChangeWrapper = (a, b) => { const newMatrixWorld = original(a, b); if (obj[NEED_UPDATE_INSTANCE_KEY] || previousMatrix.equals(newMatrixWorld) === false) { previousMatrix.copy(newMatrixWorld); obj[NEED_UPDATE_INSTANCE_KEY] = true; } return newMatrixWorld; }; obj.matrixWorld["multiplyMatrices"] = matrixChangeWrapper; // wrap matrixWorldNeedsUpdate // let originalMatrixWorldNeedsUpdate = obj.matrixWorldNeedsUpdate; // Object.defineProperty(obj, "matrixWorldNeedsUpdate", { // get: () => { // return originalMatrixWorldNeedsUpdate; // }, // set: (value: boolean) => { // if(value) console.warn("SET MATRIX WORLD NEEDS UPDATE"); // originalMatrixWorldNeedsUpdate = value; // } // }); } } /** * The instance handle is used to interface with the mesh that is rendered using instancing. */ export class InstanceHandle { static all = []; /** The name of the object */ get name() { return this.object.name; } get isActive() { return this.__instanceIndex >= 0; } get vertexCount() { return this.object.geometry.attributes.position.count; } get maxVertexCount() { return this.meshInformation.vertexCount; } get reservedVertexCount() { return this.__reservedVertexRange; } get indexCount() { return this.object.geometry.index ? this.object.geometry.index.count : 0; } get maxIndexCount() { return this.meshInformation.indexCount; } get reservedIndexCount() { return this.__reservedIndexRange; } /** The object that is being instanced */ object; /** The instancer/BatchedMesh that is rendering this object*/ renderer; /** @internal */ __instanceIndex = -1; /** @internal */ __reservedVertexRange = 0; /** @internal */ __reservedIndexRange = 0; /** The mesh information of the object */ meshInformation; constructor(originalObject, instancer) { this.__instanceIndex = -1; this.object = originalObject; this.renderer = instancer; originalObject[$instancingRenderer] = instancer; this.meshInformation = getMeshInformation(originalObject.geometry); InstanceHandle.all.push(this); } /** Updates the matrix from the rendered object. Will also call updateWorldMatrix internally */ updateInstanceMatrix(updateChildren = false, updateMatrix = true) { if (this.__instanceIndex < 0) return; if (updateMatrix) this.object.updateWorldMatrix(true, updateChildren); this.renderer.updateInstance(this.object.matrixWorld, this.__instanceIndex); } /** Updates the matrix of the instance */ setMatrix(matrix) { if (this.__instanceIndex < 0) return; this.renderer.updateInstance(matrix, this.__instanceIndex); } /** Can be used to change the geometry of this instance */ setGeometry(geo) { if (this.__instanceIndex < 0) return false; if (this.vertexCount > this.__reservedVertexRange) { console.error(`Cannot update geometry, reserved vertex range is too small: ${this.__reservedVertexRange} < ${this.vertexCount} vertices for ${this.name}`); return false; } if (this.indexCount > this.__reservedIndexRange) { console.error(`Cannot update geometry, reserved index range is too small: ${this.__reservedIndexRange} < ${this.indexCount} indices for ${this.name}`); return false; } return this.renderer.updateGeometry(geo, this.__instanceIndex); } /** Adds this object to the instancing renderer (effectively activating instancing) */ add() { if (this.__instanceIndex >= 0) return; this.renderer.add(this); GameObject.markAsInstancedRendered(this.object, true); } /** Removes this object from the instancing renderer */ remove(delete_) { if (this.__instanceIndex < 0) return; this.renderer.remove(this, delete_); GameObject.markAsInstancedRendered(this.object, false); if (delete_) { const i = InstanceHandle.all.indexOf(this); if (i >= 0) { InstanceHandle.all.splice(i, 1); } } } } class InstancedMeshRenderer { /** The three instanced mesh * @link https://threejs.org/docs/#api/en/objects/InstancedMesh */ get batchedMesh() { return this._batchedMesh; } get visible() { return this._batchedMesh.visible; } set visible(val) { this._batchedMesh.visible = val; } get castShadow() { return this._batchedMesh.castShadow; } set castShadow(val) { this._batchedMesh.castShadow = val; } set receiveShadow(val) { this._batchedMesh.receiveShadow = val; } /** If true, the instancer is allowed to grow when the max instance count is reached */ allowResize = true; /** The name of the instancer */ name = ""; /** The added geometry */ geometry; /** The material used for the instanced mesh */ material; /** The current number of instances */ get count() { return this._currentInstanceCount; } /** Update the bounding box and sphere of the instanced mesh * @param box If true, update the bounding box * @param sphere If true, update the bounding sphere */ updateBounds(box = true, sphere = true) { this._needUpdateBounds = false; if (box) this._batchedMesh.computeBoundingBox(); if (sphere) this._batchedMesh.computeBoundingSphere(); } _context; _batchedMesh; _handles = []; _maxInstanceCount; _currentInstanceCount = 0; _currentVertexCount = 0; _currentIndexCount = 0; _maxVertexCount; _maxIndexCount; static nullMatrix = new Matrix4(); /** Check if the geometry can be added to this instancer * @param geometry The geometry to check * @param material The material of the geometry * @returns true if the geometry can be added */ canAdd(geometry, material) { if (this._maxVertexCount > 10_000_000) return false; // The material instance must match // perhaps at some point later we *could* check if it's the same shader and properties but this would be risky if (material !== this.material) return false; // if(this.geometry !== _geometry) return false; // console.log(geometry.name, geometry.uuid); if (!this.validateGeometry(geometry)) return false; // const validationMethod = this.inst["_validateGeometry"]; // if (!validationMethod) throw new Error("InstancedMesh does not have a _validateGeometry method"); // try { // validationMethod.call(this.inst, _geometry); // } // catch (err) { // // console.error(err); // return false; // } const hasSpace = !this.mustGrow(geometry); if (hasSpace) return true; if (this.allowResize) return true; return false; } _needUpdateBounds = false; _debugMaterial = null; constructor(name, geo, material, initialMaxCount, context) { this.name = name; this.geometry = geo; this.material = material; this._context = context; this._maxInstanceCount = Math.max(2, initialMaxCount); if (debugInstancing) { this._debugMaterial = createDebugMaterial(); } const estimate = this.tryEstimateVertexCountSize(this._maxInstanceCount, [geo], initialMaxCount); this._maxVertexCount = estimate.vertexCount; this._maxIndexCount = estimate.indexCount; this._batchedMesh = new BatchedMesh(this._maxInstanceCount, this._maxVertexCount, this._maxIndexCount, this._debugMaterial ?? this.material); // this.inst = new InstancedMesh(geo, material, count); this._batchedMesh[$instancingAutoUpdateBounds] = true; // this.inst.count = 0; this._batchedMesh.visible = true; this._context.scene.add(this._batchedMesh); // Not handled by RawShaderMaterial, so we need to set the define explicitly. // Edge case: theoretically some users of the material could use it in an // instanced fashion, and some others not. In that case, the material would not // be able to be shared between the two use cases. We could probably add a // onBeforeRender call for the InstancedMesh and set the define there. // Same would apply if we support skinning - // there we would have to split instanced batches so that the ones using skinning // are all in the same batch. if (material instanceof RawShaderMaterial) { material.defines["USE_INSTANCING"] = true; material.needsUpdate = true; } context.pre_render_callbacks.push(this.onBeforeRender); context.post_render_callbacks.push(this.onAfterRender); if (debugInstancing) { console.log(`Instanced renderer created with ${this._maxInstanceCount} instances, ${this._maxVertexCount} max vertices and ${this._maxIndexCount} max indices for \"${name}\"`); } } dispose() { if (debugInstancing) console.warn("Dispose instanced renderer", this.name); this._context.scene.remove(this._batchedMesh); this._batchedMesh.dispose(); this._batchedMesh = null; this._handles = []; } addInstance(obj) { const handle = new InstanceHandle(obj, this); if (obj.castShadow === true && this._batchedMesh.castShadow === false) { this._batchedMesh.castShadow = true; } if (obj.receiveShadow === true && this._batchedMesh.receiveShadow === false) { this._batchedMesh.receiveShadow = true; } try { this.add(handle); } catch (e) { console.error("Failed adding mesh to instancing\n", e); if (isDevEnvironment()) showBalloonError("Failed instancing mesh. See the browser console for details."); return null; } return handle; } add(handle) { const geo = handle.object.geometry; if (this.mustGrow(geo)) { if (this.allowResize) { this.grow(geo); } else { console.error("Cannot add instance, max count reached", this.name, this.count, this._maxInstanceCount); return false; } } handle.object.updateWorldMatrix(true, true); this.addGeometry(handle); this._handles[handle.__instanceIndex] = handle; this._currentInstanceCount += 1; this.markNeedsUpdate(); if (this._currentInstanceCount > 0) this._batchedMesh.visible = true; return true; } remove(handle, delete_) { if (!handle) return; if (handle.__instanceIndex < 0 || this._handles[handle.__instanceIndex] != handle || this._currentInstanceCount <= 0) { return; } this.removeGeometry(handle, delete_); this._handles[handle.__instanceIndex] = null; handle.__instanceIndex = -1; if (this._currentInstanceCount > 0) { this._currentInstanceCount -= 1; } if (this._currentInstanceCount <= 0) this._batchedMesh.visible = false; this.markNeedsUpdate(); } updateInstance(mat, index) { this._batchedMesh.setMatrixAt(index, mat); this.markNeedsUpdate(); } updateGeometry(geo, index) { if (!this.validateGeometry(geo)) { return false; } if (this.mustGrow()) { this.grow(geo); } if (debugInstancing) console.debug("UPDATE MESH", index, geo.name, getMeshInformation(geo), geo.attributes.position.count, geo.index ? geo.index.count : 0); this._batchedMesh.setGeometryAt(index, geo); this.markNeedsUpdate(); return true; } onBeforeRender = () => { // ensure the instanced mesh is rendered / has correct layers this._batchedMesh.layers.enableAll(); if (this._needUpdateBounds && this._batchedMesh[$instancingAutoUpdateBounds] === true) { if (debugInstancing) console.log("Update instancing bounds", this.name, this._batchedMesh.matrixWorldNeedsUpdate); this.updateBounds(); } }; onAfterRender = () => { // hide the instanced mesh again when its not being rendered (for raycasting we still use the original object) this._batchedMesh.layers.disableAll(); }; validateGeometry(geometry) { const batchGeometry = this.geometry; for (const attributeName in batchGeometry.attributes) { if (attributeName === "batchId") { continue; } if (!geometry.hasAttribute(attributeName)) { if (isDevEnvironment()) console.warn(`BatchedMesh: Added geometry missing "${attributeName}". All geometries must have consistent attributes.`); // geometry.setAttribute(attributeName, batchGeometry.getAttribute(attributeName).clone()); return false; // throw new Error(`BatchedMesh: Added geometry missing "${attributeName}". All geometries must have consistent attributes.`); } // const srcAttribute = geometry.getAttribute(attributeName); // const dstAttribute = batchGeometry.getAttribute(attributeName); // if (srcAttribute.itemSize !== dstAttribute.itemSize || srcAttribute.normalized !== dstAttribute.normalized) { // if (debugInstancing) throw new Error('BatchedMesh: All attributes must have a consistent itemSize and normalized value.'); // return false; // } } return true; } markNeedsUpdate() { if (debugInstancing) console.warn("Marking instanced mesh dirty", this.name); this._needUpdateBounds = true; // this.inst.instanceMatrix.needsUpdate = true; } /** * @param geo The geometry to add (if none is provided it means the geometry is already added and just updated) */ mustGrow(geo) { if (this.count >= this._maxInstanceCount) return true; if (!geo) return false; const meshInfo = getMeshInformation(geo); const newVertexCount = meshInfo.vertexCount; const newIndexCount = meshInfo.indexCount; return this._currentVertexCount + newVertexCount > this._maxVertexCount || this._currentIndexCount + newIndexCount > this._maxIndexCount; } grow(geometry) { const newSize = this._maxInstanceCount * 2; // create a new BatchedMesh instance const estimatedSpace = this.tryEstimateVertexCountSize(newSize, [geometry]); // geometry.attributes.position.count; // const indices = geometry.index ? geometry.index.count : 0; const newMaxVertexCount = Math.max(this._maxVertexCount, estimatedSpace.vertexCount); const newMaxIndexCount = Math.max(this._maxIndexCount, estimatedSpace.indexCount, this._maxVertexCount * 2); if (debugInstancing) { const geometryInfo = getMeshInformation(geometry); console.warn(`Growing batched mesh for \"${this.name}/${geometry.name}\" ${geometryInfo.vertexCount} vertices, ${geometryInfo.indexCount} indices\nMax count ${this._maxInstanceCount}${newSize}\nMax vertex count ${this._maxVertexCount} -> ${newMaxVertexCount}\nMax index count ${this._maxIndexCount} -> ${newMaxIndexCount}`); this._debugMaterial = createDebugMaterial(); } this._maxVertexCount = newMaxVertexCount; this._maxIndexCount = newMaxIndexCount; const newInst = new BatchedMesh(newSize, this._maxVertexCount, this._maxIndexCount, this._debugMaterial ?? this.material); newInst.layers = this._batchedMesh.layers; newInst.castShadow = this._batchedMesh.castShadow; newInst.receiveShadow = this._batchedMesh.receiveShadow; newInst.visible = this._batchedMesh.visible; newInst[$instancingAutoUpdateBounds] = this._batchedMesh[$instancingAutoUpdateBounds]; newInst.matrixAutoUpdate = this._batchedMesh.matrixAutoUpdate; newInst.matrixWorldNeedsUpdate = this._batchedMesh.matrixWorldNeedsUpdate; newInst.matrixAutoUpdate = this._batchedMesh.matrixAutoUpdate; newInst.matrixWorld.copy(this._batchedMesh.matrixWorld); newInst.matrix.copy(this._batchedMesh.matrix); // dispose the old batched mesh this._batchedMesh.dispose(); this._batchedMesh.removeFromParent(); this._batchedMesh = newInst; this._maxInstanceCount = newSize; // since we have a new batched mesh we need to re-add all the instances // fixes https://linear.app/needle/issue/NE-5711 this._usedBuckets.length = 0; this._availableBuckets.length = 0; // add current instances to new instanced mesh for (const handle of this._handles) { if (handle && handle.__instanceIndex >= 0) { this.addGeometry(handle); } } this._context.scene.add(newInst); } tryEstimateVertexCountSize(newMaxInstances, _newGeometries, newGeometriesFactor = 1) { /** Used geometries and how many instances use them */ const usedGeometries = new Map(); for (const handle of this._handles) { if (handle && handle.__instanceIndex >= 0) { if (!usedGeometries.has(handle.object.geometry)) { const meshinfo = { count: 1, ...getMeshInformation(handle.object.geometry) }; usedGeometries.set(handle.object.geometry, meshinfo); } else { const entry = usedGeometries.get(handle.object.geometry); entry.count += 1; } } } // then calculate the total vertex count let totalVertices = 0; let totalIndices = 0; // let maxVertices = 0; for (const [_geo, data] of usedGeometries) { totalVertices += data.vertexCount * data.count; totalIndices += data.indexCount * data.count; // maxVertices = Math.max(maxVertices, geo.attributes.position.count * count); } // we calculate the average to make an educated guess of how many vertices will be needed with the new buffer count const averageVerts = Math.ceil(totalVertices / Math.max(1, this._currentInstanceCount)); let maxVertexCount = averageVerts * newMaxInstances; const averageIndices = Math.ceil(totalIndices / Math.max(1, this._currentInstanceCount)); let maxIndexCount = averageIndices * newMaxInstances * 2; // if new geometries are provided we *know* that they will be added // so we make sure to include them in the calculation if (_newGeometries) { for (const geo of _newGeometries) { const meshinfo = getMeshInformation(geo); maxVertexCount += meshinfo.vertexCount * newGeometriesFactor; maxIndexCount += meshinfo.indexCount * newGeometriesFactor; } } return { vertexCount: maxVertexCount, indexCount: maxIndexCount }; } _availableBuckets = new Array(); _usedBuckets = new Array(); addGeometry(handle) { // if (handle.reservedVertexCount <= 0 || handle.reservedIndexCount <= 0) { // console.error("Cannot add geometry with 0 vertices or indices", handle.name); // return; // } // search the smallest available bucket that fits our handle let smallestBucket = null; let smallestBucketIndex = -1; for (let i = this._availableBuckets.length - 1; i >= 0; i--) { const bucket = this._availableBuckets[i]; if (bucket.vertexCount >= handle.maxVertexCount && bucket.indexCount >= handle.maxIndexCount) { if (smallestBucket == null || bucket.vertexCount < smallestBucket.vertexCount) { smallestBucket = bucket; smallestBucketIndex = i; } } } // if we have a bucket that is big enough, use it if (smallestBucket != null) { const bucket = smallestBucket; if (debugInstancing) console.debug(`RE-USE SPACE #${bucket.index}, ${handle.maxVertexCount} vertices, ${handle.maxIndexCount} indices, ${handle.name}`); this._batchedMesh.setGeometryAt(bucket.index, handle.object.geometry); this._batchedMesh.setMatrixAt(bucket.index, handle.object.matrixWorld); this._batchedMesh.setVisibleAt(bucket.index, true); handle.__instanceIndex = bucket.index; this._usedBuckets[bucket.index] = bucket; this._availableBuckets.splice(smallestBucketIndex, 1); return; } // otherwise add more geometry const geo = handle.object.geometry; if (debugInstancing) console.debug("ADD GEOMETRY", geo.name, "\nvertex:", `${this._currentVertexCount} + ${handle.maxVertexCount} < ${this._maxVertexCount}?`, "\nindex:", handle.maxIndexCount, this._currentIndexCount, this._maxIndexCount); const i = this._batchedMesh.addGeometry(geo, handle.maxVertexCount, handle.maxIndexCount); handle.__instanceIndex = i; handle.__reservedVertexRange = handle.maxVertexCount; handle.__reservedIndexRange = handle.maxIndexCount; this._currentVertexCount += handle.maxVertexCount; this._currentIndexCount += handle.maxIndexCount; this._usedBuckets[i] = { index: i, vertexCount: handle.maxVertexCount, indexCount: handle.maxIndexCount }; this._batchedMesh.setMatrixAt(i, handle.object.matrixWorld); if (debugInstancing) console.debug(`ADD MESH & RESERVE SPACE #${i}, ${handle.maxVertexCount} vertices, ${handle.maxIndexCount} indices, ${handle.name} ${handle.object.uuid}`); } removeGeometry(handle, _del) { if (handle.__instanceIndex < 0) return; this._usedBuckets.splice(handle.__instanceIndex, 1); // deleteGeometry is currently not useable since there's no optimize method // https://github.com/mrdoob/three.js/issues/27985 // if (del) // this.inst.deleteGeometry(handle.__instanceIndex); // else this._batchedMesh.setVisibleAt(handle.__instanceIndex, false); this._availableBuckets.push({ index: handle.__instanceIndex, vertexCount: handle.reservedVertexCount, indexCount: handle.reservedIndexCount }); } } function getMeshInformation(geo) { let vertexCount = geo.attributes.position.count; let indexCount = geo.index ? geo.index.count : 0; const lodInfo = NEEDLE_progressive.getMeshLODInformation(geo); if (lodInfo) { const lod0 = lodInfo.lods[0]; let lod0Count = lod0.vertexCount; let lod0IndexCount = lod0.indexCount; // add some wiggle room: https://linear.app/needle/issue/NE-4505 const extra = Math.min(128, Math.ceil(lod0Count * .15)); lod0Count += extra; lod0IndexCount += 20; vertexCount = Math.max(vertexCount, lod0Count); indexCount = Math.max(indexCount, lod0IndexCount); } vertexCount = Math.ceil(vertexCount); indexCount = Math.ceil(indexCount); return { vertexCount, indexCount }; } function createDebugMaterial() { const mat = new MeshStandardMaterial({ color: new Color(Math.random(), Math.random(), Math.random()) }); mat.emissive = mat.color; mat.emissiveIntensity = .3; if (getParam("wireframe")) mat.wireframe = true; return mat; } //# sourceMappingURL=RendererInstancing.js.map