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