@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
JavaScript
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