UNPKG

@babylonjs/core

Version:

Getting started? Play directly with the Babylon.js API using our [playground](https://playground.babylonjs.com/). It also contains a lot of samples to learn how to use it.

902 lines (901 loc) 64.1 kB
/** This file must only contain pure code and pure imports */ import { Matrix, Quaternion, Vector3 } from "../../Maths/math.vector.pure.js"; import { GetGaussianSplattingMaxPartCount } from "../../Materials/GaussianSplatting/gaussianSplattingMaterial.pure.js"; import { GaussianSplattingMeshBase, AllocateShBuffers } from "./gaussianSplattingMeshBase.pure.js"; import { RawTexture } from "../../Materials/Textures/rawTexture.js"; import { DecodeBase64ToBinary, EncodeArrayBufferToBase64 } from "../../Misc/stringTools.js"; import { Mesh } from "../mesh.pure.js"; import { GaussianSplattingPartProxyMesh } from "./gaussianSplattingPartProxyMesh.pure.js"; import { BoundingInfo } from "../../Culling/boundingInfo.js"; const _GaussianSplattingBytesPerSplat = 32; const _GaussianSplattingBytesPerShTexel = 16; /** * Run-Length Encoding (RLE) compression for serialization * Compressed Uint32Array can be parsed using {@link ParsePartIndices} * Some notes for devs: We do not expect Uint8Array larger than 4GB, * so it should be safe to use Uint32Array. * @param partIndices A view of partIndices from GaussianSplattingMesh * @returns A compressed Uint32Array of [count, value, ...] */ function CompressPartIndices(partIndices) { const runs = []; const length = partIndices.length; let i = 0; while (i < length) { const value = partIndices[i]; let count = 1; while (i + count < length && partIndices[i + count] === value) { count++; } runs.push(count, value); i += count; } return new Uint32Array(runs); } /** * Parse partIndices compressed by {@link CompressPartIndices} to runtime array * @param compressed The compressed partIndices of [count, value, ...] * @returns runtime Uint8Array for GaussianSplattingMesh */ function ParsePartIndices(compressed) { let totalCount = 0; const length = compressed.length; for (let i = 0; i < length; i += 2) { totalCount += compressed[i]; } const partIndices = new Uint8Array(totalCount); let offset = 0; for (let i = 0; i < length; i += 2) { const count = compressed[i]; const value = compressed[i + 1]; partIndices.fill(value, offset, offset + count); offset += count; } return partIndices; } /** * Class used to render a Gaussian Splatting mesh. Supports both single-cloud and compound * (multi-part) rendering. In compound mode, multiple Gaussian Splatting source meshes are * merged into one draw call while retaining per-part world-matrix control via * addPart/addParts and removePart. */ export class GaussianSplattingMesh extends GaussianSplattingMeshBase { /** Gets the part indices texture used for compound rendering */ get partIndicesTexture() { return this._partIndicesTexture; } /** * Creates a new GaussianSplattingMesh * @param name the name of the mesh * @param url optional URL to load a Gaussian Splatting file from * @param scene the hosting scene * @param keepInRam whether to keep the raw splat data in RAM after uploading to GPU * @param needsRotationScaleTextures generate rotation and scale matrix textures required for voxel-based IBL shadows */ constructor(name, url = null, scene = null, keepInRam = false, needsRotationScaleTextures = false) { super(name, url, scene, keepInRam); /** * Proxy meshes indexed by part index. Maintained in sync with _partMatrices. */ this._partProxies = []; /** Part 0 local-space AABB when owned directly (not proxied). Set on first addPart, cleared on dispose/reset. */ this._part0LocalMin = null; this._part0LocalMax = null; /** * World matrices for each part, indexed by part index. */ this._partMatrices = []; /** When true, suppresses the sort trigger inside setWorldMatrixForPart during batch rebuilds. */ this._rebuilding = false; /** * Visibility values for each part (0.0 to 1.0), indexed by part index. */ this._partVisibility = []; this._partIndicesTexture = null; this._partIndices = null; // Ensure _splatsData is retained once compound mode is entered — addPart/addParts need // the source data for full-texture rebuilds. Set after super() so it is visible to // _updateData when the async load completes. this._alwaysRetainSplatsData = true; this._needsRotationScaleTextures = needsRotationScaleTextures; } /** * Returns the class name * @returns "GaussianSplattingMesh" */ getClassName() { return "GaussianSplattingMesh"; } /** * Is this node ready to be used/rendered. * Force-syncs every part proxy's world matrix into `_partMatrices` BEFORE delegating to * the base readiness check. This guarantees that any pending proxy transform changes * (for example a user-set `proxy.position`) are reflected in the next sort post, so the * base `isReady` will only return true once `sortAppliedId === sortRequestId` for that * up-to-date state. Without this, the proxy's `onAfterWorldMatrixUpdateObservable` would * fire during the first render and queue a fresh sort AFTER readiness was reported, * leaving the rendered frame with stale splat order on `renderCount=1` runs. * @param completeCheck defines if a complete check (including materials and lights) has to be done (false by default) * @returns true when ready */ isReady(completeCheck = false) { for (const proxy of this._partProxies) { if (proxy) { proxy.computeWorldMatrix(true); } } return super.isReady(completeCheck); } /** * Recomputes compound local-space bounds from part 0's stored AABB (if unproxied) plus all * proxy world AABBs inverse-transformed to compound-local space. All 8 corners of each proxy * AABB are transformed so the result is correct under non-identity compound rotation/scale. */ _updateBoundingInfoFromProxies() { const compoundWorld = this.getWorldMatrix(); const invCompoundWorld = Matrix.Invert(compoundWorld); const localMin = this._part0LocalMin ? this._part0LocalMin.clone() : new Vector3(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE); const localMax = this._part0LocalMax ? this._part0LocalMax.clone() : new Vector3(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE); const corner = new Vector3(); for (const proxy of this._partProxies) { if (!proxy) { continue; } // Proxies have no geometry — getHierarchyBoundingVectors returns sentinels. Use boundingBox directly. proxy.computeWorldMatrix(false); const bb = proxy.getBoundingInfo().boundingBox; const wMin = bb.minimumWorld; const wMax = bb.maximumWorld; for (let b = 0; b < 8; b++) { corner.set(b & 1 ? wMax.x : wMin.x, b & 2 ? wMax.y : wMin.y, b & 4 ? wMax.z : wMin.z); Vector3.TransformCoordinatesToRef(corner, invCompoundWorld, corner); localMin.minimizeInPlace(corner); localMax.maximizeInPlace(corner); } } if (localMin.x <= localMax.x) { // Direct access avoids getBoundingInfo() → _updateBoundingInfo() recursion. if (this._boundingInfo) { this._boundingInfo.reConstruct(localMin, localMax, compoundWorld); } else { this._boundingInfo = new BoundingInfo(localMin, localMax, compoundWorld); } this._cachedBoundingMin = localMin.clone(); this._cachedBoundingMax = localMax.clone(); } } /** * Override for compound meshes: recomputes bounds from proxy world extents instead of * local bounds × world matrix, which is wrong for proxied parts with independent transforms. * @returns this mesh */ _updateBoundingInfo() { if (this.isCompound) { this._updateBoundingInfoFromProxies(); this._updateSubMeshesBoundingInfo(this.worldMatrixFromCache); return this; } return super._updateBoundingInfo(); } /** * Replaces the base hierarchy bounds computation for compound meshes: computes world bounds * from scratch by iterating part 0's local AABB and all proxy meshes, rather than delegating * to the base _children traversal which never reaches proxies (they are not parented to the * compound). Visibility per-part is respected; invisible parts are excluded. * @param includeDescendants when true, includes descendants (default: true) * @param predicate optional filter predicate * @returns world-space min/max of the hierarchy bounding box */ getHierarchyBoundingVectors(includeDescendants = true, predicate = null) { if (!this.isCompound) { return super.getHierarchyBoundingVectors(includeDescendants, predicate); } // For compound meshes, compute visible-only world bounds from scratch so that // invisible parts don't inflate the result (e.g. for voxelization scene bounds). const min = new Vector3(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE); const max = new Vector3(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE); // Unproxied part 0: the compound mesh owns this geometry directly (no proxy node). // Transform its local AABB to world space if visible. if (this._part0LocalMin && (this._partVisibility[0] ?? 1.0) > 0) { const wm = this.getWorldMatrix(); const lMin = this._part0LocalMin; const lMax = this._part0LocalMax; const corner = new Vector3(); for (let b = 0; b < 8; b++) { corner.set(b & 1 ? lMax.x : lMin.x, b & 2 ? lMax.y : lMin.y, b & 4 ? lMax.z : lMin.z); Vector3.TransformCoordinatesToRef(corner, wm, corner); min.minimizeInPlace(corner); max.maximizeInPlace(corner); } } for (let i = 0; i < this._partProxies.length; i++) { const proxy = this._partProxies[i]; if (!proxy || (this._partVisibility[i] ?? 1.0) === 0) { continue; } proxy.computeWorldMatrix(false); const bb = proxy.getBoundingInfo().boundingBox; min.minimizeInPlace(bb.minimumWorld); max.maximizeInPlace(bb.maximumWorld); } return { min, max }; } /** * Disposes proxy meshes and clears part data in addition to the base class GPU resources. * @param doNotRecurse Set to true to not recurse into each children */ dispose(doNotRecurse) { for (const proxy of this._partProxies) { proxy.dispose(); } this._partIndicesTexture?.dispose(); this._partProxies = []; this._partMatrices = []; this._partVisibility = []; this._partIndicesTexture = null; this._part0LocalMin = null; this._part0LocalMax = null; super.dispose(doNotRecurse); } // --------------------------------------------------------------------------- // Worker and material hooks // --------------------------------------------------------------------------- /** * Posts the initial per-part data to the sort worker after it has been created. * Sends the current part matrices and group index array so the worker can correctly * weight depth values per part. * @param worker the newly created sort worker */ _onWorkerCreated(worker) { worker.postMessage({ partMatrices: this._partMatrices.map((matrix) => new Float32Array(matrix.m)) }); worker.postMessage({ partIndices: this._partIndices ? new Uint8Array(this._partIndices) : null }); } /** * Stores the raw part index array, padded to texture length, so the worker and GPU texture * creation step have access to it. * @param partIndices - the raw part indices array received during a data load * @param textureLength - the padded texture length to allocate into */ _onIndexDataReceived(partIndices, textureLength) { this._partIndices = new Uint8Array(textureLength); this._partIndices.set(partIndices); } /** * Returns `true` when at least one part has been added to this compound mesh. * Returns `false` before any parts are added, so the mesh renders in normal * (non-compound) mode until the first addPart/addParts call. This matches the * old base-class behavior of `this._partMatrices.length > 0` and avoids * binding unset partWorld uniforms (which would cause division-by-zero in the * Gaussian projection Jacobian and produce huge distorted splats). * @internal */ get isCompound() { return this._partMatrices.length > 0; } /** * During a removePart rebuild, keep the existing sort worker alive rather than * tearing it down and spinning up a new one. This avoids startup latency and the * transient state window where a stale sort could fire against an incomplete * partMatrices array. * Outside of a rebuild the base-class behaviour is used unchanged. */ _instantiateWorker() { if (this._rebuilding && this._worker) { // Worker already exists and is kept alive; just resize the splat-index buffer. this._updateSplatIndexBuffer(this._vertexCount); return; } super._instantiateWorker(); } /** * Ensures the part-index GPU texture exists at the start of an incremental update. * Called before the sub-texture upload so the correct texture is available for the first batch. * @param textureSize - current texture dimensions */ _onIncrementalUpdateStart(textureSize) { this._ensurePartIndicesTexture(textureSize, this._partIndices ?? undefined); } /** * Posts positions (via super) and then additionally posts the current part-index array * to the sort worker so it can associate each splat with its part. */ _notifyWorkerNewData() { super._notifyWorkerNewData(); if (this._worker) { this._worker.postMessage({ partIndices: this._partIndices ?? null }); } } /** * Binds all compound-specific shader uniforms: the group index texture, per-part world * matrices, and per-part visibility values. * @param effect the shader effect that is being bound * @internal */ bindExtraEffectUniforms(effect) { if (!this._partIndicesTexture) { return; } effect.setTexture("partIndicesTexture", this._partIndicesTexture); const partWorldData = new Float32Array(this.partCount * 16); for (let i = 0; i < this.partCount; i++) { this._partMatrices[i].toArray(partWorldData, i * 16); } effect.setMatrices("partWorld", partWorldData); const partVisibilityData = []; for (let i = 0; i < this.partCount; i++) { partVisibilityData.push(this._partVisibility[i] ?? 1.0); } effect.setArray("partVisibility", partVisibilityData); } // --------------------------------------------------------------------------- // Part matrix / visibility management // --------------------------------------------------------------------------- /** * Gets the number of parts in the compound. */ get partCount() { return this._partMatrices.length; } /** * Gets the part visibility array. */ get partVisibility() { return this._partVisibility; } /** * Sets the world matrix for a specific part of the compound. * This will trigger a re-sort of the mesh. * The `_partMatrices` array is automatically extended when `partIndex >= partCount`. * @param partIndex index of the part * @param worldMatrix the world matrix to set */ setWorldMatrixForPart(partIndex, worldMatrix) { if (this._partMatrices.length <= partIndex) { this.computeWorldMatrix(true); const defaultMatrix = this.getWorldMatrix(); while (this._partMatrices.length <= partIndex) { this._partMatrices.push(defaultMatrix.clone()); this._partVisibility.push(1.0); } } // Skip the post / sort if the matrix is unchanged. Babylon recomputes the proxy mesh's world matrix every frame // and fires onAfterWorldMatrixUpdateObservable, so without this guard a stable scene would queue a forced sort // every frame and `isReady()` would never settle (sortRequestId would keep advancing past sortAppliedId). if (this._partMatrices[partIndex].equals(worldMatrix)) { return; } this._partMatrices[partIndex].copyFrom(worldMatrix); // During a batch rebuild suppress intermediate posts — the final correct set is posted // once the full rebuild completes (at the end of removePart). if (!this._rebuilding) { if (this._worker) { this._worker.postMessage({ partMatrices: this._partMatrices.map((matrix) => new Float32Array(matrix.m)) }); } this._postToWorker(true); } } /** * Gets the world matrix for a specific part of the compound. * @param partIndex index of the part, that must be between 0 and partCount - 1 * @returns the world matrix for the part, or the current world matrix of the mesh if the part is not found */ getWorldMatrixForPart(partIndex) { return this._partMatrices[partIndex] ?? this.getWorldMatrix(); } /** * Gets the visibility for a specific part of the compound. * @param partIndex index of the part, that must be between 0 and partCount - 1 * @returns the visibility value (0.0 to 1.0) for the part */ getPartVisibility(partIndex) { return this._partVisibility[partIndex] ?? 1.0; } /** * Sets the visibility for a specific part of the compound. * @param partIndex index of the part, that must be between 0 and partCount - 1 * @param value the visibility value (0.0 to 1.0) to set */ setPartVisibility(partIndex, value) { this._partVisibility[partIndex] = Math.max(0.0, Math.min(1.0, value)); } _copyTextures(source) { super._copyTextures(source); this._partIndicesTexture = source._partIndicesTexture?.clone(); } _onUpdateTextures(textureSize) { const createTextureFromDataU8 = (data, width, height, format) => { return new RawTexture(data, width, height, format, this._scene, false, false, 2, 0); }; // Keep the part indices texture in sync with _partIndices whenever textures are rebuilt. // The old "only create if absent" logic left the texture stale after a second addPart/addParts // call that doesn't change the texture dimensions: all new splats kept reading partIndex=0 // (the first part), causing wrong positions, broken GPU picking, and shared movement. if (this._partIndices) { const buffer = new Uint8Array(this._partIndices); if (!this._partIndicesTexture) { this._partIndicesTexture = createTextureFromDataU8(buffer, textureSize.x, textureSize.y, 6); this._partIndicesTexture.wrapU = 0; this._partIndicesTexture.wrapV = 0; } else { const existingSize = this._partIndicesTexture.getSize(); if (existingSize.width !== textureSize.x || existingSize.height !== textureSize.y) { // Dimensions changed — dispose and recreate at the new size. this._partIndicesTexture.dispose(); this._partIndicesTexture = createTextureFromDataU8(buffer, textureSize.x, textureSize.y, 6); this._partIndicesTexture.wrapU = 0; this._partIndicesTexture.wrapV = 0; } else { // Same size — update data in-place (e.g. second addParts fitting in existing dims). this._updateTextureFromData(this._partIndicesTexture, buffer, textureSize.x, 0, textureSize.y); } } } } _updateSubTextures(splatPositions, covA, covB, colorArray, lineStart, lineCount, sh, partIndices) { super._updateSubTextures(splatPositions, covA, covB, colorArray, lineStart, lineCount, sh); if (partIndices && this._partIndicesTexture) { const textureSize = this._getTextureSize(this._vertexCount); const texelStart = lineStart * textureSize.x; const texelCount = lineCount * textureSize.x; const partIndicesView = new Uint8Array(partIndices.buffer, texelStart, texelCount); this._updateTextureFromData(this._partIndicesTexture, partIndicesView, textureSize.x, lineStart, lineCount); if (this._worker) { this._worker.postMessage({ partIndices: partIndices }); } } } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- /** * Creates the part indices GPU texture the first time an incremental addPart introduces * compound data. Has no effect if the texture already exists or no partIndices are provided. * @param textureSize - Current texture dimensions * @param partIndices - Part index data; if undefined the method is a no-op */ _ensurePartIndicesTexture(textureSize, partIndices) { if (!partIndices || this._partIndicesTexture) { return; } const buffer = new Uint8Array(this._partIndices); this._partIndicesTexture = new RawTexture(buffer, textureSize.x, textureSize.y, 6, this._scene, false, false, 2, 0); this._partIndicesTexture.wrapU = 0; this._partIndicesTexture.wrapV = 0; if (this._worker) { this._worker.postMessage({ partIndices: partIndices ?? null }); } } _appendPartSourceToArrays(source, dstOffset, covA, covB, colorArray, sh, minimum, maximum) { this._appendSourceToArrays(source, dstOffset, covA, covB, colorArray, sh, minimum, maximum); } _createRetainedPartSource(proxy) { if (!this._splatsData || (this._shDegree > 0 && !this._shData)) { return null; } const splatByteOffset = proxy._splatsDataOffset * _GaussianSplattingBytesPerSplat; const splatByteLength = proxy._vertexCount * _GaussianSplattingBytesPerSplat; const shByteOffset = proxy._shDataOffset * _GaussianSplattingBytesPerShTexel; const shByteLength = proxy._vertexCount * _GaussianSplattingBytesPerShTexel; const splatBytes = GaussianSplattingMeshBase._GetSplatDataBytes(this._splatsData); return { name: proxy.name, _vertexCount: proxy._vertexCount, _splatsData: splatBytes.subarray(splatByteOffset, splatByteOffset + splatByteLength), _shData: this._shData?.map((texture) => texture.subarray(shByteOffset, shByteOffset + shByteLength)) ?? null, _shDegree: this._shData ? this._shDegree : 0, isCompound: false, getWorldMatrix: () => proxy.getWorldMatrix(), getBoundingInfo: () => proxy.getBoundingInfo(), dispose: () => { }, }; } _retainMergedPartData(existingVertexCount, totalCount, others, shDegree) { if (!this._keepInRam && !this._alwaysRetainSplatsData) { this._splatsData = null; this._shData = null; return; } const mergedSplatsData = new Uint8Array(totalCount * _GaussianSplattingBytesPerSplat); let splatByteOffset = 0; if (this._splatsData && existingVertexCount > 0) { mergedSplatsData.set(GaussianSplattingMeshBase._GetSplatDataBytes(this._splatsData).subarray(0, existingVertexCount * _GaussianSplattingBytesPerSplat), splatByteOffset); splatByteOffset += existingVertexCount * _GaussianSplattingBytesPerSplat; } for (const other of others) { if (!other._splatsData) { continue; } const splatByteLength = other._vertexCount * _GaussianSplattingBytesPerSplat; mergedSplatsData.set(GaussianSplattingMeshBase._GetSplatDataBytes(other._splatsData).subarray(0, splatByteLength), splatByteOffset); splatByteOffset += splatByteLength; } this._splatsData = mergedSplatsData.buffer; if (shDegree <= 0) { this._shData = null; return; } // Each SH texture holds one texel per splat; each texel is _GaussianSplattingBytesPerShTexel // bytes with one byte per scalar, so it carries that many scalars. Degree d has // ((d+1)^2 - 1) higher-order coefficients × 3 RGB = total scalars per splat; divide by texel capacity. const shTextureCount = Math.ceil((((shDegree + 1) * (shDegree + 1) - 1) * 3) / _GaussianSplattingBytesPerShTexel); const mergedShData = AllocateShBuffers(shTextureCount, totalCount * _GaussianSplattingBytesPerShTexel); let shByteOffset = 0; if (this._shData && existingVertexCount > 0) { const existingShByteLength = existingVertexCount * _GaussianSplattingBytesPerShTexel; for (let textureIndex = 0; textureIndex < mergedShData.length; textureIndex++) { if (textureIndex < this._shData.length) { mergedShData[textureIndex].set(this._shData[textureIndex].subarray(0, existingShByteLength), shByteOffset); } } shByteOffset += existingShByteLength; } for (const other of others) { const otherShByteLength = other._vertexCount * _GaussianSplattingBytesPerShTexel; if (other._shData) { for (let textureIndex = 0; textureIndex < mergedShData.length; textureIndex++) { if (textureIndex < other._shData.length) { mergedShData[textureIndex].set(other._shData[textureIndex].subarray(0, otherShByteLength), shByteOffset); } } } shByteOffset += otherShByteLength; } this._shData = mergedShData; } /** * Core implementation for adding one or more source parts as new * parts. Writes directly into texture-sized CPU arrays, updates the retained merged source * buffers, and uploads in one pass. * * @param others - Source meshes to append (must each be non-compound and fully loaded) * @param disposeOthers - Dispose source meshes after appending * @returns Proxy meshes and their assigned part indices */ _addPartsInternal(others, disposeOthers) { if (others.length === 0) { return { proxyMeshes: [], assignedPartIndices: [] }; } // Validate for (const other of others) { if (!other._splatsData) { throw new Error(`To call addPart()/addParts(), each source mesh must be fully loaded`); } if (other.isCompound) { throw new Error(`To call addPart()/addParts(), each source mesh must not be a compound`); } } const splatCountA = this._vertexCount; const totalOtherCount = others.reduce((s, o) => s + o._vertexCount, 0); const totalCount = splatCountA + totalOtherCount; const textureSize = this._getTextureSize(totalCount); const textureLength = textureSize.x * textureSize.y; const covBSItemSize = this._useRGBACovariants ? 4 : 2; // Allocate destination arrays for the full new texture const covA = new Uint16Array(textureLength * 4); const covB = new Uint16Array(covBSItemSize * textureLength); const colorArray = new Uint8Array(textureLength * 4); // Determine merged SH degree. // hasSH is true when the merged result will carry SH: // - Existing compound already has SH (_shDegree>0): preserve it even if new parts // have no SH — their texel region is pre-filled with 128 (neutral) by AllocateShBuffers. // - At least one new part carries SH: enable SH for the whole compound; existing // parts that had no SH also get neutral fill. // Deliberately excludes the case where the existing compound has no SH and no new part // has SH either (shDegreeNew stays 0, no SH textures allocated). const hasSH = this._shDegree > 0 || others.some((o) => o._shData !== null); const shDegreeNew = hasSH ? Math.max(this._shDegree, ...others.map((o) => o._shDegree)) : 0; let sh = undefined; if (hasSH && shDegreeNew > 0) { // Each SH texture holds one texel per splat; each texel is _GaussianSplattingBytesPerShTexel // bytes with one byte per scalar, so it carries that many scalars. Degree d has // ((d+1)^2 - 1) higher-order coefficients × 3 RGB = total scalars per splat; divide by texel capacity. const shTextureCount = Math.ceil((((shDegreeNew + 1) * (shDegreeNew + 1) - 1) * 3) / _GaussianSplattingBytesPerShTexel); sh = AllocateShBuffers(shTextureCount, textureLength * _GaussianSplattingBytesPerShTexel); } // --- Incremental path: can we reuse the already-committed GPU region? --- const incremental = this._canReuseCachedData(splatCountA, totalCount); const firstNewLine = incremental ? Math.floor(splatCountA / textureSize.x) : 0; const minimum = incremental ? this._cachedBoundingMin.clone() : new Vector3(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE); const maximum = incremental ? this._cachedBoundingMax.clone() : new Vector3(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE); // Preserve existing processed positions in the new array const oldPositions = this._splatPositions; this._splatPositions = new Float32Array(4 * textureLength); if (incremental && oldPositions) { this._splatPositions.set(oldPositions.subarray(0, splatCountA * 4)); } // --- Build part indices --- let nextPartIndex = this.partCount; let partIndicesA = this._partIndices; if (!partIndicesA) { // First addPart on a plain mesh: assign its splats to part 0 partIndicesA = new Uint8Array(splatCountA); nextPartIndex = splatCountA > 0 ? 1 : 0; } this._partIndices = new Uint8Array(textureLength); this._partIndices.set(partIndicesA.subarray(0, splatCountA)); const assignedPartIndices = []; const assignedSplatsDataOffsets = []; let dstOffset = splatCountA; const maxPartCount = GetGaussianSplattingMaxPartCount(this._scene.getEngine()); for (const other of others) { if (nextPartIndex >= maxPartCount) { throw new Error(`Cannot add part, as the maximum part count (${maxPartCount}) has been reached`); } const newPartIndex = nextPartIndex++; assignedPartIndices.push(newPartIndex); assignedSplatsDataOffsets.push(dstOffset); this._partIndices.fill(newPartIndex, dstOffset, dstOffset + other._vertexCount); dstOffset += other._vertexCount; } // --- Process source data --- if (!incremental) { // Full rebuild path — only reached when the GPU texture must be reallocated // (either the texture height needs to grow to fit the new total, or this is // the very first addPart onto a mesh with no GPU textures yet). In the common // case where the texture height is unchanged, `incremental` is true and this // entire block is skipped. The `splatCountA > 0` guard avoids redundant work // on the first-ever addPart when the compound mesh starts empty. if (splatCountA > 0) { if (this._partProxies.length > 0) { // Already compound: rebuild every existing part from its stored source data. // // DESIGN NOTE: The intended use of GaussianSplattingMesh / GaussianSplattingCompoundMesh // in compound mode is to start EMPTY and compose parts exclusively via addPart/addParts. // In a future major version this will be the only supported path and the "own data" // legacy branch below will be removed. // // Until then, two layouts are possible: // A) LEGACY — compound loaded its own splat data (via URL or updateData) before // any addPart call. _partProxies[0] is undefined; the mesh's own splat data // is treated as an implicit "part 0" in this._splatsData. Proxied parts occupy // indices 1+. This layout will be deprecated in the next major version. // B) PREFERRED — compound started empty; first addPart assigned partIndex=0. // _partProxies[0] is set; this._splatsData is null; all parts are proxied. let rebuildOffset = 0; // Rebuild the compound's legacy "own" data at part 0 (scenario A only). // Skipped in the preferred empty-composer path (scenario B). if (!this._partProxies[0] && this._splatsData) { const proxyVertexCount = this._partProxies.reduce((sum, proxy) => sum + (proxy ? proxy._vertexCount : 0), 0); const part0Count = splatCountA - proxyVertexCount; if (part0Count > 0) { const uBufA = GaussianSplattingMeshBase._GetSplatDataBytes(this._splatsData); const fBufA = GaussianSplattingMeshBase._GetSplatDataFloats(this._splatsData); for (let i = 0; i < part0Count; i++) { this._makeSplat(i, fBufA, uBufA, covA, covB, colorArray, minimum, maximum, false); } if (sh && this._shData) { for (let texIdx = 0; texIdx < sh.length; texIdx++) { if (texIdx < this._shData.length) { sh[texIdx].set(this._shData[texIdx].subarray(0, part0Count * _GaussianSplattingBytesPerShTexel), 0); } } } rebuildOffset += part0Count; } } // Rebuild all proxied parts. Loop from index 0 because in the preferred // scenario B, part 0 is itself a proxied part with no implicit "own" data. for (let partIndex = 0; partIndex < this._partProxies.length; partIndex++) { const proxy = this._partProxies[partIndex]; if (!proxy) { continue; } const source = this._createRetainedPartSource(proxy); if (!source) { throw new Error(`Cannot rebuild compound part "${proxy.name}": the retained compound source data is not available.`); } this._appendPartSourceToArrays(source, rebuildOffset, covA, covB, colorArray, sh, minimum, maximum); rebuildOffset += source._vertexCount; } } else { // No proxies yet: this is the very first addPart call on a mesh that loaded // its own splat data (scenario A legacy path). Re-process that own data so // it occupies the start of the new texture before the incoming part is appended. // In the preferred scenario B (empty composer) splatCountA is 0 and this // entire branch is skipped by the outer `if (splatCountA > 0)` guard. if (this._splatsData) { const uBufA = GaussianSplattingMeshBase._GetSplatDataBytes(this._splatsData); const fBufA = GaussianSplattingMeshBase._GetSplatDataFloats(this._splatsData); for (let i = 0; i < splatCountA; i++) { this._makeSplat(i, fBufA, uBufA, covA, covB, colorArray, minimum, maximum, false); } if (sh && this._shData) { for (let texIdx = 0; texIdx < sh.length; texIdx++) { if (texIdx < this._shData.length) { sh[texIdx].set(this._shData[texIdx].subarray(0, splatCountA * _GaussianSplattingBytesPerShTexel), 0); } } } } } } } // Incremental path: rebuild the partial first row (indices firstNewTexel to splatCountA-1) // so _updateSubTextures does not upload stale zeros over those already-committed texels. // The base-class _updateData always re-processes from firstNewTexel for the same reason; // the compound path must do the same. // Boundary-row SH is restored after _retainMergedPartData (see below), where _shData is ready. if (incremental) { const firstNewTexel = firstNewLine * textureSize.x; if (firstNewTexel < splatCountA) { if (this._partProxies.length === 0) { // No proxies: the mesh loaded its own splat data and this is the first // addPart call (scenario A legacy path). Re-process the partial boundary // row so it is not clobbered by stale zeros during the sub-texture upload. if (this._splatsData) { const uBufA = GaussianSplattingMeshBase._GetSplatDataBytes(this._splatsData); const fBufA = GaussianSplattingMeshBase._GetSplatDataFloats(this._splatsData); for (let i = firstNewTexel; i < splatCountA; i++) { this._makeSplat(i, fBufA, uBufA, covA, covB, colorArray, minimum, maximum, false, i); } } } else { // Already compound: build a per-partIndex source lookup so each splat in the // partial boundary row can be re-processed from its original source buffer. // // Handles both layouts (see full-rebuild comment above): // A) LEGACY: _partProxies[0] absent → seed lookup[0] with this._splatsData // B) PREFERRED: _partProxies[0] present → all entries filled from proxies const proxyTotal = this._partProxies.reduce((s, p) => s + (p ? p._vertexCount : 0), 0); const part0Count = splatCountA - proxyTotal; // > 0 only in legacy scenario A const srcUBufs = new Array(this._partProxies.length).fill(null); const srcFBufs = new Array(this._partProxies.length).fill(null); const partStarts = new Array(this._partProxies.length).fill(0); // Legacy scenario A: part 0 is the mesh's own loaded data. if (!this._partProxies[0] && this._splatsData && part0Count > 0) { srcUBufs[0] = GaussianSplattingMeshBase._GetSplatDataBytes(this._splatsData); srcFBufs[0] = GaussianSplattingMeshBase._GetSplatDataFloats(this._splatsData); partStarts[0] = 0; } // All proxied parts — start from pi=0 to cover preferred scenario B. let cumOffset = part0Count; for (let pi = 0; pi < this._partProxies.length; pi++) { const proxy = this._partProxies[pi]; if (!proxy) { continue; } const source = this._createRetainedPartSource(proxy); if (!source || !source._splatsData) { throw new Error(`Cannot rebuild compound part "${proxy.name}": the retained compound source data is not available.`); } srcUBufs[pi] = GaussianSplattingMeshBase._GetSplatDataBytes(source._splatsData); srcFBufs[pi] = GaussianSplattingMeshBase._GetSplatDataFloats(source._splatsData); partStarts[pi] = cumOffset; cumOffset += source._vertexCount; } for (let splatIdx = firstNewTexel; splatIdx < splatCountA; splatIdx++) { const partIdx = this._partIndices ? this._partIndices[splatIdx] : 0; const uBuf = partIdx < srcUBufs.length ? srcUBufs[partIdx] : null; const fBuf = partIdx < srcFBufs.length ? srcFBufs[partIdx] : null; if (uBuf && fBuf) { this._makeSplat(splatIdx, fBuf, uBuf, covA, covB, colorArray, minimum, maximum, false, splatIdx - (partStarts[partIdx] ?? 0)); } } } } } // Append each new source dstOffset = splatCountA; for (const other of others) { this._appendPartSourceToArrays(other, dstOffset, covA, covB, colorArray, sh, minimum, maximum); dstOffset += other._vertexCount; } // Pad empty splats to texture boundary const paddedEnd = (totalCount + 15) & ~0xf; for (let i = totalCount; i < paddedEnd; i++) { this._makeEmptySplat(i, covA, covB, colorArray); } // --- Update vertex count / index buffer --- if (totalCount !== this._vertexCount) { this._updateSplatIndexBuffer(totalCount); } this._retainMergedPartData(splatCountA, totalCount, others, shDegreeNew); this._vertexCount = totalCount; this._shDegree = shDegreeNew; // Gate the sort worker for the duration of this operation. _updateTextures (below) may create the worker and fire an // immediate sort via _postToWorker. At that point partMatrices has not yet been updated for the incoming parts, so the // worker would compute depthCoeffs for fewer parts than partIndices references — crashing with // "Cannot read properties of undefined (reading '0')". // When called from removePart, _rebuilding is already true and _canPostToWorker is already false, so the gate is a // no-op — removePart handles the final post+sort. const needsWorkerGate = !this._rebuilding; if (needsWorkerGate) { this._canPostToWorker = false; this._rebuilding = true; } try { // --- Upload to GPU --- if (incremental) { // Create missing SH GPU textures: either the compound just gained SH for the first // time (_shTextures===null) or the degree increased (sh.length > _shTextures.length). // Use _shData when available (contains correct merged values for all rows); // fall back to sh[idx] (pre-filled with 128) when _shData is absent (keepInRam=false). // _updateSubTextures will re-upload from firstNewLine, which is redundant but harmless. if (sh && (!this._shTextures || sh.length > this._shTextures.length)) { if (!this._shTextures) { this._shTextures = []; } while (this._shTextures.length < sh.length) { const idx = this._shTextures.length; const shTexture = new RawTexture(null, textureSize.x, textureSize.y, 11, this._scene, false, false, 1, 7); shTexture.wrapU = 0; shTexture.wrapV = 0; this._shTextures.push(shTexture); const src = this._shData && idx < this._shData.length ? this._shData[idx] : sh[idx]; this._updateShTextureData(shTexture, src, textureSize.x, 0, textureSize.y); } } // Restore boundary-row SH: sh is freshly filled with 128, and _updateSubTextures // starts at firstNewLine — existing splats on that row need their values from _shData. if (sh && this._shData) { const firstNewTexel = firstNewLine * textureSize.x; if (firstNewTexel < splatCountA) { const byteStart = firstNewTexel * _GaussianSplattingBytesPerShTexel; const byteEnd = splatCountA * _GaussianSplattingBytesPerShTexel; for (let texIdx = 0; texIdx < sh.length; texIdx++) { if (texIdx < this._shData.length) { sh[texIdx].set(this._shData[texIdx].subarray(byteStart, byteEnd), byteStart); } } } } // Update the part-indices texture (handles both create and update-in-place). // _ensurePartIndicesTexture is a no-op when the texture already exists, so on the // second+ addPart the partIndices would be stale without this call. this._onUpdateTextures(textureSize); this._updateSubTextures(this._splatPositions, covA, covB, colorArray, firstNewLine, textureSize.y - firstNewLine, sh); } else { this._updateTextures(covA, covB, colorArray, sh); } this.setEnabled(true); this._notifyWorkerNewData(); // Bounding info is updated via _updateBoundingInfoFromProxies (called below, after proxy // world matrices are known), which needs part 0's local-space AABB as an input: // • For unproxied part 0 (legacy layout A: compound loaded its own splat data before // any addPart call, so no _partProxies[0]), capture the local-space AABB from the // compound mesh's existing _boundingInfo — set when the mesh loaded its own data via // URL/updateData — so _updateBoundingInfoFromProxies can include part 0's geometry. // • For proxied part 0, skip — its bounds are already on the proxy's getBoundingInfo() // and _updateBoundingInfoFromProxies picks it up there. // Guard splatCountA > 0 avoids reading a stale bounding box on a fresh empty mesh. // Guard !this._part0LocalMin ensures we only store once; subsequent addPart calls must // not overwrite it, because by then _boundingInfo reflects the full merged dataset. if (!this._partProxies[0] && splatCountA > 0 && !this._part0LocalMin) { this._part0LocalMin = this.getBoundingInfo().minimum.clone(); this._part0LocalMax = this.getBoundingInfo().maximum.clone(); } // --- Create proxy meshes --- const proxyMeshes = []; for (let i = 0; i < others.length; i++) { const other = others[i]; const newPartIndex = assignedPartIndices[i]; const partWorldMatrix = other.getWorldMatrix(); this.setWorldMatrixForPart(newPartIndex, partWorldMatrix); const proxyMesh = new GaussianSplattingPartProxyMesh(other.name, this.getScene(), this, newPartIndex, other.getBoundingInfo(), other._vertexCount, assignedSplatsDataOffsets[i], assignedSplatsDataOffsets[i]); if (disposeOthers) { other.dispose(); } const quaternion = new Quaternion(); partWorldMatrix.decompose(proxyMesh.scaling, quaternion, proxyMesh.position); proxyMesh.rotationQuaternion = quaternion; proxyMesh.computeWorldMatrix(true); this._partProxies[newPartIndex] = proxyMesh; proxyMeshes.push(proxyMesh); } // Update compound bounds now that all proxy world matrices are known. this._updateBoundingInfoFromProxies(); // Restore the rebuild gate and post the now-complete partMatrices in one message, then trigger a single sort pass. // This ensures the worker sees a consistent partMatrices array that matches the partIndices for every splat. if (needsWorkerGate) { this._rebuilding = false; if (this._worker) { this._worker.postMessage({ partMatrices: this._partMatrices.map((matrix) => new Float32Array(matrix.m)) }); } this._canPostToWorker = true; this._postToWorker