@babylonjs/viewer
Version:
The Babylon Viewer aims to simplify a specific but common Babylon.js use case: loading, viewing, and interacting with a 3D model.
8,131 lines • 377 kB
JavaScript
import { cq as RegisterGaussianSplattingPartProxyMesh, cr as RegisterGaussianSplattingMesh, cs as RegisterCamera, aV as Mesh, i as Color3, b1 as Color4, c as ShaderMaterial, a7 as VertexBuffer, F as Material, aE as _MissingSideEffect, aW as VertexData, V as Vector3, y as Logger, ct as _CreationDataStorage, aH as RawTexture, C as Constants, cu as Scalar, br as AllocateShBuffers, aY as Tools, be as MultiRenderTarget, bc as Vector4, bq as GaussianSplattingMesh, cv as Plane, M as Matrix, aU as Camera, bl as BoundingInfo, aD as Quaternion, cw as Frustum, b3 as Vector2, T as TmpVectors, a as EngineStore, ca as RandomRange, p as BaseTexture, ci as IsWindowObjectExist, cx as runCoroutineAsync, cy as createYieldingScheduler, cz as SPLATFileLoaderMetadata, bn as RegisterSceneLoaderPlugin, cA as ThinEngine, w as InternalTexture, cB as GetExponentOfTwo } from './index-HyNDfLMI.esm.js';
import './thinInstanceMesh-HgvJSqOW.esm.js';
import './tools-Y0cM3vR-.esm.js';
import { I as InstancedMesh, A as AssetContainer } from './assetContainer-BhW2gcI4.esm.js';
import './buffer-DQKHeFx_.esm.js';
import './shaderMaterial-h03aowhV.esm.js';
import { R as Ray } from './ray.core-CFKXvGjI.esm.js';
import { S as StandardMaterial } from './standardMaterial.pure-B45jJn5x.esm.js';
import './prepass.defines-NGwPAvhm.esm.js';
import './material.detailMapConfiguration-C33hA3Hm.esm.js';
/**
* Re-exports pure implementation and applies runtime side effects.
* Import gaussianSplattingPartProxyMesh.pure for tree-shakeable, side-effect-free usage.
*/
RegisterGaussianSplattingPartProxyMesh();
/**
* Re-exports pure implementation and applies runtime side effects.
* Import gaussianSplattingMesh.pure for tree-shakeable, side-effect-free usage.
*/
RegisterGaussianSplattingMesh();
/**
* Re-exports pure implementation and applies runtime side effects.
* Import camera.pure for tree-shakeable, side-effect-free usage.
*/
RegisterCamera();
/** This file must only contain pure code and pure imports */
/**
* Line mesh
* @see https://doc.babylonjs.com/features/featuresDeepDive/mesh/creation/param
*/
class LinesMesh extends Mesh {
_isShaderMaterial(shader) {
if (!shader) {
return false;
}
return shader.getClassName() === "ShaderMaterial";
}
/**
* Creates a new LinesMesh
* @param name defines the name
* @param scene defines the hosting scene
* @param parent defines the parent mesh if any
* @param source defines the optional source LinesMesh used to clone data from
* @param doNotCloneChildren When cloning, skip cloning child meshes of source, default False.
* When false, achieved by calling a clone(), also passing False.
* This will make creation of children, recursive.
* @param useVertexColor defines if this LinesMesh supports vertex color
* @param useVertexAlpha defines if this LinesMesh supports vertex alpha
* @param material material to use to draw the line. If not provided, will create a new one
*/
constructor(name, scene = null, parent = null, source = null, doNotCloneChildren,
/**
* If vertex color should be applied to the mesh
*/
useVertexColor,
/**
* If vertex alpha should be applied to the mesh
*/
useVertexAlpha, material) {
super(name, scene, parent, source, doNotCloneChildren);
this.useVertexColor = useVertexColor;
this.useVertexAlpha = useVertexAlpha;
/**
* Color of the line (Default: White)
*/
this.color = new Color3(1, 1, 1);
/**
* Alpha of the line (Default: 1)
*/
this.alpha = 1;
/** Shader language used by the material */
this._shaderLanguage = 0 /* ShaderLanguage.GLSL */;
this._ownsMaterial = false;
if (source) {
this.color = source.color.clone();
this.alpha = source.alpha;
this.useVertexColor = source.useVertexColor;
this.useVertexAlpha = source.useVertexAlpha;
}
this.intersectionThreshold = 0.1;
const defines = [];
const options = {
attributes: [VertexBuffer.PositionKind],
uniforms: ["world", "viewProjection"],
needAlphaBlending: true,
defines: defines,
useClipPlane: null,
shaderLanguage: 0 /* ShaderLanguage.GLSL */,
};
if (!this.useVertexAlpha) {
options.needAlphaBlending = false;
}
else {
options.defines.push("#define VERTEXALPHA");
}
if (!this.useVertexColor) {
options.uniforms.push("color");
this._color4 = new Color4();
}
else {
options.defines.push("#define VERTEXCOLOR");
options.attributes.push(VertexBuffer.ColorKind);
}
if (material) {
this.material = material;
}
else {
const engine = this.getScene().getEngine();
if (engine.isWebGPU && !LinesMesh.ForceGLSL) {
this._shaderLanguage = 1 /* ShaderLanguage.WGSL */;
}
options.shaderLanguage = this._shaderLanguage;
options.extraInitializationsAsync = async () => {
if (this._shaderLanguage === 1 /* ShaderLanguage.WGSL */) {
await Promise.all([import('./color.vertex-DVwEZTDT.esm.js'), import('./color.fragment-ClokCmr8.esm.js')]);
}
else {
await Promise.all([import('./color.vertex-ROl260Tm.esm.js'), import('./color.fragment-CTpr4lPV.esm.js')]);
}
};
const material = new ShaderMaterial("colorShader", this.getScene(), "color", options, false);
material.doNotSerialize = true;
this._ownsMaterial = true;
this._setInternalMaterial(material);
}
}
/**
* @returns the string "LineMesh"
*/
getClassName() {
return "LinesMesh";
}
/**
* @internal
*/
get material() {
return this._internalAbstractMeshDataInfo._material;
}
/**
* @internal
*/
set material(value) {
const currentMaterial = this.material;
if (currentMaterial === value) {
return;
}
const shouldDispose = currentMaterial && this._ownsMaterial;
this._ownsMaterial = false;
this._setInternalMaterial(value);
if (shouldDispose) {
currentMaterial?.dispose();
}
}
_setInternalMaterial(material) {
this._setMaterial(material);
if (this.material) {
this.material.fillMode = Material.LineListDrawMode;
this.material.disableLighting = true;
}
}
/**
* @internal
*/
get checkCollisions() {
return false;
}
set checkCollisions(value) {
// Just ignore it
}
/**
* @internal
*/
_bind(_subMesh, colorEffect) {
if (!this._geometry) {
return this;
}
// VBOs
const indexToBind = this.isUnIndexed ? null : this._geometry.getIndexBuffer();
if (!this._userInstancedBuffersStorage || this.hasThinInstances) {
this._geometry._bind(colorEffect, indexToBind);
}
else {
this._geometry._bind(colorEffect, indexToBind, this._userInstancedBuffersStorage.vertexBuffers, this._userInstancedBuffersStorage.vertexArrayObjects);
}
// Color
if (!this.useVertexColor && this._isShaderMaterial(this.material)) {
const { r, g, b } = this.color;
this._color4.set(r, g, b, this.alpha);
this.material.setColor4("color", this._color4);
}
return this;
}
/**
* @internal
*/
_draw(subMesh, fillMode, instancesCount) {
if (!this._geometry || !this._geometry.getVertexBuffers() || (!this._unIndexed && !this._geometry.getIndexBuffer())) {
return this;
}
const engine = this.getScene().getEngine();
// Draw order
if (this._unIndexed) {
engine.drawArraysType(Material.LineListDrawMode, subMesh.verticesStart, subMesh.verticesCount, instancesCount);
}
else {
engine.drawElementsType(Material.LineListDrawMode, subMesh.indexStart, subMesh.indexCount, instancesCount);
}
return this;
}
/**
* Disposes of the line mesh (this disposes of the automatically created material if not instructed otherwise).
* @param doNotRecurse If children should be disposed
* @param disposeMaterialAndTextures This parameter is used to force disposing the material in case it is not the default one
* @param doNotDisposeMaterial If the material should not be disposed (default: false, meaning the material might be disposed)
*/
dispose(doNotRecurse, disposeMaterialAndTextures = false, doNotDisposeMaterial) {
if (!doNotDisposeMaterial) {
if (this._ownsMaterial) {
this.material?.dispose(false, false, true);
}
else if (disposeMaterialAndTextures) {
this.material?.dispose(false, false, true);
}
}
super.dispose(doNotRecurse);
}
/**
* Returns a new LineMesh object cloned from the current one.
* @param name defines the cloned mesh name
* @param newParent defines the new mesh parent
* @param doNotCloneChildren if set to true, none of the mesh children are cloned (false by default)
* @returns the new mesh
*/
clone(name, newParent = null, doNotCloneChildren) {
if (newParent && newParent._addToSceneRootNodes === undefined) {
const createOptions = newParent;
createOptions.source = this;
return new LinesMesh(name, this.getScene(), createOptions.parent, createOptions.source, createOptions.doNotCloneChildren);
}
return new LinesMesh(name, this.getScene(), newParent, this, doNotCloneChildren);
}
/**
* Creates a new InstancedLinesMesh object from the mesh model.
* @see https://doc.babylonjs.com/features/featuresDeepDive/mesh/copies/instances
* @param name defines the name of the new instance
* @returns a new InstancedLinesMesh
*/
createInstance(name) {
const instance = new InstancedLinesMesh(name, this);
if (this.instancedBuffers) {
instance.instancedBuffers = {};
for (const key in this.instancedBuffers) {
instance.instancedBuffers[key] = this.instancedBuffers[key];
}
}
return instance;
}
/**
* Serializes this ground mesh
* @param serializationObject object to write serialization to
*/
serialize(serializationObject) {
super.serialize(serializationObject);
serializationObject.color = this.color.asArray();
serializationObject.alpha = this.alpha;
}
/**
* Parses a serialized ground mesh
* @param parsedMesh the serialized mesh
* @param scene the scene to create the ground mesh in
* @returns the created ground mesh
*/
static Parse(parsedMesh, scene) {
const result = new LinesMesh(parsedMesh.name, scene);
result.color = Color3.FromArray(parsedMesh.color);
result.alpha = parsedMesh.alpha;
return result;
}
}
/**
* Force all the LineMeshes to compile their default color material to glsl even on WebGPU engines.
* False by default. This is mostly meant for backward compatibility.
*/
LinesMesh.ForceGLSL = false;
/**
* Creates an instance based on a source LinesMesh
*/
class InstancedLinesMesh extends InstancedMesh {
constructor(name, source) {
super(name, source);
this.intersectionThreshold = source.intersectionThreshold;
}
/**
* @returns the string "InstancedLinesMesh".
*/
getClassName() {
return "InstancedLinesMesh";
}
}
LinesMesh.prototype.enableEdgesRendering ??= _MissingSideEffect("LinesMesh", "enableEdgesRendering");
InstancedLinesMesh.prototype.enableEdgesRendering ??= _MissingSideEffect("InstancedLinesMesh", "enableEdgesRendering");
// #endregion GENERATED_SIDE_EFFECT_STUBS
/** This file must only contain pure code and pure imports */
/* eslint-disable @typescript-eslint/naming-convention */
/**
* Creates the VertexData of the LineSystem
* @param options an object used to set the following optional parameters for the LineSystem, required but can be empty
* - lines an array of lines, each line being an array of successive Vector3
* - colors an array of line colors, each of the line colors being an array of successive Color4, one per line point
* @returns the VertexData of the LineSystem
*/
function CreateLineSystemVertexData(options) {
const indices = [];
const positions = [];
const lines = options.lines;
const colors = options.colors;
const vertexColors = [];
let idx = 0;
for (let l = 0; l < lines.length; l++) {
const points = lines[l];
for (let index = 0; index < points.length; index++) {
const { x, y, z } = points[index];
positions.push(x, y, z);
if (colors) {
const color = colors[l];
const { r, g, b, a } = color[index];
vertexColors.push(r, g, b, a);
}
if (index > 0) {
indices.push(idx - 1);
indices.push(idx);
}
idx++;
}
}
const vertexData = new VertexData();
vertexData.indices = indices;
vertexData.positions = positions;
if (colors) {
vertexData.colors = vertexColors;
}
return vertexData;
}
/**
* Create the VertexData for a DashedLines
* @param options an object used to set the following optional parameters for the DashedLines, required but can be empty
* - points an array successive Vector3
* - dashSize the size of the dashes relative to the dash number, optional, default 3
* - gapSize the size of the gap between two successive dashes relative to the dash number, optional, default 1
* - dashNb the intended total number of dashes, optional, default 200
* @returns the VertexData for the DashedLines
*/
function CreateDashedLinesVertexData(options) {
const dashSize = options.dashSize || 3;
const gapSize = options.gapSize || 1;
const dashNb = options.dashNb || 200;
const points = options.points;
const positions = [];
const indices = [];
const curvect = Vector3.Zero();
let lg = 0;
let nb;
let curshft;
let idx = 0;
let i;
for (i = 0; i < points.length - 1; i++) {
points[i + 1].subtractToRef(points[i], curvect);
lg += curvect.length();
}
const shft = lg / dashNb;
const dashshft = (dashSize * shft) / (dashSize + gapSize);
for (i = 0; i < points.length - 1; i++) {
points[i + 1].subtractToRef(points[i], curvect);
nb = Math.floor(curvect.length() / shft);
curvect.normalize();
for (let j = 0; j < nb; j++) {
curshft = shft * j;
positions.push(points[i].x + curshft * curvect.x, points[i].y + curshft * curvect.y, points[i].z + curshft * curvect.z);
positions.push(points[i].x + (curshft + dashshft) * curvect.x, points[i].y + (curshft + dashshft) * curvect.y, points[i].z + (curshft + dashshft) * curvect.z);
indices.push(idx, idx + 1);
idx += 2;
}
}
// Result
const vertexData = new VertexData();
vertexData.positions = positions;
vertexData.indices = indices;
return vertexData;
}
/**
* Creates a line system mesh. A line system is a pool of many lines gathered in a single mesh
* * A line system mesh is considered as a parametric shape since it has no predefined original shape. Its shape is determined by the passed array of lines as an input parameter
* * Like every other parametric shape, it is dynamically updatable by passing an existing instance of LineSystem to this static function
* * The parameter `lines` is an array of lines, each line being an array of successive Vector3
* * The optional parameter `instance` is an instance of an existing LineSystem object to be updated with the passed `lines` parameter
* * The optional parameter `colors` is an array of line colors, each line colors being an array of successive Color4, one per line point
* * The optional parameter `useVertexAlpha` is to be set to `false` (default `true`) when you don't need the alpha blending (faster)
* * The optional parameter `material` is the material to use to draw the lines if provided. If not, a default material will be created
* * Updating a simple Line mesh, you just need to update every line in the `lines` array : https://doc.babylonjs.com/features/featuresDeepDive/mesh/dynamicMeshMorph#lines-and-dashedlines
* * When updating an instance, remember that only line point positions can change, not the number of points, neither the number of lines
* * The mesh can be set to updatable with the boolean parameter `updatable` (default false) if its internal geometry is supposed to change once created
* @see https://doc.babylonjs.com/features/featuresDeepDive/mesh/creation/param#line-system
* @param name defines the name of the new line system
* @param options defines the options used to create the line system
* @param scene defines the hosting scene
* @returns a new line system mesh
*/
function CreateLineSystem(name, options, scene = null) {
const instance = options.instance;
const lines = options.lines;
const colors = options.colors;
if (instance) {
// lines update
const positions = instance.getVerticesData(VertexBuffer.PositionKind);
let vertexColor;
let lineColors;
if (colors) {
vertexColor = instance.getVerticesData(VertexBuffer.ColorKind);
}
let i = 0;
let c = 0;
for (let l = 0; l < lines.length; l++) {
const points = lines[l];
for (let p = 0; p < points.length; p++) {
positions[i] = points[p].x;
positions[i + 1] = points[p].y;
positions[i + 2] = points[p].z;
if (colors && vertexColor) {
lineColors = colors[l];
vertexColor[c] = lineColors[p].r;
vertexColor[c + 1] = lineColors[p].g;
vertexColor[c + 2] = lineColors[p].b;
vertexColor[c + 3] = lineColors[p].a;
c += 4;
}
i += 3;
}
}
instance.updateVerticesData(VertexBuffer.PositionKind, positions, false, false);
if (colors && vertexColor) {
instance.updateVerticesData(VertexBuffer.ColorKind, vertexColor, false, false);
}
instance.refreshBoundingInfo();
return instance;
}
// line system creation
const useVertexColor = colors ? true : false;
const lineSystem = new LinesMesh(name, scene, null, undefined, undefined, useVertexColor, options.useVertexAlpha, options.material);
const vertexData = CreateLineSystemVertexData(options);
vertexData.applyToMesh(lineSystem, options.updatable);
return lineSystem;
}
/**
* Creates a line mesh
* A line mesh is considered as a parametric shape since it has no predefined original shape. Its shape is determined by the passed array of points as an input parameter
* * Like every other parametric shape, it is dynamically updatable by passing an existing instance of LineMesh to this static function
* * The parameter `points` is an array successive Vector3
* * The optional parameter `instance` is an instance of an existing LineMesh object to be updated with the passed `points` parameter : https://doc.babylonjs.com/features/featuresDeepDive/mesh/dynamicMeshMorph#lines-and-dashedlines
* * The optional parameter `colors` is an array of successive Color4, one per line point
* * The optional parameter `useVertexAlpha` is to be set to `false` (default `true`) when you don't need alpha blending (faster)
* * The optional parameter `material` is the material to use to draw the lines if provided. If not, a default material will be created
* * When updating an instance, remember that only point positions can change, not the number of points
* * The mesh can be set to updatable with the boolean parameter `updatable` (default false) if its internal geometry is supposed to change once created
* @see https://doc.babylonjs.com/features/featuresDeepDive/mesh/creation/param#lines
* @param name defines the name of the new line system
* @param options defines the options used to create the line system
* @param scene defines the hosting scene
* @returns a new line mesh
*/
function CreateLines(name, options, scene = null) {
const colors = options.colors ? [options.colors] : null;
const lines = CreateLineSystem(name, { lines: [options.points], updatable: options.updatable, instance: options.instance, colors: colors, useVertexAlpha: options.useVertexAlpha, material: options.material }, scene);
return lines;
}
/**
* Creates a dashed line mesh
* * A dashed line mesh is considered as a parametric shape since it has no predefined original shape. Its shape is determined by the passed array of points as an input parameter
* * Like every other parametric shape, it is dynamically updatable by passing an existing instance of LineMesh to this static function
* * The parameter `points` is an array successive Vector3
* * The parameter `dashNb` is the intended total number of dashes (positive integer, default 200)
* * The parameter `dashSize` is the size of the dashes relatively the dash number (positive float, default 3)
* * The parameter `gapSize` is the size of the gap between two successive dashes relatively the dash number (positive float, default 1)
* * The optional parameter `instance` is an instance of an existing LineMesh object to be updated with the passed `points` parameter : https://doc.babylonjs.com/features/featuresDeepDive/mesh/dynamicMeshMorph#lines-and-dashedlines
* * The optional parameter `useVertexAlpha` is to be set to `false` (default `true`) when you don't need the alpha blending (faster)
* * The optional parameter `material` is the material to use to draw the lines if provided. If not, a default material will be created
* * When updating an instance, remember that only point positions can change, not the number of points
* * The mesh can be set to updatable with the boolean parameter `updatable` (default false) if its internal geometry is supposed to change once created
* @param name defines the name of the mesh
* @param options defines the options used to create the mesh
* @param scene defines the hosting scene
* @returns the dashed line mesh
* @see https://doc.babylonjs.com/features/featuresDeepDive/mesh/creation/param#dashed-lines
*/
function CreateDashedLines(name, options, scene = null) {
const points = options.points;
const instance = options.instance;
const gapSize = options.gapSize || 1;
const dashSize = options.dashSize || 3;
if (instance) {
// dashed lines update
const positionFunction = (positions) => {
const curvect = Vector3.Zero();
const nbSeg = positions.length / 6;
let lg = 0;
let nb;
let curshft;
let p = 0;
let i;
let j;
for (i = 0; i < points.length - 1; i++) {
points[i + 1].subtractToRef(points[i], curvect);
lg += curvect.length();
}
const shft = lg / nbSeg;
const dashSize = instance._creationDataStorage.dashSize;
const gapSize = instance._creationDataStorage.gapSize;
const dashshft = (dashSize * shft) / (dashSize + gapSize);
for (i = 0; i < points.length - 1; i++) {
points[i + 1].subtractToRef(points[i], curvect);
nb = Math.floor(curvect.length() / shft);
curvect.normalize();
j = 0;
while (j < nb && p < positions.length) {
curshft = shft * j;
positions[p] = points[i].x + curshft * curvect.x;
positions[p + 1] = points[i].y + curshft * curvect.y;
positions[p + 2] = points[i].z + curshft * curvect.z;
positions[p + 3] = points[i].x + (curshft + dashshft) * curvect.x;
positions[p + 4] = points[i].y + (curshft + dashshft) * curvect.y;
positions[p + 5] = points[i].z + (curshft + dashshft) * curvect.z;
p += 6;
j++;
}
}
while (p < positions.length) {
positions[p] = points[i].x;
positions[p + 1] = points[i].y;
positions[p + 2] = points[i].z;
p += 3;
}
};
if (options.dashNb || options.dashSize || options.gapSize || options.useVertexAlpha || options.material) {
Logger.Warn("You have used an option other than points with the instance option. Please be aware that these other options will be ignored.");
}
instance.updateMeshPositions(positionFunction, false);
return instance;
}
// dashed lines creation
const dashedLines = new LinesMesh(name, scene, null, undefined, undefined, undefined, options.useVertexAlpha, options.material);
const vertexData = CreateDashedLinesVertexData(options);
vertexData.applyToMesh(dashedLines, options.updatable);
dashedLines._creationDataStorage = new _CreationDataStorage();
dashedLines._creationDataStorage.dashSize = dashSize;
dashedLines._creationDataStorage.gapSize = gapSize;
return dashedLines;
}
let _Registered$2 = false;
/**
* Register side effects for linesBuilder.
* Safe to call multiple times; only the first call has an effect.
*/
function RegisterLinesBuilder() {
if (_Registered$2) {
return;
}
_Registered$2 = true;
VertexData.CreateLineSystem = CreateLineSystemVertexData;
VertexData.CreateDashedLines = CreateDashedLinesVertexData;
Mesh.CreateLines = (name, points, scene = null, updatable = false, instance = null) => {
const options = {
points,
updatable,
instance,
};
return CreateLines(name, options, scene);
};
Mesh.CreateDashedLines = (name, points, dashSize, gapSize, dashNb, scene = null, updatable, instance) => {
const options = {
points,
dashSize,
gapSize,
dashNb,
updatable,
instance,
};
return CreateDashedLines(name, options, scene);
};
}
/**
* Re-exports pure implementation and applies runtime side effects.
* Import linesBuilder.pure for tree-shakeable, side-effect-free usage.
*/
RegisterLinesBuilder();
const SH_C0 = 0.28209479177387814;
async function LoadWebpImageData(rootUrlOrData, filename, engine) {
const promise = new Promise((resolve, reject) => {
const image = engine.createCanvasImage();
if (!image) {
throw new Error("Failed to create ImageBitmap");
}
image.onload = () => {
try {
// Draw to canvas
const canvas = engine.createCanvas(image.width, image.height);
if (!canvas) {
throw new Error("Failed to create canvas");
}
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get 2D context");
}
ctx.drawImage(image, 0, 0);
// Extract pixel data (RGBA per pixel)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
resolve({ bits: new Uint8Array(imageData.data.buffer), width: imageData.width, height: imageData.height });
}
catch (error) {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(`Error loading image ${image.src} with exception: ${error}`);
}
};
image.onerror = (error) => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(`Error loading image ${image.src} with exception: ${error}`);
};
image.crossOrigin = "anonymous"; // To avoid CORS issues
let objectUrl;
if (typeof rootUrlOrData === "string") {
// old behavior: URL + filename
if (!filename) {
throw new Error("filename is required when using a URL");
}
image.src = rootUrlOrData + filename;
}
else {
// new behavior: Uint8Array
const blob = new Blob([rootUrlOrData], { type: "image/webp" });
objectUrl = URL.createObjectURL(blob);
image.src = objectUrl;
}
});
return await promise;
}
async function ParseSogDatas(data, imageDataArrays, scene) {
const splatCount = data.count ? data.count : data.means.shape[0];
const rowOutputLength = 3 * 4 + 3 * 4 + 4 + 4; // 32
const buffer = new ArrayBuffer(rowOutputLength * splatCount);
const position = new Float32Array(buffer);
const scale = new Float32Array(buffer);
const rgba = new Uint8ClampedArray(buffer);
const rot = new Uint8ClampedArray(buffer);
// Undo the symmetric log transform used at encode time:
const unlog = (n) => Math.sign(n) * (Math.exp(Math.abs(n)) - 1);
const meansl = imageDataArrays[0].bits;
const meansu = imageDataArrays[1].bits;
// Check that data.means.mins is an array
if (!Array.isArray(data.means.mins) || !Array.isArray(data.means.maxs)) {
throw new Error("Missing arrays in SOG data.");
}
// --- Positions
for (let i = 0; i < splatCount; i++) {
const index = i * 4;
for (let j = 0; j < 3; j++) {
const meansMin = data.means.mins[j];
const meansMax = data.means.maxs[j];
const meansup = meansu[index + j];
const meanslow = meansl[index + j];
const q = (meansup << 8) | meanslow;
const n = Scalar.Lerp(meansMin, meansMax, q / 65535);
position[i * 8 + j] = unlog(n);
}
}
// --- Scales
const scales = imageDataArrays[2].bits;
if (data.version === 2) {
if (!data.scales.codebook) {
throw new Error("Missing codebook in SOG version 2 scales data.");
}
for (let i = 0; i < splatCount; i++) {
const index = i * 4;
for (let j = 0; j < 3; j++) {
const sc = data.scales.codebook[scales[index + j]];
const sce = Math.exp(sc);
scale[i * 8 + 3 + j] = sce;
}
}
}
else {
if (!Array.isArray(data.scales.mins) || !Array.isArray(data.scales.maxs)) {
throw new Error("Missing arrays in SOG scales data.");
}
for (let i = 0; i < splatCount; i++) {
const index = i * 4;
for (let j = 0; j < 3; j++) {
const sc = scales[index + j];
const lsc = Scalar.Lerp(data.scales.mins[j], data.scales.maxs[j], sc / 255);
const lsce = Math.exp(lsc);
scale[i * 8 + 3 + j] = lsce;
}
}
}
// --- Colors/SH0
const colors = imageDataArrays[4].bits;
if (data.version === 2) {
if (!data.sh0.codebook) {
throw new Error("Missing codebook in SOG version 2 sh0 data.");
}
for (let i = 0; i < splatCount; i++) {
const index = i * 4;
for (let j = 0; j < 3; j++) {
const component = 0.5 + data.sh0.codebook[colors[index + j]] * SH_C0;
rgba[i * 32 + 24 + j] = Math.max(0, Math.min(255, Math.round(255 * component)));
}
rgba[i * 32 + 24 + 3] = colors[index + 3];
}
}
else {
if (!Array.isArray(data.sh0.mins) || !Array.isArray(data.sh0.maxs)) {
throw new Error("Missing arrays in SOG sh0 data.");
}
for (let i = 0; i < splatCount; i++) {
const index = i * 4;
for (let j = 0; j < 4; j++) {
const colorsMin = data.sh0.mins[j];
const colorsMax = data.sh0.maxs[j];
const colort = colors[index + j];
const c = Scalar.Lerp(colorsMin, colorsMax, colort / 255);
let csh;
if (j < 3) {
csh = 0.5 + c * SH_C0;
}
else {
csh = 1.0 / (1.0 + Math.exp(-c));
}
rgba[i * 32 + 24 + j] = Math.max(0, Math.min(255, Math.round(255 * csh)));
}
}
}
// --- Rotations
// Dequantize the stored three components:
const toComp = (c) => ((c / 255 - 0.5) * 2.0) / Math.SQRT2;
const quatArray = imageDataArrays[3].bits;
for (let i = 0; i < splatCount; i++) {
const quatsr = quatArray[i * 4 + 0];
const quatsg = quatArray[i * 4 + 1];
const quatsb = quatArray[i * 4 + 2];
const quatsa = quatArray[i * 4 + 3];
const a = toComp(quatsr);
const b = toComp(quatsg);
const c = toComp(quatsb);
const mode = quatsa - 252; // 0..3 (R,G,B,A is one of the four components)
// Reconstruct the omitted component so that ||q|| = 1 and w.l.o.g. the omitted one is non-negative
const t = a * a + b * b + c * c;
const d = Math.sqrt(Math.max(0, 1 - t));
// Place components according to mode
let q;
switch (mode) {
case 0:
q = [d, a, b, c];
break; // omitted = x
case 1:
q = [a, d, b, c];
break; // omitted = y
case 2:
q = [a, b, d, c];
break; // omitted = z
case 3:
q = [a, b, c, d];
break; // omitted = w
default:
throw new Error("Invalid quaternion mode");
}
rot[i * 32 + 28 + 0] = q[0] * 127.5 + 127.5;
rot[i * 32 + 28 + 1] = q[1] * 127.5 + 127.5;
rot[i * 32 + 28 + 2] = q[2] * 127.5 + 127.5;
rot[i * 32 + 28 + 3] = q[3] * 127.5 + 127.5;
}
// --- SH
if (data.shN) {
const coeffs = data.shN.bands ? (data.shN.bands + 1) ** 2 - 1 : data.shN.shape[1] / 3; // 3 components per coeff
const shDegree = data.shN.bands !== undefined && data.shN.bands !== null ? data.shN.bands : Math.round(Math.sqrt(coeffs + 1) - 1);
const shCentroids = imageDataArrays[5].bits;
const shLabelsData = imageDataArrays[6].bits;
const shCentroidsWidth = imageDataArrays[5].width;
const shComponentCount = coeffs * 3;
const textureCount = Math.ceil(shComponentCount / 16); // 4 components can be stored per texture, 4 sh per component
//let shIndexRead = byteOffset;
const engine = scene.getEngine();
const width = engine.getCaps().maxTextureSize;
const height = Math.ceil(splatCount / width);
// sh is an array of uint8array that will be used to create sh textures
const sh = AllocateShBuffers(textureCount, height * width * 4 * 4);
if (data.version === 2) {
if (!data.shN.codebook) {
throw new Error("Missing codebook in SOG version 2 shN data.");
}
for (let i = 0; i < splatCount; i++) {
const n = shLabelsData[i * 4 + 0] + (shLabelsData[i * 4 + 1] << 8);
const u = (n % 64) * coeffs;
const v = Math.floor(n / 64);
for (let k = 0; k < coeffs; k++) {
for (let j = 0; j < 3; j++) {
const shIndexWrite = k * 3 + j;
const textureIndex = Math.floor(shIndexWrite / 16);
const shArray = sh[textureIndex];
const byteIndexInTexture = shIndexWrite % 16; // [0..15]
const offsetPerSplat = i * 16; // 16 sh values per texture per splat.
const shValue = data.shN.codebook[shCentroids[(u + k) * 4 + j + v * shCentroidsWidth * 4]] * 127.5 + 127.5;
shArray[byteIndexInTexture + offsetPerSplat] = Math.max(0, Math.min(255, shValue));
}
}
}
}
else {
for (let i = 0; i < splatCount; i++) {
const n = shLabelsData[i * 4 + 0] + (shLabelsData[i * 4 + 1] << 8);
const u = (n % 64) * coeffs;
const v = Math.floor(n / 64);
const shMin = data.shN.mins;
const shMax = data.shN.maxs;
for (let j = 0; j < 3; j++) {
for (let k = 0; k < coeffs / 3; k++) {
const shIndexWrite = k * 3 + j;
const textureIndex = Math.floor(shIndexWrite / 16);
const shArray = sh[textureIndex];
const byteIndexInTexture = shIndexWrite % 16; // [0..15]
const offsetPerSplat = i * 16; // 16 sh values per texture per splat.
const shValue = Scalar.Lerp(shMin, shMax, shCentroids[(u + k) * 4 + j + v * shCentroidsWidth * 4] / 255) * 127.5 + 127.5;
shArray[byteIndexInTexture + offsetPerSplat] = Math.max(0, Math.min(255, shValue));
}
}
}
}
return await new Promise((resolve) => {
resolve({ mode: 0 /* Mode.Splat */, data: buffer, hasVertexColors: false, sh: sh, shDegree: shDegree });
});
}
return await new Promise((resolve) => {
resolve({ mode: 0 /* Mode.Splat */, data: buffer, hasVertexColors: false });
});
}
/**
* Parse SOG data from either a SOGRootData object (with webp files loaded from rootUrl) or from a Map of filenames to Uint8Array file data (including meta.json)
* @param dataOrFiles Either the SOGRootData or a Map of filenames to Uint8Array file data (including meta.json)
* @param rootUrl Base URL to load webp files from (if dataOrFiles is SOGRootData)
* @param scene The Babylon.js scene
* @returns Parsed data
*/
async function ParseSogMeta(dataOrFiles, rootUrl, scene) {
let data;
let files;
if (dataOrFiles instanceof Map) {
files = dataOrFiles;
const metaFile = files.get("meta.json");
if (!metaFile) {
throw new Error("meta.json not found in files Map");
}
data = JSON.parse(new TextDecoder().decode(metaFile));
}
else {
data = dataOrFiles;
}
// Collect all file names
const urls = [...data.means.files, ...data.scales.files, ...data.quats.files, ...data.sh0.files];
if (data.shN) {
urls.push(...data.shN.files);
}
// Load webp images in parallel
const imageDataArrays = await Promise.all(urls.map(async (fileName) => {
if (files && files.has(fileName)) {
// load from in-memory Uint8Array
const fileData = files.get(fileName);
return await LoadWebpImageData(fileData, fileName, scene.getEngine());
}
else {
// fallback: load from URL
return await LoadWebpImageData(rootUrl, fileName, scene.getEngine());
}
}));
return await ParseSogDatas(data, imageDataArrays, scene);
}
function CreateSogTexture(scene, bits, width, height) {
const tex = new RawTexture(bits, width, height, Constants.TEXTUREFORMAT_RGBA, scene, false, false, Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURETYPE_UNSIGNED_BYTE);
tex.wrapU = Constants.TEXTURE_CLAMP_ADDRESSMODE;
tex.wrapV = Constants.TEXTURE_CLAMP_ADDRESSMODE;
return tex;
}
function CreateSogTextureFromImage(scene, image) {
return CreateSogTexture(scene, image.bits, image.width, image.height);
}
/**
* Loads a SOG attribute image straight onto a GPU texture using `createImageBitmap` (decoded off the main
* thread, with `premultiplyAlpha`/`colorSpaceConversion` set to "none" so the raw data bytes are preserved)
* and a direct `texImage2D` upload — avoiding the `<img>` + 2D-canvas `getImageData` readback that stalls the
* frame. Falls back to the canvas path when `createImageBitmap` is unavailable or fails. Use only for textures
* whose pixels are never read back on the CPU (scales, quats, sh0, shN); means_l/means_u still go through the
* readback path because their bytes are needed to decode positions for the sort worker.
* @param rootUrlOrData base URL (string) or the raw file bytes (Uint8Array)
* @param filename file name (used only with a URL)
* @param scene hosting scene
* @returns a GPU texture holding the raw image bytes
*/
async function LoadSogTextureDirectAsync(rootUrlOrData, filename, scene) {
const engine = scene.getEngine();
if (typeof createImageBitmap === "function") {
try {
// A typed blob is required: createImageBitmap can fail to decode a typeless blob (and the
// content-type is lost when loading via LoadFileAsync), which would force the slow canvas fallback.
const mimeType = filename.toLowerCase().endsWith(".png") ? "image/png" : "image/webp";
let blob;
if (typeof rootUrlOrData === "string") {
const buffer = (await Tools.LoadFileAsync(rootUrlOrData + filename, true));
blob = new Blob([buffer], { type: mimeType });
}
else {
blob = new Blob([rootUrlOrData], { type: mimeType });
}
const bitmap = await createImageBitmap(blob, { premultiplyAlpha: "none", colorSpaceConversion: "none" });
try {
const tex = new RawTexture(null, bitmap.width, bitmap.height, Constants.TEXTUREFORMAT_RGBA, scene, false, false, Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURETYPE_UNSIGNED_BYTE);
tex.wrapU = Constants.TEXTURE_CLAMP_ADDRESSMODE;
tex.wrapV = Constants.TEXTURE_CLAMP_ADDRESSMODE;
const internal = tex.getInternalTexture();
if (internal) {
// invertY=false / premulAlpha=false keep the byte layout identical to the canvas path.
engine.updateDynamicTexture(internal, bitmap, false, false);
}
return tex;
}
finally {
bitmap.close();
}
}
catch {
// Fall through to the canvas readback path below.
}
}
const image = await LoadWebpImageData(rootUrlOrData, filename, engine);
return CreateSogTextureFromImage(scene, image);
}
function DecodeSogPositions(data, meansl, meansu, splatCount) {
const unlog = (n) => Math.sign(n) * (Math.exp(Math.abs(n)) - 1);
if (!Array.isArray(data.means.mins) || !Array.isArray(data.means.maxs)) {
throw new Error("Missing arrays in SOG data.");
}
// Stride-4 layout (x,y,z,w) expected by the depth-sort worker and the centers texture.
const positions = new Float32Array(splatCount * 4);
for (let i = 0; i < splatCount; i++) {
const index = i * 4;
for (let j = 0; j < 3; j++) {
const q = (meansu[index + j] << 8) | meansl[index + j];
const n = Scalar.Lerp(data.means.mins[j], data.means.maxs[j], q / 65535);
positions[i * 4 + j] = unlog(n);
}
positions[i * 4 + 3] = 1.0;
}
return positions;
}
/**
* Parse SOG data and produce a set of GPU textures + dequantization parameters.
* The shader will sample these raw RGBA8 textures and reconstruct positions/scales/rotations/colors/SH on the GPU.
* @param dataOrFiles Either the SOGRootData or a Map of filenames to Uint8Array file data (including meta.json)
* @param rootUrl Base URL to load webp files from (if dataOrFiles is SOGRootData)
* @param scene The Babylon.js scene
* @param computeCpuPositions When true (default), means_l/means_u are read back on the CPU and `pack.positions`
* is decoded for the sort worker / bounding box. Pass false when the caller will instead read the decoded
* centers back from the GPU work buffer — then every attribute (including means) uses the fast direct
* ImageBitmap upload (no `getImageData` readback) and `pack.positions` is left empty.
* @param downloadManager Optional download manager that throttles and retries the per-file image downloads
* (used by the LOD streamer). When omitted, files are fetched directly. Only applies when loading from a URL.
* @param downloadGroupId Optional group tag passed to the download manager so this file's image downloads can
* be cancelled together if the streamer no longer needs them.
* @returns Parsed splat info with `sogTextures` populated.
*/
// eslint-disable-next-line @typescript-eslint/no-restricted-types
async function ParseSogMetaAsTextures(dataOrFiles, rootUrl, scene, computeCpuPositions = true, downloadManager, downloadGroupId) {
let data;
let files;
if (dataOrFiles instanceof Map) {
files = dataOrFiles;
const metaFile = files.get("meta.json");
if (!metaFile) {
throw new Error("meta.json not found in files Map");
}
data = JSON.parse(new TextDecoder().decode(metaFile));
}
else {
data = dataOrFiles;
}
// Attribute textures (scales/quats/sh0/shN) are only sampled on the GPU, so they always upload straight
// from a decoded ImageBitmap (no getImageData readback). means_l/means_u additionally need their CPU bytes
// when computeCpuPositions is set (to decode positions for the sort worker) — those go through the
// <img>+canvas path; otherwise means also use the fast direct upload. All loads run in parallel.
const loadMeansImageAsync = async (fileName) => {
if (files && files.has(fileName)) {
return await LoadWebpImageData(files.get(fileName), fileName, scene.getEngine());
}
if (downloadManager) {
const bytes = new Uint8Array(await downloadManager.loadFileAsync(rootUrl + fileName, downloadGroupId));
return await LoadWebpImageData(bytes, fileName, scene.getEngine());
}
return await LoadWebpImageData(rootUrl, fileName, scene.getEngine());
};
const loadGpuTextureAsync = async (fileName) => {
if (files && files.has(fileName)) {
return await LoadSogTextureDirectAsync(files.get(fileName), fileName, scene);
}
if (downloadManager) {
const bytes = new Uint8Array(await downloadManager.loadFileAsync(rootUrl + fileName, downloadGroupId));
return await LoadSogTextureDirectAsync(bytes, fileName, scene);
}
return await LoadSogTextureDirectAsync(rootUrl, fileName, scene);
};
const gpuFiles = [...data.scales.files, ...data.quats.files, ...data.sh0.files, ...(data.shN?.files ?? [])];
let meansL;
let meansU;
let meansWidth;
let meansHeight;
let meansImages = null;
let gpuTextures;
if (computeCpuPositions) {
const [images, gpu] = await Promise.all([Promise.all(data.means.files.map(loadMeansImageAsync)), Promise.all(gpuFiles.map(loadGpuTextureAsync))]);
meansImages = [images[0], images[1]];
gpuTextures = gpu;
meansL = CreateSogTextureFromImage(scene, images[0]);
meansU = CreateSogTextureFromImage(scene, images[1]);
meansWidth = images[0].width;
meansHeight = images[0].height;
}
else {
const [meansTex, gpu] = await Promise.all([Promise.all(data.means.files.map(loadGpuTextureAsync)), Promise.all(gpuFiles.map(loadGpuTextureAsync))]);
gpuTextures = gpu;
meansL = meansTex[0];
meansU = meansTex[1];
const size = meansL.getSize();
meansWidth = size.width;
meansHeight = size.height;
}
const splatCount = data.count ?? data.means.shape[0];
const splatTexelCount = meansWidth * meansHeight;
if (splatTexelCount < splatCount) {
throw new Error(`SOG texture contains ${splatTexelCount} texels, but metadata references ${splatCount} splats.`);
}
const scales = gpuTextures[0];
const quats = gpuTextures[1];
const sh0 = gpuTextures[2];
let shCentroids;
let shLabels;
let shCoeffCount = 0;
let shDegree = 0;
if (data.shN && gpuTextures.length >= 5) {
shCentroids = gpuTextures[3];
shLabels = gpuTextures[4];
shCoeffCount = data.shN.bands ? (data.shN.bands + 1) ** 2 - 1 : data.shN.shape[1] / 3;
shDegree = data.shN.bands ?? Math.round(Math.sqrt(shCoeffCount + 1) - 1);
}
// Optional codebook packed into a 1D R32F texture: [scales(256) | sh0(256) | shN(256)]
let codebookTexture;
if (data.version === 2) {
const codebookSize = 256;
const packed = new Float32Array(codebookSize * 3);
if (data.scales.codebook) {
packed.set(data.scales.codebook.slice(0, codebookSize), 0);
}
if (data.sh0.codebook) {
packed.set(data.sh0.codebook.slice(0, codebookSize), codebookSize);
}
if (data.shN?.codebook) {
packed.set(data.shN.codebook.slice(0, codebookSize), codebookSize * 2);
}
codebookTexture = new RawTexture(packed, codebookSize * 3, 1, Constants.TEXTUREFORMAT_R, scene, false, false, Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURETYPE_FLOAT);
codebookTexture.wrapU = Constants.TEXTURE_CLAMP_ADDRESSMODE;
codebookTexture.wrapV = Constants.TEXTURE_CLAMP_ADDRESSMODE;
}
const meansMins = data.means.mins;
const meansMaxs = data.means.maxs;
const pack = {
version: (data.version === 2 ? 2 : 1),
splatCount,
shDegree,
meansTextureL: meansL,
meansTextureU: meansU,
scalesTexture: scales,
quatsTexture: quats,
sh0Texture: sh0,
shCentroidsTexture: shCentroids,
shLabelsTexture: shLabels,
codebookTexture,
meansMin: [meansMins[0], meansMins[1], meansMins[2]],
meansMax: [meansMaxs[0], meansMaxs[1], meansMaxs[2]],
scalesMin: Array.isArray(data.scales.mins) ? [data.scales.mins[0], data.scales.mins[1], data.scales.mins[2]] : undefined,
scalesMax: Array.isArray(data.scales.maxs) ? [data.scales.maxs[0], data.scales.maxs[1], data.scales.maxs[2]] : undefined,
sh0Min: Array.isArray(data.sh0.mins) ? [data.sh0.mins[0], data.sh0.mins[1], data.sh0.mins[2], data.sh0.mins[3]] : undefined,
sh0Max: Array.isArray(data.sh0.maxs) ? [data.sh0.maxs[0], data.sh0.maxs[1], data.sh0.maxs[2], data.sh0.maxs[3]] : undefined,
shnMin: typeof data.shN?.mins === "number" ? data.shN.mins : undefined,
shnMax: typeof data.shN?.maxs === "number" ? data.shN.maxs : undefined,
shCoeffCount,
positions: meansImages ? DecodeSogPositions(data, meansImages[0].bits, meansImages[1].bits, splatCount) : new Float32Array(0),
};
return { mode: 0 /* Mode.Splat */, data: new ArrayBuffer(0), hasVertexColors: false, shDegree, sogTextures: pack };
}
/** This file must only contain pure code and pure imports */
/**
* Shared shader names for the SOG -> decoded work-buffer copy pass.
*/
/**
* Pass-through vertex shader (GLSL): the geometry is a fullscreen triangle already in NDC.
*/
const GaussianSplattingWorkBufferVertexShaderGLSL = `precision highp float;
attribute vec3 position;
void main() {
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
/**
* Fragment shader (GLSL/WebGL2): decodes one SOG source file into the decoded GS work-buffer layout,
* writing each splat into its allocated pixel. Mirrors the USE_SOG decode in ShadersInclude/gaussianSplatting.fx
* but outputs the decoded MRT (center, covA, covB, color) consumed by the standard (non-SOG) draw path.
*
* MRT layout: 0 = center (x,y,z,1), 1 = covA (Sigma00,01,02,11), 2 = covB (Sigma12,22,0,0), 3 = color (rgba).
*/
const GaussianSplattingWorkBufferFragmentShaderGLSL = `precision highp float;
precision highp int;
uniform sampler2D sogMeansLTex;
uniform sampler2D sogMeansUTex;
uniform sampler2D sogScalesTex;
uniform sampler2D sogQuatsTex;
uniform sampler2D sogSh0Tex;
uniform sampler2D sogCodebookTex;
uniform vec3 sogMeansMin;
uniform vec3 sogMeansMax;
uniform vec3 sogScalesMin;
uniform vec3 sogScalesMax;
uniform vec4 sogSh0Min;
uniform vec4 sogSh0Max;
uniform int uVersion;
uniform int uOffset;
uniform int uCount;
uniform int uDestWidth;
uniform int uSrcWidth;
layout(location = 0) out vec4 glFragData[4];
mat3 transposeM(mat3 m) {
return mat3(m[0][0], m[1][0], m[2][0], m[0][1], m[1][1], m[2][1], m[0][2], m[1][2], m[2][2]);
}
void main() {
ivec2 p = ivec2(gl_FragCoord.xy);
int global = p.y * uDestWidth + p.x;
if (global < uOffset || global >= uOffset + uCount) {
discard;
}
int k = global - uOffset;
ivec2 src = ivec2(k - (k / uSrcWidth) * uSrcWidth, k / uSrcWidth);
vec3 mL = texelFetch(sogMeansLTex, src, 0).xyz;
vec3 mU = texelFetch(sogMeansUTex, src, 0).xyz;
vec3 sRaw = texelFetch(sogScalesTex, src, 0).xyz;
vec4 qRaw = texelFetch(sogQuatsTex, src, 0);
vec4 c0 = texelFetch(sogSh0Tex, src, 0);
// Position: q16 = (u<<8)|l normalized; n = lerp(min,max,q16); pos = sign(n)*(exp(|n|)-1)
vec3 q16 = (mU * 256.0 + mL) * (255.0 / 65535.0);
vec3 nPos = mix(sogMeansMin, sogMeansMax, q16);
vec3 center = sign(nPos) * (exp(abs(nPos)) - vec3(1.0));
// Scale (v1: lerp+exp ; v2: codebook lookup)
vec3 splatScale;
if (uVersion == 2) {
vec3 sIdx = floor(sRaw * 255.0 + 0.5);
splatScale.x = exp(texelFetch(sogCodebookTex, ivec2(int(sIdx.x), 0), 0).r);
splatScale.y = exp(texelFetch(sogCodebookTex, ivec2(int(sIdx.y), 0), 0).r);
splatScale.z = exp(texelFetch(sogCodebookTex, ivec2(int(sIdx.z), 0), 0).r);
} else {
splatScale = exp(mix(sogScalesMin, sogScalesMax, sRaw));
}
// Quaternion (largest-omitted, mode in alpha as 252 + omitted-index)
const float invSqrt2 = 0.70710678118;
vec3 qabc = (qRaw.xyz - vec3(0.5)) * 2.0 * invSqrt2;
int qMode = int(qRaw.w * 255.0 + 0.5) - 252;
float qd = sqrt(max(0.0, 1.0 - dot(qabc, qabc)));
vec4 quat;
if (qMode == 0) {
quat = vec4(qd, qabc.x, qabc.y, qabc.z);
} else if (qMode == 1) {
quat = vec4(qabc.x, qd, qabc.y, qabc.z);
} else if (qMode == 2) {
quat = vec4(qabc.x, qabc.y, qd, qabc.z);
} else {
quat = vec4(qabc.x, qabc.y, qabc.z, qd);
}
float qw = quat.x, qx = quat.y, qy = quat.z, qz = quat.w;
mat3 R = mat3(
1.0 - 2.0 * (qy * qy + qz * qz), 2.0 * (qx * qy + qw * qz), 2.0 * (qx * qz - qw * qy),
2.0 * (qx * qy - qw * qz), 1.0 - 2.0 * (qx * qx + qz * qz), 2.0 * (qy * qz + qw * qx),
2.0 * (qx * qz + qw * qy), 2.0 * (qy * qz - qw * qx), 1.0 - 2.0 * (qx * qx + qy * qy)
);
mat3 S2 = mat3(
4.0 * splatScale.x * splatScale.x, 0.0, 0.0,
0.0, 4.0 * splatScale.y * splatScale.y, 0.0,
0.0, 0.0, 4.0 * splatScale.z * splatScale.z
);
mat3 Sigma = R * S2 * transposeM(R);
// Color (sh0)
const float SH_C0 = 0.28209479177387814;
vec3 colRgb;
float colA;
if (uVersion == 2) {
vec3 c3;
c3.x = texelFetch(sogCodebookTex, ivec2(256 + int(c0.x * 255.0 + 0.5), 0), 0).r;
c3.y = texelFetch(sogCodebookTex, ivec2(256 + int(c0.y * 255.0 + 0.5), 0), 0).r;
c3.z = texelFetch(sogCodebookTex, ivec2(256 + int(c0.z * 255.0 + 0.5), 0), 0).r;
colRgb = vec3(0.5) + c3 * SH_C0;
colA = c0.w;
} else {
vec4 cLerp = mix(sogSh0Min, sogSh0Max, c0);
colRgb = vec3(0.5) + cLerp.xyz * SH_C0;
colA = 1.0 / (1.0 + exp(-cLerp.w));
}
glFragData[0] = vec4(center, 1.0);
glFragData[1] = vec4(Sigma[0][0], Sigma[0][1], Sigma[0][2], Sigma[1][1]);
glFragData[2] = vec4(Sigma[1][2], Sigma[2][2], 0.0, 0.0);
glFragData[3] = vec4(colRgb, colA);
}
`;
/**
* Pass-through vertex shader (WGSL).
*/
const GaussianSplattingWorkBufferVertexShaderWGSL = `
attribute position : vec3<f32>;
@vertex
fn main(input : VertexInputs) -> FragmentInputs {
vertexOutputs.position = vec4<f32>(input.position.xy, 0.0, 1.0);
}
`;
/**
* Fragment shader (WGSL/WebGPU) — same decode as the GLSL variant, writing 4 MRT attachments.
*/
const GaussianSplattingWorkBufferFragmentShaderWGSL = `
var sogMeansLTexSampler : sampler;
var sogMeansLTex : texture_2d<f32>;
var sogMeansUTexSampler : sampler;
var sogMeansUTex : texture_2d<f32>;
var sogScalesTexSampler : sampler;
var sogScalesTex : texture_2d<f32>;
var sogQuatsTexSampler : sampler;
var sogQuatsTex : texture_2d<f32>;
var sogSh0TexSampler : sampler;
var sogSh0Tex : texture_2d<f32>;
var sogCodebookTexSampler : sampler;
var sogCodebookTex : texture_2d<f32>;
uniform sogMeansMin : vec3<f32>;
uniform sogMeansMax : vec3<f32>;
uniform sogScalesMin : vec3<f32>;
uniform sogScalesMax : vec3<f32>;
uniform sogSh0Min : vec4<f32>;
uniform sogSh0Max : vec4<f32>;
uniform uVersion : i32;
uniform uOffset : i32;
uniform uCount : i32;
uniform uDestWidth : i32;
uniform uSrcWidth : i32;
@fragment
fn main(input : FragmentInputs) -> FragmentOutputs {
let p : vec2<i32> = vec2<i32>(i32(fragmentInputs.position.x), i32(fragmentInputs.position.y));
let global : i32 = p.y * uniforms.uDestWidth + p.x;
if (global < uniforms.uOffset || global >= uniforms.uOffset + uniforms.uCount) {
discard;
}
let k : i32 = global - uniforms.uOffset;
let src : vec2<i32> = vec2<i32>(k - (k / uniforms.uSrcWidth) * uniforms.uSrcWidth, k / uniforms.uSrcWidth);
let mL : vec3<f32> = textureLoad(sogMeansLTex, src, 0).xyz;
let mU : vec3<f32> = textureLoad(sogMeansUTex, src, 0).xyz;
let sRaw : vec3<f32> = textureLoad(sogScalesTex, src, 0).xyz;
let qRaw : vec4<f32> = textureLoad(sogQuatsTex, src, 0);
let c0 : vec4<f32> = textureLoad(sogSh0Tex, src, 0);
let q16 : vec3<f32> = (mU * 256.0 + mL) * (255.0 / 65535.0);
let nPos : vec3<f32> = mix(uniforms.sogMeansMin, uniforms.sogMeansMax, q16);
let center : vec3<f32> = sign(nPos) * (exp(abs(nPos)) - vec3<f32>(1.0));
var splatScale : vec3<f32>;
if (uniforms.uVersion == 2) {
let sIdx : vec3<f32> = floor(sRaw * 255.0 + 0.5);
splatScale.x = exp(textureLoad(sogCodebookTex, vec2<i32>(i32(sIdx.x), 0), 0).r);
splatScale.y = exp(textureLoad(sogCodebookTex, vec2<i32>(i32(sIdx.y), 0), 0).r);
splatScale.z = exp(textureLoad(sogCodebookTex, vec2<i32>(i32(sIdx.z), 0), 0).r);
} else {
splatScale = exp(mix(uniforms.sogScalesMin, uniforms.sogScalesMax, sRaw));
}
let invSqrt2 : f32 = 0.70710678118;
let qabc : vec3<f32> = (qRaw.xyz - vec3<f32>(0.5)) * 2.0 * invSqrt2;
let qMode : i32 = i32(qRaw.w * 255.0 + 0.5) - 252;
let qd : f32 = sqrt(max(0.0, 1.0 - dot(qabc, qabc)));
var quat : vec4<f32>;
if (qMode == 0) {
quat = vec4<f32>(qd, qabc.x, qabc.y, qabc.z);
} else if (qMode == 1) {
quat = vec4<f32>(qabc.x, qd, qabc.y, qabc.z);
} else if (qMode == 2) {
quat = vec4<f32>(qabc.x, qabc.y, qd, qabc.z);
} else {
quat = vec4<f32>(qabc.x, qabc.y, qabc.z, qd);
}
let qw : f32 = quat.x;
let qx : f32 = quat.y;
let qy : f32 = quat.z;
let qz : f32 = quat.w;
let R : mat3x3<f32> = mat3x3<f32>(
1.0 - 2.0 * (qy * qy + qz * qz), 2.0 * (qx * qy + qw * qz), 2.0 * (qx * qz - qw * qy),
2.0 * (qx * qy - qw * qz), 1.0 - 2.0 * (qx * qx + qz * qz), 2.0 * (qy * qz + qw * qx),
2.0 * (qx * qz + qw * qy), 2.0 * (qy * qz - qw * qx), 1.0 - 2.0 * (qx * qx + qy * qy)
);
let S2 : mat3x3<f32> = mat3x3<f32>(
4.0 * splatScale.x * splatScale.x, 0.0, 0.0,
0.0, 4.0 * splatScale.y * splatScale.y, 0.0,
0.0, 0.0, 4.0 * splatScale.z * splatScale.z
);
let Sigma : mat3x3<f32> = R * S2 * transpose(R);
let SH_C0 : f32 = 0.28209479177387814;
var colRgb : vec3<f32>;
var colA : f32;
if (uniforms.uVersion == 2) {
var c3 : vec3<f32>;
c3.x = textureLoad(sogCodebookTex, vec2<i32>(256 + i32(c0.x * 255.0 + 0.5), 0), 0).r;
c3.y = textureLoad(sogCodebookTex, vec2<i32>(256 + i32(c0.y * 255.0 + 0.5), 0), 0).r;
c3.z = textureLoad(sogCodebookTex, vec2<i32>(256 + i32(c0.z * 255.0 + 0.5), 0), 0).r;
colRgb = vec3<f32>(0.5) + c3 * SH_C0;
colA = c0.w;
} else {
let cLerp : vec4<f32> = mix(uniforms.sogSh0Min, uniforms.sogSh0Max, c0);
colRgb = vec3<f32>(0.5) + cLerp.xyz * SH_C0;
colA = 1.0 / (1.0 + exp(-cLerp.w));
}
fragmentOutputs.fragData0 = vec4<f32>(center, 1.0);
fragmentOutputs.fragData1 = vec4<f32>(Sigma[0][0], Sigma[0][1], Sigma[0][2], Sigma[1][1]);
fragmentOutputs.fragData2 = vec4<f32>(Sigma[1][2], Sigma[2][2], 0.0, 0.0);
fragmentOutputs.fragData3 = vec4<f32>(colRgb, colA);
}
`;
/**
* Shader name for the rotation/scale decode pass (the three half-float textures voxel-IBL shadowing consumes).
*/
const GaussianSplattingWorkBufferRotationDecodeShaderName = "gsSogRotDecodeToWorkBuffer";
/**
* Rotation/scale decode fragment shader (GLSL/WebGL2). Reconstructs each splat's rotation matrix R and scale and
* writes the three half-float textures the voxel shader samples (rotationsATexture/B/Scale). The layout lets the
* voxel shader reconstruct the same R and scale, so streamed splats get the same ellipsoid the draw path renders:
* rotA = (R col0.xyz, R col1.x)
* rotB = (R col1.yz, R col2.xy)
* rotScale = (R col2.z, 2*scale.x, 2*scale.y, 2*scale.z)
*/
const GaussianSplattingWorkBufferRotationDecodeFragmentShaderGLSL = `precision highp float;
precision highp int;
uniform sampler2D sogScalesTex;
uniform sampler2D sogQuatsTex;
uniform sampler2D sogCodebookTex;
uniform vec3 sogScalesMin;
uniform vec3 sogScalesMax;
uniform int uVersion;
uniform int uOffset;
uniform int uCount;
uniform int uDestWidth;
uniform int uSrcWidth;
layout(location = 0) out vec4 glFragData[3];
void main() {
ivec2 p = ivec2(gl_FragCoord.xy);
int global = p.y * uDestWidth + p.x;
if (global < uOffset || global >= uOffset + uCount) {
discard;
}
int k = global - uOffset;
ivec2 src = ivec2(k - (k / uSrcWidth) * uSrcWidth, k / uSrcWidth);
vec3 sRaw = texelFetch(sogScalesTex, src, 0).xyz;
vec4 qRaw = texelFetch(sogQuatsTex, src, 0);
vec3 splatScale;
if (uVersion == 2) {
vec3 sIdx = floor(sRaw * 255.0 + 0.5);
splatScale.x = exp(texelFetch(sogCodebookTex, ivec2(int(sIdx.x), 0), 0).r);
splatScale.y = exp(texelFetch(sogCodebookTex, ivec2(int(sIdx.y), 0), 0).r);
splatScale.z = exp(texelFetch(sogCodebookTex, ivec2(int(sIdx.z), 0), 0).r);
} else {
splatScale = exp(mix(sogScalesMin, sogScalesMax, sRaw));
}
const float invSqrt2 = 0.70710678118;
vec3 qabc = (qRaw.xyz - vec3(0.5)) * 2.0 * invSqrt2;
int qMode = int(qRaw.w * 255.0 + 0.5) - 252;
float qd = sqrt(max(0.0, 1.0 - dot(qabc, qabc)));
vec4 quat;
if (qMode == 0) {
quat = vec4(qd, qabc.x, qabc.y, qabc.z);
} else if (qMode == 1) {
quat = vec4(qabc.x, qd, qabc.y, qabc.z);
} else if (qMode == 2) {
quat = vec4(qabc.x, qabc.y, qd, qabc.z);
} else {
quat = vec4(qabc.x, qabc.y, qabc.z, qd);
}
float qw = quat.x, qx = quat.y, qy = quat.z, qz = quat.w;
mat3 R = mat3(
1.0 - 2.0 * (qy * qy + qz * qz), 2.0 * (qx * qy + qw * qz), 2.0 * (qx * qz - qw * qy),
2.0 * (qx * qy - qw * qz), 1.0 - 2.0 * (qx * qx + qz * qz), 2.0 * (qy * qz + qw * qx),
2.0 * (qx * qz + qw * qy), 2.0 * (qy * qz - qw * qx), 1.0 - 2.0 * (qx * qx + qy * qy)
);
glFragData[0] = vec4(R[0], R[1].x);
glFragData[1] = vec4(R[1].y, R[1].z, R[2].x, R[2].y);
glFragData[2] = vec4(R[2].z, 2.0 * splatScale.x, 2.0 * splatScale.y, 2.0 * splatScale.z);
}
`;
/**
* Rotation/scale decode fragment shader (WGSL/WebGPU) — same decode as the GLSL variant, writing 3 half-float MRT
* attachments.
*/
const GaussianSplattingWorkBufferRotationDecodeFragmentShaderWGSL = `
var sogScalesTexSampler : sampler;
var sogScalesTex : texture_2d<f32>;
var sogQuatsTexSampler : sampler;
var sogQuatsTex : texture_2d<f32>;
var sogCodebookTexSampler : sampler;
var sogCodebookTex : texture_2d<f32>;
uniform sogScalesMin : vec3<f32>;
uniform sogScalesMax : vec3<f32>;
uniform uVersion : i32;
uniform uOffset : i32;
uniform uCount : i32;
uniform uDestWidth : i32;
uniform uSrcWidth : i32;
@fragment
fn main(input : FragmentInputs) -> FragmentOutputs {
let p : vec2<i32> = vec2<i32>(i32(fragmentInputs.position.x), i32(fragmentInputs.position.y));
let global : i32 = p.y * uniforms.uDestWidth + p.x;
if (global < uniforms.uOffset || global >= uniforms.uOffset + uniforms.uCount) {
discard;
}
let k : i32 = global - uniforms.uOffset;
let src : vec2<i32> = vec2<i32>(k - (k / uniforms.uSrcWidth) * uniforms.uSrcWidth, k / uniforms.uSrcWidth);
let sRaw : vec3<f32> = textureLoad(sogScalesTex, src, 0).xyz;
let qRaw : vec4<f32> = textureLoad(sogQuatsTex, src, 0);
var splatScale : vec3<f32>;
if (uniforms.uVersion == 2) {
let sIdx : vec3<f32> = floor(sRaw * 255.0 + 0.5);
splatScale.x = exp(textureLoad(sogCodebookTex, vec2<i32>(i32(sIdx.x), 0), 0).r);
splatScale.y = exp(textureLoad(sogCodebookTex, vec2<i32>(i32(sIdx.y), 0), 0).r);
splatScale.z = exp(textureLoad(sogCodebookTex, vec2<i32>(i32(sIdx.z), 0), 0).r);
} else {
splatScale = exp(mix(uniforms.sogScalesMin, uniforms.sogScalesMax, sRaw));
}
let invSqrt2 : f32 = 0.70710678118;
let qabc : vec3<f32> = (qRaw.xyz - vec3<f32>(0.5)) * 2.0 * invSqrt2;
let qMode : i32 = i32(qRaw.w * 255.0 + 0.5) - 252;
let qd : f32 = sqrt(max(0.0, 1.0 - dot(qabc, qabc)));
var quat : vec4<f32>;
if (qMode == 0) {
quat = vec4<f32>(qd, qabc.x, qabc.y, qabc.z);
} else if (qMode == 1) {
quat = vec4<f32>(qabc.x, qd, qabc.y, qabc.z);
} else if (qMode == 2) {
quat = vec4<f32>(qabc.x, qabc.y, qd, qabc.z);
} else {
quat = vec4<f32>(qabc.x, qabc.y, qabc.z, qd);
}
let qw : f32 = quat.x;
let qx : f32 = quat.y;
let qy : f32 = quat.z;
let qz : f32 = quat.w;
let R : mat3x3<f32> = mat3x3<f32>(
1.0 - 2.0 * (qy * qy + qz * qz), 2.0 * (qx * qy + qw * qz), 2.0 * (qx * qz - qw * qy),
2.0 * (qx * qy - qw * qz), 1.0 - 2.0 * (qx * qx + qz * qz), 2.0 * (qy * qz + qw * qx),
2.0 * (qx * qz + qw * qy), 2.0 * (qy * qz - qw * qx), 1.0 - 2.0 * (qx * qx + qy * qy)
);
fragmentOutputs.fragData0 = vec4<f32>(R[0], R[1].x);
fragmentOutputs.fragData1 = vec4<f32>(R[1].y, R[1].z, R[2].x, R[2].y);
fragmentOutputs.fragData2 = vec4<f32>(R[2].z, 2.0 * splatScale.x, 2.0 * splatScale.y, 2.0 * splatScale.z);
}
`;
/**
* Shader name for the SOG higher-order SH decode pass (bakes one packed-u32 SH texture per pass).
*/
const GaussianSplattingWorkBufferShDecodeShaderName = "gsSogShDecodeToWorkBuffer";
/**
* SH decode fragment shader (GLSL/WebGL2). Decodes one SOG file's higher-order spherical-harmonics into one
* packed-u32 SH texture at the region offset, in the layout the draw path's `computeSHWeighted`/`decompose`
* expects (16 SH scalar-bytes per RGBA-u32 texel, little-endian; one texel per splat). Run once per SH texture
* (`uShTextureIndex` selects the 16 scalars written this pass). Coefficients this file lacks are written neutral
* (128 == 0 after `decompose`), so a lower-band file mixes correctly with higher-band ones.
*/
const GaussianSplattingWorkBufferShDecodeFragmentShaderGLSL = `precision highp float;
precision highp int;
uniform sampler2D sogShLabelsTex;
uniform sampler2D sogShCentroidsTex;
uniform sampler2D sogCodebookTex;
uniform float sogShnMin;
uniform float sogShnMax;
uniform int uVersion;
uniform int uOffset;
uniform int uCount;
uniform int uDestWidth;
uniform int uSrcWidth;
uniform int uCoeffs;
uniform int uShTextureIndex;
layout(location = 0) out uvec4 outSh;
void main() {
ivec2 p = ivec2(gl_FragCoord.xy);
int global = p.y * uDestWidth + p.x;
if (global < uOffset || global >= uOffset + uCount) {
discard;
}
int kLocal = global - uOffset;
// 16-bit label for this source splat (LSB in r, MSB in g), indexed over the labels texture's own width.
ivec2 lsz = textureSize(sogShLabelsTex, 0);
ivec2 lsrc = ivec2(kLocal - (kLocal / lsz.x) * lsz.x, kLocal / lsz.x);
vec4 labelRaw = texelFetch(sogShLabelsTex, lsrc, 0);
int n = int(labelRaw.r * 255.0 + 0.5) + int(labelRaw.g * 255.0 + 0.5) * 256;
int u = (n - (n / 64) * 64) * uCoeffs;
int v = n / 64;
uint packed0 = 0u;
uint packed1 = 0u;
uint packed2 = 0u;
uint packed3 = 0u;
for (int b = 0; b < 16; b++) {
int s = uShTextureIndex * 16 + b; // flat SH scalar index
int kc = s / 3; // higher-order coefficient (0-based)
int j = s - kc * 3; // channel (0=r,1=g,2=b)
float byteVal = 128.0; // neutral (decompose(128) ~= 0)
if (kc < uCoeffs) {
vec4 centroidRaw = texelFetch(sogShCentroidsTex, ivec2(u + kc, v), 0);
float ch = (j == 0) ? centroidRaw.r : ((j == 1) ? centroidRaw.g : centroidRaw.b);
float coeff;
if (uVersion == 2) {
int cidx = int(ch * 255.0 + 0.5);
coeff = texelFetch(sogCodebookTex, ivec2(512 + cidx, 0), 0).r;
} else {
coeff = mix(sogShnMin, sogShnMax, ch);
}
byteVal = clamp(coeff * 127.5 + 127.5, 0.0, 255.0);
}
uint bv = uint(byteVal + 0.5);
int comp = b / 4;
uint contrib = bv << uint((b - comp * 4) * 8);
if (comp == 0) { packed0 |= contrib; }
else if (comp == 1) { packed1 |= contrib; }
else if (comp == 2) { packed2 |= contrib; }
else { packed3 |= contrib; }
}
outSh = uvec4(packed0, packed1, packed2, packed3);
}
`;
/**
* SH decode fragment shader (WGSL/WebGPU) — same as the GLSL variant. The integer output (`vec4<u32>` fragData)
* requires the WGSL processor's integer-fragData support.
*/
const GaussianSplattingWorkBufferShDecodeFragmentShaderWGSL = `
var sogShLabelsTexSampler : sampler;
var sogShLabelsTex : texture_2d<f32>;
var sogShCentroidsTexSampler : sampler;
var sogShCentroidsTex : texture_2d<f32>;
var sogCodebookTexSampler : sampler;
var sogCodebookTex : texture_2d<f32>;
uniform sogShnMin : f32;
uniform sogShnMax : f32;
uniform uVersion : i32;
uniform uOffset : i32;
uniform uCount : i32;
uniform uDestWidth : i32;
uniform uSrcWidth : i32;
uniform uCoeffs : i32;
uniform uShTextureIndex : i32;
@fragment
fn main(input : FragmentInputs) -> FragmentOutputs {
let p : vec2<i32> = vec2<i32>(i32(fragmentInputs.position.x), i32(fragmentInputs.position.y));
let global : i32 = p.y * uniforms.uDestWidth + p.x;
if (global < uniforms.uOffset || global >= uniforms.uOffset + uniforms.uCount) {
discard;
}
let kLocal : i32 = global - uniforms.uOffset;
let lsz : vec2<i32> = vec2<i32>(textureDimensions(sogShLabelsTex, 0));
let lsrc : vec2<i32> = vec2<i32>(kLocal - (kLocal / lsz.x) * lsz.x, kLocal / lsz.x);
let labelRaw : vec4<f32> = textureLoad(sogShLabelsTex, lsrc, 0);
let n : i32 = i32(labelRaw.r * 255.0 + 0.5) + i32(labelRaw.g * 255.0 + 0.5) * 256;
let u : i32 = (n - (n / 64) * 64) * uniforms.uCoeffs;
let v : i32 = n / 64;
var packed : array<u32, 4> = array<u32, 4>(0u, 0u, 0u, 0u);
for (var b : i32 = 0; b < 16; b = b + 1) {
let s : i32 = uniforms.uShTextureIndex * 16 + b;
let kc : i32 = s / 3;
let j : i32 = s - kc * 3;
var byteVal : f32 = 128.0;
if (kc < uniforms.uCoeffs) {
let centroidRaw : vec4<f32> = textureLoad(sogShCentroidsTex, vec2<i32>(u + kc, v), 0);
var ch : f32 = centroidRaw.b;
if (j == 0) { ch = centroidRaw.r; } else if (j == 1) { ch = centroidRaw.g; }
var coeff : f32;
if (uniforms.uVersion == 2) {
let cidx : i32 = i32(ch * 255.0 + 0.5);
coeff = textureLoad(sogCodebookTex, vec2<i32>(512 + cidx, 0), 0).r;
} else {
coeff = mix(uniforms.sogShnMin, uniforms.sogShnMax, ch);
}
byteVal = clamp(coeff * 127.5 + 127.5, 0.0, 255.0);
}
let bv : u32 = u32(byteVal + 0.5);
let comp : i32 = b / 4;
packed[comp] = packed[comp] | (bv << u32((b - comp * 4) * 8));
}
fragmentOutputs.fragData0 = vec4<u32>(packed[0], packed[1], packed[2], packed[3]);
}
`;
/**
* Shader name for the work-buffer relayout (defrag/compaction) copy pass.
*/
const GaussianSplattingWorkBufferRelayoutShaderName = "gsWorkBufferRelayout";
/**
* Relayout copy fragment shader (GLSL/WebGL2). Copies the four decoded work-buffer textures from a source
* layout to a destination layout, one output texel per draw. In map mode (`uUseMap == 1`) each destination
* texel reads its source splat index from `uMapTex` (R32F; a negative value marks a gap and is discarded so
* the cleared destination stays zero). In identity mode the source texel equals the destination texel.
*/
const GaussianSplattingWorkBufferRelayoutFragmentShaderGLSL = `precision highp float;
precision highp int;
uniform sampler2D uMapTex;
uniform sampler2D uSrc0;
uniform sampler2D uSrc1;
uniform sampler2D uSrc2;
uniform sampler2D uSrc3;
uniform int uDstWidth;
uniform int uSrcWidth;
uniform int uUseMap;
// Region-scoped relayout (hosted compound atlas), both default 0 (standalone square path unchanged):
// uSrcBaseOffset — added to the map's (region-local) source index so pass 1 reads the correct GLOBAL atlas texel.
// uDstBaseRow — subtracted from the atlas destination row so pass 2's identity copy reads the region-local temp.
uniform int uSrcBaseOffset;
uniform int uDstBaseRow;
layout(location = 0) out vec4 glFragData[4];
void main() {
ivec2 p = ivec2(gl_FragCoord.xy);
int srcIdx;
if (uUseMap == 1) {
float m = texelFetch(uMapTex, p, 0).r;
if (m < 0.0) {
discard;
}
srcIdx = uSrcBaseOffset + int(m + 0.5);
} else {
srcIdx = (p.y - uDstBaseRow) * uDstWidth + p.x;
}
ivec2 s = ivec2(srcIdx - (srcIdx / uSrcWidth) * uSrcWidth, srcIdx / uSrcWidth);
glFragData[0] = texelFetch(uSrc0, s, 0);
glFragData[1] = texelFetch(uSrc1, s, 0);
glFragData[2] = texelFetch(uSrc2, s, 0);
glFragData[3] = texelFetch(uSrc3, s, 0);
}
`;
/**
* Shader name for the INTEGER (packed-u32 SH) relayout/backup copy pass. Same index/map/base math as the float
* relayout shader, but samples ONE integer SH source texture (`usampler2D`) and writes ONE integer attachment,
* so it moves one baked SH texture per pass (parallel to the four-out float copy).
*/
const GaussianSplattingWorkBufferShCopyShaderName = "gsWorkBufferShCopy";
/**
* Integer SH relayout/backup copy fragment shader (GLSL/WebGL2). Copies one packed-u32 SH texture from a source
* layout to a destination layout, one output texel per draw. Map mode (`uUseMap == 1`) reads each destination
* texel's source splat index from `uMapTex` (R32F; negative = gap, discarded); identity mode copies texel-for-texel
* (region backup/restore). `uSrcBaseOffset`/`uDstBaseRow` scope the copy to a hosted region's atlas rows (default 0).
*/
const GaussianSplattingWorkBufferShCopyFragmentShaderGLSL = `precision highp float;
precision highp int;
precision highp usampler2D;
uniform sampler2D uMapTex;
uniform usampler2D uSrcSh;
uniform int uDstWidth;
uniform int uSrcWidth;
uniform int uUseMap;
uniform int uSrcBaseOffset;
uniform int uDstBaseRow;
layout(location = 0) out uvec4 outSh;
void main() {
ivec2 p = ivec2(gl_FragCoord.xy);
int srcIdx;
if (uUseMap == 1) {
float m = texelFetch(uMapTex, p, 0).r;
if (m < 0.0) {
discard;
}
srcIdx = uSrcBaseOffset + int(m + 0.5);
} else {
srcIdx = (p.y - uDstBaseRow) * uDstWidth + p.x;
}
ivec2 s = ivec2(srcIdx - (srcIdx / uSrcWidth) * uSrcWidth, srcIdx / uSrcWidth);
outSh = texelFetch(uSrcSh, s, 0);
}
`;
/**
* Integer SH relayout/backup copy fragment shader (WGSL/WebGPU) — same copy as the GLSL variant. The integer output
* (`vec4<u32>` fragData) requires the WGSL processor's integer-fragData support.
*/
const GaussianSplattingWorkBufferShCopyFragmentShaderWGSL = `
var uMapTexSampler : sampler;
var uMapTex : texture_2d<f32>;
// Integer source sampled via textureLoad only — NO paired sampler (a sampler on a Uint texture fails WebGPU
// validation: "None of the supported sample types (Uint)"). Mirrors the draw shader's shTexture0 declaration.
var uSrcSh : texture_2d<u32>;
uniform uDstWidth : i32;
uniform uSrcWidth : i32;
uniform uUseMap : i32;
uniform uSrcBaseOffset : i32;
uniform uDstBaseRow : i32;
@fragment
fn main(input : FragmentInputs) -> FragmentOutputs {
let p : vec2<i32> = vec2<i32>(i32(fragmentInputs.position.x), i32(fragmentInputs.position.y));
var srcIdx : i32;
if (uniforms.uUseMap == 1) {
let m : f32 = textureLoad(uMapTex, p, 0).r;
if (m < 0.0) {
discard;
}
srcIdx = uniforms.uSrcBaseOffset + i32(m + 0.5);
} else {
srcIdx = (p.y - uniforms.uDstBaseRow) * uniforms.uDstWidth + p.x;
}
let s : vec2<i32> = vec2<i32>(srcIdx - (srcIdx / uniforms.uSrcWidth) * uniforms.uSrcWidth, srcIdx / uniforms.uSrcWidth);
// Wrap in an explicit vec4<u32> so the WGSL processor emits an integer fragData location (its detection keys
// off a literal vec4<u32>/vec4u in the assignment; a bare textureLoad(...) would default to vec4<f32>).
fragmentOutputs.fragData0 = vec4<u32>(textureLoad(uSrcSh, s, 0));
}
`;
/**
* Shader name for the rotation/scale relayout/backup copy pass. Same index/map/base math as the four-out float
* relayout shader, but moves the THREE half-float rotation/scale textures in one pass (their own separate atlas).
*/
const GaussianSplattingWorkBufferRotCopyShaderName = "gsWorkBufferRotCopy";
/**
* Rotation/scale relayout/backup copy fragment shader (GLSL/WebGL2). Copies the three half-float rotation/scale
* textures from a source layout to a destination layout, one output texel per draw. Identical to the four-out
* float relayout shader but with three attachments (the rotation atlas has no fourth texture).
*/
const GaussianSplattingWorkBufferRotCopyFragmentShaderGLSL = `precision highp float;
precision highp int;
uniform sampler2D uMapTex;
uniform sampler2D uSrc0;
uniform sampler2D uSrc1;
uniform sampler2D uSrc2;
uniform int uDstWidth;
uniform int uSrcWidth;
uniform int uUseMap;
uniform int uSrcBaseOffset;
uniform int uDstBaseRow;
layout(location = 0) out vec4 glFragData[3];
void main() {
ivec2 p = ivec2(gl_FragCoord.xy);
int srcIdx;
if (uUseMap == 1) {
float m = texelFetch(uMapTex, p, 0).r;
if (m < 0.0) {
discard;
}
srcIdx = uSrcBaseOffset + int(m + 0.5);
} else {
srcIdx = (p.y - uDstBaseRow) * uDstWidth + p.x;
}
ivec2 s = ivec2(srcIdx - (srcIdx / uSrcWidth) * uSrcWidth, srcIdx / uSrcWidth);
glFragData[0] = texelFetch(uSrc0, s, 0);
glFragData[1] = texelFetch(uSrc1, s, 0);
glFragData[2] = texelFetch(uSrc2, s, 0);
}
`;
/**
* Rotation/scale relayout/backup copy fragment shader (WGSL/WebGPU) — same copy as the GLSL variant, 3 attachments.
*/
const GaussianSplattingWorkBufferRotCopyFragmentShaderWGSL = `
var uMapTexSampler : sampler;
var uMapTex : texture_2d<f32>;
var uSrc0Sampler : sampler;
var uSrc0 : texture_2d<f32>;
var uSrc1Sampler : sampler;
var uSrc1 : texture_2d<f32>;
var uSrc2Sampler : sampler;
var uSrc2 : texture_2d<f32>;
uniform uDstWidth : i32;
uniform uSrcWidth : i32;
uniform uUseMap : i32;
uniform uSrcBaseOffset : i32;
uniform uDstBaseRow : i32;
@fragment
fn main(input : FragmentInputs) -> FragmentOutputs {
let p : vec2<i32> = vec2<i32>(i32(fragmentInputs.position.x), i32(fragmentInputs.position.y));
var srcIdx : i32;
if (uniforms.uUseMap == 1) {
let m : f32 = textureLoad(uMapTex, p, 0).r;
if (m < 0.0) {
discard;
}
srcIdx = uniforms.uSrcBaseOffset + i32(m + 0.5);
} else {
srcIdx = (p.y - uniforms.uDstBaseRow) * uniforms.uDstWidth + p.x;
}
let s : vec2<i32> = vec2<i32>(srcIdx - (srcIdx / uniforms.uSrcWidth) * uniforms.uSrcWidth, srcIdx / uniforms.uSrcWidth);
fragmentOutputs.fragData0 = textureLoad(uSrc0, s, 0);
fragmentOutputs.fragData1 = textureLoad(uSrc1, s, 0);
fragmentOutputs.fragData2 = textureLoad(uSrc2, s, 0);
}
`;
/**
* Relayout copy fragment shader (WGSL/WebGPU) — same copy as the GLSL variant.
*/
const GaussianSplattingWorkBufferRelayoutFragmentShaderWGSL = `
var uMapTexSampler : sampler;
var uMapTex : texture_2d<f32>;
var uSrc0Sampler : sampler;
var uSrc0 : texture_2d<f32>;
var uSrc1Sampler : sampler;
var uSrc1 : texture_2d<f32>;
var uSrc2Sampler : sampler;
var uSrc2 : texture_2d<f32>;
var uSrc3Sampler : sampler;
var uSrc3 : texture_2d<f32>;
uniform uDstWidth : i32;
uniform uSrcWidth : i32;
uniform uUseMap : i32;
// Region-scoped relayout (hosted compound atlas), both default 0 (standalone square path unchanged).
uniform uSrcBaseOffset : i32;
uniform uDstBaseRow : i32;
@fragment
fn main(input : FragmentInputs) -> FragmentOutputs {
let p : vec2<i32> = vec2<i32>(i32(fragmentInputs.position.x), i32(fragmentInputs.position.y));
var srcIdx : i32;
if (uniforms.uUseMap == 1) {
let m : f32 = textureLoad(uMapTex, p, 0).r;
if (m < 0.0) {
discard;
}
srcIdx = uniforms.uSrcBaseOffset + i32(m + 0.5);
} else {
srcIdx = (p.y - uniforms.uDstBaseRow) * uniforms.uDstWidth + p.x;
}
let s : vec2<i32> = vec2<i32>(srcIdx - (srcIdx / uniforms.uSrcWidth) * uniforms.uSrcWidth, srcIdx / uniforms.uSrcWidth);
fragmentOutputs.fragData0 = textureLoad(uSrc0, s, 0);
fragmentOutputs.fragData1 = textureLoad(uSrc1, s, 0);
fragmentOutputs.fragData2 = textureLoad(uSrc2, s, 0);
fragmentOutputs.fragData3 = textureLoad(uSrc3, s, 0);
}
`;
/**
* A unified, GPU-decoded Gaussian Splatting work buffer.
*
* Holds a square MRT texture set (centers / covA / covB / colors) sized to a fixed splat capacity
* (`ceil(sqrt(capacity))`). Each streamed SOG file is decoded directly on the GPU
* (no CPU readback) into its allocated pixel range. The decoded textures are consumed unchanged by the
* standard (non-SOG) Gaussian Splatting draw path.
*
* @experimental
*/
class GaussianSplattingWorkBuffer {
/**
* True when the engine supports the non-blocking GPU readback used by {@link readCentersRangeAsync}:
* WebGL2 (PBO + fence) or WebGPU (copyTextureToBuffer + mapAsync). When false (e.g. WebGL1), callers must
* decode positions on the CPU instead.
*/
get supportsAsyncCentersReadback() {
const engine = this._scene.getEngine();
if (engine.isWebGPU) {
return true;
}
const glEngine = engine;
return !!glEngine._gl && typeof glEngine._readPixelsAsync === "function" && (glEngine.webGLVersion ?? 0) >= 2;
}
/**
* Square edge length (in pixels) of the work-buffer textures.
*/
get textureSize() {
return this._textureSize;
}
/**
* The decoded work-buffer textures: [centers, covA, covB, colors].
*/
get textures() {
return this._mrt.textures;
}
/**
* The baked higher-order SH textures (packed-u32, one per `ceil(coeffs*3/16)`), consumed by the draw path's
* `computeSHWeighted`/`decompose` as `shTexture0..N`. Empty when SH decoding is not enabled.
*/
get shTextures() {
return this._shMrts.map((m) => m.textures[0]);
}
/**
* The decoded rotation/scale textures ([rotationsA, rotationsB, rotationScale], half-float), consumed by the
* voxel-IBL path as `rotationsATexture`/`rotationsBTexture`/`rotationScaleTexture`. Empty when rotation decode
* is not enabled.
*/
get rotationTextures() {
return this._rotMrt ? this._rotMrt.textures : [];
}
/**
* Creates a work buffer sized to hold `capacity` splats.
*
* Standalone (default): the work buffer creates and owns a square MRT sized `ceil(sqrt(capacity))`, with
* decodes addressed from splat 0.
*
* Hosted (`externalAtlas` provided): the work buffer decodes/reads back into an externally-owned MRT (a
* compound mesh's shared atlas) instead of creating its own. Decodes are placed at `externalAtlas.baseOffset`
* (the reserved region's first splat) and addressed over `externalAtlas.width` (the wide atlas width), so the
* streamed splats land in the compound's atlas and sort/draw together with the static parts.
* @param scene hosting scene
* @param capacity total number of splats the work buffer must address
* @param externalAtlas optional external atlas to decode into instead of creating an owned square MRT
* @param sh optional higher-order SH decode configuration. `textureCount = ceil(coeffs*3/16)` for the max SH
* degree across the streamed files. Standalone: the work buffer creates that many owned single-attachment
* integer render targets. Hosted: `externalMrts` are the compound's shared SH atlas targets (borrowed).
* @param rotationScale optional rotation/scale decode configuration (for voxel-IBL shadows). When present the
* work buffer decodes each splat's rotation matrix + scale into a 3-attachment half-float target. Standalone:
* the work buffer creates and owns that target. Hosted: `externalMrt` is the compound's shared rotation atlas.
*/
constructor(scene, capacity, externalAtlas, sh, rotationScale) {
// Relayout (defrag) copy material, created lazily on first relayout.
this._copyMaterial = null;
// Reusable destination->source index map for the relayout pass (created lazily, sized to the work buffer).
this._relayoutMapData = null;
this._relayoutMapTexture = null;
// Transient backup of a hosted region's four textures, held between backupRegion() and restoreRegion() so the
// GPU-decoded data survives the compound recreating its atlas on a grow (adding a part / a second stream).
this._backupMrt = null;
this._disposed = false;
// Reused WebGL framebuffer for the async centers readback (created lazily, freed in dispose).
this._readFbo = null;
// Higher-order spherical-harmonics. Each SH texture is its own single-attachment integer render target
// (RGBA_INTEGER/UNSIGNED_INTEGER, 16 SH scalar-bytes per texel) so a decode/relayout pass writes exactly one
// attachment, keeping within WebGPU's per-sample color-attachment byte budget. Empty when SH is off.
this._shMrts = [];
// True when this work buffer owns (and disposes) its SH render targets (standalone). False when they are the
// hosting compound's shared SH atlas (hosted) — borrowed, not disposed. Rebound alongside the core atlas.
this._ownsShMrts = false;
// The SH decode material (packed-u32 dequant), created only when SH is requested. Reused across files/passes.
this._shMaterial = null;
// Integer SH relayout/backup copy material (usampler2D -> uvec4), created lazily alongside the float copy.
this._shCopyMaterial = null;
// Transient region-sized integer backups of the SH textures, held between backupRegion() and restoreRegion()
// so the baked SH survives the compound recreating its SH atlas on a grow/compaction (parallel to _backupMrt).
this._backupShMrts = null;
// Rotation/scale. One 3-attachment half-float render target (rotA / rotB / rotScale) holding the per-splat
// rotation matrix + scale that voxel-based IBL shadowing consumes. Null when rotation decode is off.
this._rotMrt = null;
// True when this work buffer owns (and disposes) its rotation target (standalone). False when it is the hosting
// compound's shared rotation atlas (hosted) — borrowed, rebound alongside the core atlas, never disposed here.
this._ownsRotMrt = false;
// The rotation/scale decode material, created only when rotation is requested. Reused across files.
this._rotMaterial = null;
// Rotation/scale relayout/backup copy material (3-out float copy), created lazily alongside the float copy.
this._rotCopyMaterial = null;
// Transient region-sized backup of the rotation textures, held between backupRegion() and restoreRegion() so the
// decoded rotation/scale survives the compound recreating its rotation atlas on a grow/compaction.
this._backupRotMrt = null;
this._scene = scene;
this._shaderLanguage = scene.getEngine().isWebGPU ? 1 /* ShaderLanguage.WGSL */ : 0 /* ShaderLanguage.GLSL */;
this._capacity = Math.max(1, capacity);
if (externalAtlas) {
this._mrt = externalAtlas.mrt;
this._textureSize = externalAtlas.width;
this._baseOffset = externalAtlas.baseOffset;
this._ownsMrt = false;
}
else {
this._textureSize = Math.max(1, Math.ceil(Math.sqrt(Math.max(1, capacity))));
this._baseOffset = 0;
this._ownsMrt = true;
// The decode buffer accumulates (clear disabled) so each decode preserves previously-decoded files.
this._mrt = this._createMrt("gsWorkBuffer", true);
}
// Higher-order SH: one single-attachment integer target per packed-u32 SH texture. Standalone owns square
// targets sized like the core atlas; hosted borrows the compound's wide shared SH atlas targets.
if (sh && sh.textureCount > 0) {
if (sh.externalMrts) {
this._shMrts = sh.externalMrts.slice(0, sh.textureCount);
this._ownsShMrts = false;
}
else {
for (let k = 0; k < sh.textureCount; k++) {
this._shMrts.push(this._createShMrt(`gsWorkBufferSh${k}`, true));
}
this._ownsShMrts = true;
}
this._shMaterial = this._createShMaterial();
}
// Rotation/scale: one 3-attachment half-float target. Standalone owns a square target sized like the core
// atlas; hosted borrows the compound's wide shared rotation atlas.
if (rotationScale) {
if (rotationScale.externalMrt) {
this._rotMrt = rotationScale.externalMrt;
this._ownsRotMrt = false;
}
else {
this._rotMrt = this._createRotMrt("gsWorkBufferRot", true);
this._ownsRotMrt = true;
}
this._rotMaterial = this._createRotMaterial();
}
// One persistent decode material + fullscreen-triangle quad, reused (with per-file uniforms)
// for every decode so the shader is compiled only once.
this._material = this._createMaterial();
this._quad = this._createQuad();
this._quad.material = this._material;
// Hosted: start compiling the copy shader now so backupRegion()/restoreRegion() are ready by the time
// the compound grows its atlas (a synchronous, non-frame-driven event we can't wait on).
if (!this._ownsMrt) {
this.isRelayoutReady();
}
}
/**
* Rebinds a hosted work buffer to a new atlas MRT (after the compound recreated it on a grow). No-op for a
* standalone work buffer, which owns its MRT.
* @param mrt the compound's new shared atlas
*/
rebindAtlas(mrt) {
if (!this._ownsMrt) {
this._mrt = mrt;
}
}
/**
* Rebinds a hosted work buffer to the compound's NEW shared SH atlas (after the compound recreated it on a
* grow/compaction). No-op when SH isn't in use or the work buffer owns its SH targets (standalone).
* @param shMrts the compound's new shared SH render targets (one per packed-u32 SH texture)
*/
rebindShAtlas(shMrts) {
if (!this._ownsShMrts && shMrts && this._shMrts.length) {
this._shMrts = shMrts.slice(0, this._shMrts.length);
}
}
/**
* Rebinds a hosted work buffer to the compound's NEW shared rotation atlas (after the compound recreated it on a
* grow/compaction). No-op when rotation isn't in use or the work buffer owns its rotation target (standalone).
* @param rotMrt the compound's new shared rotation atlas
*/
rebindRotAtlas(rotMrt) {
if (!this._ownsRotMrt && rotMrt && this._rotMrt) {
this._rotMrt = rotMrt;
}
}
/**
* Relocates this hosted region to a new base splat offset in the shared atlas (used when the compound
* compacts its atlas and this region's rows move). Subsequent decode/render/readback and
* {@link restoreRegion} all address from the new base. Call between {@link backupRegion} (which read from
* the old base) and {@link restoreRegion} (which will write to the new base). No-op for a standalone
* work buffer, which owns its square MRT at base 0.
* @param baseOffset the region's new first splat index in the atlas
*/
setBaseOffset(baseOffset) {
if (!this._ownsMrt) {
this._baseOffset = baseOffset;
}
}
/**
* True once the backup/restore/relayout copy shaders are compiled, so {@link backupRegion} can preserve the
* region across an atlas rebuild. Callers that decode into a hosted region should await this before writing
* data, so a later grow/compaction can never race shader compilation and drop the region.
*/
get canBackup() {
return this._disposed || this._ownsMrt ? false : this.isRelayoutReady();
}
/**
* Copies this hosted region's four decoded textures out of the shared atlas into an internal backup MRT so
* they survive the compound recreating the atlas on a grow. Call immediately before the atlas is recreated,
* then {@link rebindAtlas} + {@link restoreRegion} after.
*/
backupRegion() {
if (this._disposed || this._ownsMrt) {
return;
}
if (!this.isRelayoutReady()) {
// The copy shaders should be compiled well before any grow (callers warm them before decoding), so this
// path means a grow raced compilation; warn rather than silently drop the region's GPU data on the restore.
Logger.Warn("GaussianSplattingWorkBuffer: backup skipped because the copy shaders are not ready; streamed region data may be lost on the atlas rebuild.");
return;
}
const width = this._textureSize;
const regionRows = Math.max(1, Math.floor(this._capacity / width));
const baseRow = Math.floor(this._baseOffset / width);
if (!this._backupMrt) {
this._backupMrt = this._createMrt("gsAtlasBackup", false, width, regionRows);
}
// Identity copy of the region out of the atlas: with useMap=0 and dstBaseRow=-baseRow the shader reads
// atlas texel ((p.y + baseRow) * width + p.x) — the region's global texel — into backup texel p.
this._renderRelayoutPass(this._backupMrt, this._mrt.textures, this._mrt.textures[0], 0, width, width, 0, -baseRow);
// Same for each baked SH texture (one integer copy pass per texture).
if (this._shMrts.length && this._shCopyMaterial) {
if (!this._backupShMrts) {
this._backupShMrts = this._shMrts.map((_, k) => this._createShMrt(`gsShAtlasBackup${k}`, false, width, regionRows));
}
for (let k = 0; k < this._shMrts.length; k++) {
this._renderShCopyPass(this._backupShMrts[k], this._shMrts[k].textures[0], this._mrt.textures[0], 0, width, width, 0, -baseRow);
}
}
// Same for the rotation/scale textures (one 3-out float copy pass).
if (this._rotMrt && this._rotCopyMaterial) {
if (!this._backupRotMrt) {
this._backupRotMrt = this._createRotMrt("gsRotAtlasBackup", false, width, regionRows);
}
this._renderRotCopyPass(this._backupRotMrt, this._rotMrt.textures, this._mrt.textures[0], 0, width, width, 0, -baseRow);
}
this._quad.material = this._material;
}
/**
* Restores the backed-up region into the (new) atlas, confined by a scissor to the region's rows so no other
* part is touched. Call {@link rebindAtlas} to the new atlas first. Frees the backup afterwards.
*/
restoreRegion() {
if (this._disposed || this._ownsMrt || !this._backupMrt || !this._copyMaterial) {
return;
}
const width = this._textureSize;
const regionRows = Math.max(1, Math.floor(this._capacity / width));
const baseRow = Math.floor(this._baseOffset / width);
const engine = this._scene.getEngine();
engine.enableScissor(0, baseRow, width, regionRows);
try {
// useMap=0 identity with dstBaseRow=baseRow: atlas texel p reads backup texel ((p.y - baseRow)*width + p.x).
this._renderRelayoutPass(this._mrt, this._backupMrt.textures, this._backupMrt.textures[0], 0, width, width, 0, baseRow);
// Restore each baked SH texture the same way (scissored to the region's rows). The map placeholder must
// be a FLOAT texture (uMapTex is texture_2d<f32>) even though useMap=0 doesn't sample it — WebGPU still
// validates the bound texture's sample type against the layout. Use the atlas centers (F32).
if (this._backupShMrts && this._shMrts.length && this._shCopyMaterial) {
for (let k = 0; k < this._shMrts.length && k < this._backupShMrts.length; k++) {
this._renderShCopyPass(this._shMrts[k], this._backupShMrts[k].textures[0], this._mrt.textures[0], 0, width, width, 0, baseRow);
}
}
// Restore the rotation/scale textures the same way (3-out float copy, scissored to the region's rows).
if (this._backupRotMrt && this._rotMrt && this._rotCopyMaterial) {
this._renderRotCopyPass(this._rotMrt, this._backupRotMrt.textures, this._mrt.textures[0], 0, width, width, 0, baseRow);
}
}
finally {
engine.disableScissor();
this._quad.material = this._material;
}
this._backupMrt.dispose();
this._backupMrt = null;
if (this._backupShMrts) {
for (const mrt of this._backupShMrts) {
mrt.dispose();
}
this._backupShMrts = null;
}
this._backupRotMrt?.dispose();
this._backupRotMrt = null;
}
/**
* Creates a 4-attachment MRT (centers F32 / covA / covB / colors U8) sized to the work buffer. covA/covB
* use HALF_FLOAT when the engine can render to it, matching the precision the non-streamed
* GaussianSplattingMesh path already uses for these same two textures (see
* `gaussianSplattingMeshBase.pure.ts`'s `createTextureFromDataF16` covA/covB textures); centers stays F32
* and colors stays U8 in both paths.
* @param name MRT and attachment base name
* @param disableClear when true, clearing is suppressed so renders accumulate (the decode buffer); when
* false the MRT clears to zero on each render (the temporary relayout buffer, so gaps stay zeroed)
* @param width texture width (defaults to the work-buffer size; the region row width for a scoped relayout)
* @param height texture height (defaults to the work-buffer size; the region row count for a scoped relayout)
* @returns the created MRT
*/
_createMrt(name, disableClear, width = this._textureSize, height = this._textureSize) {
const covType = this._scene.getEngine()._caps.textureHalfFloatRender ? Constants.TEXTURETYPE_HALF_FLOAT : Constants.TEXTURETYPE_FLOAT;
const mrt = new MultiRenderTarget(name, { width, height }, 4, this._scene, {
types: [Constants.TEXTURETYPE_FLOAT, covType, covType, Constants.TEXTURETYPE_UNSIGNED_BYTE],
samplingModes: [
Constants.TEXTURE_NEAREST_SAMPLINGMODE,
Constants.TEXTURE_NEAREST_SAMPLINGMODE,
Constants.TEXTURE_NEAREST_SAMPLINGMODE,
Constants.TEXTURE_NEAREST_SAMPLINGMODE,
],
formats: [Constants.TEXTUREFORMAT_RGBA, Constants.TEXTUREFORMAT_RGBA, Constants.TEXTUREFORMAT_RGBA, Constants.TEXTUREFORMAT_RGBA],
generateDepthBuffer: false,
generateDepthTexture: false,
generateMipMaps: false,
}, [`${name}Centers`, `${name}CovA`, `${name}CovB`, `${name}Colors`]);
mrt.clearColor = new Color4(0, 0, 0, 0);
mrt.renderList = [];
if (disableClear) {
mrt.onClearObservable.add(() => { });
}
return mrt;
}
/**
* Creates a single-attachment integer render target (RGBA_INTEGER / UNSIGNED_INTEGER) that holds one packed-u32
* baked-SH texture. One attachment per pass keeps within WebGPU's per-sample color-attachment byte budget and
* matches the format/type of the draw path's `shTexture0..N` samplers (`_GaussianSplattingBytesPerShTexel`).
* @param name attachment name
* @param disableClear when true, clearing is suppressed so SH decodes accumulate across files
* @param width texture width (defaults to the work-buffer size)
* @param height texture height (defaults to the work-buffer size)
* @returns the created single-attachment integer MRT
*/
_createShMrt(name, disableClear, width = this._textureSize, height = this._textureSize) {
const mrt = new MultiRenderTarget(name, { width, height }, 1, this._scene, {
types: [Constants.TEXTURETYPE_UNSIGNED_INTEGER],
formats: [Constants.TEXTUREFORMAT_RGBA_INTEGER],
samplingModes: [Constants.TEXTURE_NEAREST_SAMPLINGMODE],
generateDepthBuffer: false,
generateDepthTexture: false,
generateMipMaps: false,
}, [name]);
mrt.clearColor = new Color4(0, 0, 0, 0);
mrt.renderList = [];
if (disableClear) {
mrt.onClearObservable.add(() => { });
}
return mrt;
}
/**
* Creates a 3-attachment half-float MRT ([rotA, rotB, rotScale]) holding the per-splat rotation matrix + scale
* consumed by voxel-IBL shadowing. RGBA half-float when the engine can render to it (matching the covariance
* precision), else full float. 3 attachments = 24 B/sample, within WebGPU's per-sample budget (its own pass).
* @param name MRT and attachment base name
* @param disableClear when true, clearing is suppressed so decodes accumulate (the decode buffer)
* @param width texture width (defaults to the work-buffer size; the region row width for a scoped relayout)
* @param height texture height (defaults to the work-buffer size; the region row count for a scoped relayout)
* @returns the created MRT
*/
_createRotMrt(name, disableClear, width = this._textureSize, height = this._textureSize) {
const rotType = this._scene.getEngine()._caps.textureHalfFloatRender ? Constants.TEXTURETYPE_HALF_FLOAT : Constants.TEXTURETYPE_FLOAT;
const mrt = new MultiRenderTarget(name, { width, height }, 3, this._scene, {
types: [rotType, rotType, rotType],
formats: [Constants.TEXTUREFORMAT_RGBA, Constants.TEXTUREFORMAT_RGBA, Constants.TEXTUREFORMAT_RGBA],
samplingModes: [Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURE_NEAREST_SAMPLINGMODE],
generateDepthBuffer: false,
generateDepthTexture: false,
generateMipMaps: false,
}, [`${name}A`, `${name}B`, `${name}Scale`]);
mrt.clearColor = new Color4(0, 0, 0, 0);
mrt.renderList = [];
if (disableClear) {
mrt.onClearObservable.add(() => { });
}
return mrt;
}
/**
* Decodes one SOG file into the work buffer at the given splat offset (accumulating; previously
* decoded files are preserved). Resolves once the GPU decode has been issued. The caller may
* dispose the source pack textures after this resolves.
* @param pack the SOG texture pack (GPU source textures + per-file decode parameters)
* @param offset first splat index (pixel offset) for this file in the work buffer
*/
async decodeAsync(pack, offset) {
if (this._disposed) {
return;
}
this._applyPack(pack);
// When SH is enabled, bake EVERY file's region — even a file with no higher-order SH (a coarse LOD may drop
// it): those get uCoeffs=0 so the whole region neutral-fills (128), keeping mixed-degree files consistent.
const decodeSh = this._shMaterial !== null && this._shMrts.length > 0;
if (decodeSh) {
this._applyShPack(pack);
}
const decodeRot = this._rotMaterial !== null && this._rotMrt !== null;
if (decodeRot) {
this._applyRotPack(pack);
}
// Render the decode pass at the start of a frame (the safe point for custom render targets),
// once the shader is compiled — never re-entrantly from a promise/observable continuation.
await new Promise((resolve) => {
const attempt = () => {
if (this._disposed) {
resolve();
return;
}
if (!this._material.isReady(this._quad) || (decodeSh && !this._shMaterial.isReady(this._quad)) || (decodeRot && !this._rotMaterial.isReady(this._quad))) {
this._scene.onBeforeRenderObservable.addOnce(attempt);
return;
}
// Scissor every pass to just this file's atlas rows so the fullscreen triangle only shades the region
// it writes, not the whole (wide) atlas. Correct because the shaders already discard outside the exact
// splat range, and the row band is a superset of it.
const width = this._textureSize;
// Bind uOffset at render time from the live _baseOffset so it shares one value with the scissor below:
// compactAtlas() can relocate this region (via setBaseOffset) during the defer window.
const globalOffset = this._baseOffset + offset;
this._material.setInt("uOffset", globalOffset);
if (decodeSh) {
this._shMaterial.setInt("uOffset", globalOffset);
}
if (decodeRot) {
this._rotMaterial.setInt("uOffset", globalOffset);
}
const firstRow = Math.floor(globalOffset / width);
const rowCount = Math.max(1, Math.ceil((globalOffset + pack.splatCount) / width) - firstRow);
const engine = this._scene.getEngine();
engine.enableScissor(0, firstRow, width, rowCount);
try {
this._quad.material = this._material;
this._mrt.renderList = [this._quad];
this._mrt.render();
// Bake this file's higher-order SH: one pass per packed-u32 SH texture (uShTextureIndex selects the
// 16 SH scalars written this pass). Files with fewer coefficients neutral-fill (128) higher bands.
if (decodeSh) {
for (let k = 0; k < this._shMrts.length; k++) {
this._shMaterial.setInt("uShTextureIndex", k);
this._quad.material = this._shMaterial;
this._shMrts[k].renderList = [this._quad];
this._shMrts[k].render();
}
this._quad.material = this._material;
}
// Bake this file's rotation/scale into the 3-attachment half-float rotation target (one pass).
if (decodeRot) {
this._quad.material = this._rotMaterial;
this._rotMrt.renderList = [this._quad];
this._rotMrt.render();
this._quad.material = this._material;
}
}
finally {
engine.disableScissor();
}
resolve();
};
this._scene.onBeforeRenderObservable.addOnce(attempt);
});
}
/**
* Whether the relayout copy shader is compiled and ready. Lazily creates the copy material on first call.
* Callers should poll this before {@link relayoutSync} (which must only run when ready).
* @returns true when {@link relayoutSync} can run this frame
*/
isRelayoutReady() {
if (this._disposed) {
return false;
}
if (!this._copyMaterial) {
this._copyMaterial = this._createCopyMaterial();
}
// When SH is in use, the integer copy shader must also be compiled before a backup/restore/relayout can run.
if (this._shMrts.length && !this._shCopyMaterial) {
this._shCopyMaterial = this._createShCopyMaterial();
}
// Same for the rotation/scale 3-out float copy shader when rotation is in use.
if (this._rotMrt && !this._rotCopyMaterial) {
this._rotCopyMaterial = this._createRotCopyMaterial();
}
// `isReady` reports "not ready" when a bound sampler points at a disposed texture (a previous backup/relayout
// binds its temp MRTs as sources, then frees them). The copy passes bind fresh textures every call, so re-point
// the materials at the live atlas textures first; the check then reflects only whether the shader compiled.
this._bindCopyMaterialsToAtlas();
const shReady = this._shMrts.length === 0 || (this._shCopyMaterial !== null && this._shCopyMaterial.isReady(this._quad));
const rotReady = !this._rotMrt || (this._rotCopyMaterial !== null && this._rotCopyMaterial.isReady(this._quad));
return this._copyMaterial.isReady(this._quad) && shReady && rotReady;
}
/**
* Re-points the relayout/backup copy materials' samplers at the live atlas textures (valid, never freed), so a
* later {@link isRelayoutReady} check isn't tripped by a stale binding to a disposed temp/backup MRT.
*/
_bindCopyMaterialsToAtlas() {
const t = this._mrt.textures;
if (this._copyMaterial) {
this._copyMaterial.setTexture("uMapTex", t[0]);
this._copyMaterial.setTexture("uSrc0", t[0]);
this._copyMaterial.setTexture("uSrc1", t[1]);
this._copyMaterial.setTexture("uSrc2", t[2]);
this._copyMaterial.setTexture("uSrc3", t[3]);
}
if (this._shCopyMaterial && this._shMrts.length) {
this._shCopyMaterial.setTexture("uMapTex", t[0]);
this._shCopyMaterial.setTexture("uSrcSh", this._shMrts[0].textures[0]);
}
if (this._rotCopyMaterial && this._rotMrt) {
const r = this._rotMrt.textures;
this._rotCopyMaterial.setTexture("uMapTex", t[0]);
this._rotCopyMaterial.setTexture("uSrc0", r[0]);
this._rotCopyMaterial.setTexture("uSrc1", r[1]);
this._rotCopyMaterial.setTexture("uSrc2", r[2]);
}
}
/**
* Relayouts the decoded work-buffer textures to a new (defragmented) splat layout, keeping the same
* texture instances so the consuming mesh does not need to re-bind. `srcIndexByDst[d]` is the source splat
* index whose decoded data should end up at destination index `d`, or a negative value for a gap (left
* zeroed). Uses a temporary MRT ping-pong (old -> temp via the map, then temp -> old identity) so
* overlapping moves stay correct. Must be called at a frame-safe point (inside `onBeforeRender`) and only
* when {@link isRelayoutReady} returns true.
* @param srcIndexByDst per-destination source splat index (negative = gap)
*/
relayoutSync(srcIndexByDst) {
if (this._disposed || !this._copyMaterial) {
return;
}
// Map dimensions: standalone maps the whole square buffer; hosted maps just the region's rows.
const width = this._textureSize;
const mapW = width;
const mapH = this._ownsMrt ? width : Math.max(1, Math.floor(this._capacity / width));
// Reuse the map buffer + its GPU texture across relayouts (dimensions are fixed for a work buffer).
if (!this._relayoutMapData) {
this._relayoutMapData = new Float32Array(mapW * mapH);
}
const mapData = this._relayoutMapData;
mapData.fill(-1);
mapData.set(srcIndexByDst.subarray(0, Math.min(srcIndexByDst.length, mapData.length)));
if (!this._relayoutMapTexture) {
this._relayoutMapTexture = new RawTexture(mapData, mapW, mapH, Constants.TEXTUREFORMAT_R, this._scene, false, false, Constants.TEXTURE_NEAREST_SAMPLINGMODE, Constants.TEXTURETYPE_FLOAT);
}
else {
this._relayoutMapTexture.update(mapData);
}
const mapTexture = this._relayoutMapTexture;
if (this._ownsMrt) {
// Standalone: the work buffer owns the whole square texture, so a full ping-pong is safe.
const temp = this._createMrt("gsRelayoutTemp", false);
try {
this._renderRelayoutPass(temp, this._mrt.textures, mapTexture, 1); // old -> temp via map (gaps cleared)
this._renderRelayoutPass(this._mrt, temp.textures, mapTexture, 0); // temp -> old, identity full overwrite
}
finally {
temp.dispose();
}
// Same ping-pong for each baked SH texture (integer copy, one temp per texture).
if (this._shMrts.length && this._shCopyMaterial) {
for (let k = 0; k < this._shMrts.length; k++) {
const shTemp = this._createShMrt("gsShRelayoutTemp", false);
try {
this._renderShCopyPass(shTemp, this._shMrts[k].textures[0], mapTexture, 1);
this._renderShCopyPass(this._shMrts[k], shTemp.textures[0], mapTexture, 0);
}
finally {
shTemp.dispose();
}
}
}
// Same ping-pong for the rotation/scale textures (3-out float copy).
if (this._rotMrt && this._rotCopyMaterial) {
const rotTemp = this._createRotMrt("gsRotRelayoutTemp", false);
try {
this._renderRotCopyPass(rotTemp, this._rotMrt.textures, mapTexture, 1);
this._renderRotCopyPass(this._rotMrt, rotTemp.textures, mapTexture, 0);
}
finally {
rotTemp.dispose();
}
}
this._quad.material = this._material;
return;
}
// Hosted (shared compound atlas): scope the ping-pong to THIS region's row band so other parts are never
// touched. `_baseOffset` and `_capacity` are row-aligned (reserveStreamingPart), so the band is exact.
const baseRow = Math.floor(this._baseOffset / width);
const regionRows = mapH;
const engine = this._scene.getEngine();
// Region-sized temp (width x regionRows) — memory stays proportional to the region, not the whole atlas.
const temp = this._createMrt("gsRelayoutTemp", false, width, regionRows);
// One region-sized integer temp per baked SH texture (same ping-pong, integer format).
const shTemps = this._shMrts.length && this._shCopyMaterial ? this._shMrts.map((_, k) => this._createShMrt(`gsShRelayoutTemp${k}`, false, width, regionRows)) : [];
// One region-sized rotation temp (3-attachment float) when rotation is in use.
const rotTemp = this._rotMrt && this._rotCopyMaterial ? this._createRotMrt("gsRotRelayoutTemp", false, width, regionRows) : null;
try {
// Pass 1: atlas region -> temp via map. The map is region-local; uSrcBaseOffset shifts each source
// index to its GLOBAL atlas texel (uSrcWidth = atlas width). Temp is exactly the band, so no scissor.
this._renderRelayoutPass(temp, this._mrt.textures, mapTexture, 1, /*dstWidth*/ width, /*srcWidth*/ width, /*srcBaseOffset*/ this._baseOffset, /*dstBaseRow*/ 0);
for (let k = 0; k < shTemps.length; k++) {
this._renderShCopyPass(shTemps[k], this._shMrts[k].textures[0], mapTexture, 1, width, width, this._baseOffset, 0);
}
if (rotTemp) {
this._renderRotCopyPass(rotTemp, this._rotMrt.textures, mapTexture, 1, width, width, this._baseOffset, 0);
}
// Pass 2: temp -> atlas region, identity within the band. uDstBaseRow maps the atlas destination row
// back into the region-local temp; the scissor confines writes to the band so static parts are safe.
engine.enableScissor(0, baseRow, width, regionRows);
try {
this._renderRelayoutPass(this._mrt, temp.textures, mapTexture, 0, /*dstWidth*/ width, /*srcWidth*/ width, /*srcBaseOffset*/ 0, /*dstBaseRow*/ baseRow);
for (let k = 0; k < shTemps.length; k++) {
this._renderShCopyPass(this._shMrts[k], shTemps[k].textures[0], mapTexture, 0, width, width, 0, baseRow);
}
if (rotTemp) {
this._renderRotCopyPass(this._rotMrt, rotTemp.textures, mapTexture, 0, width, width, 0, baseRow);
}
}
finally {
engine.disableScissor();
}
}
finally {
temp.dispose();
for (const t of shTemps) {
t.dispose();
}
rotTemp?.dispose();
this._quad.material = this._material;
}
}
/**
* Renders one relayout copy pass into the target MRT, sampling the given source textures.
* @param target destination MRT
* @param sources the four source work-buffer textures
* @param mapTexture the R32F destination-to-source index map (only sampled when `useMap` is 1; any bound texture otherwise)
* @param useMap 1 to read source indices from the map (gaps discarded), 0 for an identity copy
* @param dstWidth destination width used to linearize the destination texel (defaults to the work-buffer size)
* @param srcWidth source width used to convert a linear source index to a texel (defaults to the work-buffer size)
* @param srcBaseOffset added to each mapped source index so a region-local map reads the correct global atlas texel (hosted relayout)
* @param dstBaseRow subtracted from the destination row so an identity copy reads the region-local temp (hosted relayout)
*/
_renderRelayoutPass(target, sources, mapTexture, useMap, dstWidth = this._textureSize, srcWidth = this._textureSize, srcBaseOffset = 0, dstBaseRow = 0) {
const material = this._copyMaterial;
material.setTexture("uMapTex", mapTexture);
material.setTexture("uSrc0", sources[0]);
material.setTexture("uSrc1", sources[1]);
material.setTexture("uSrc2", sources[2]);
material.setTexture("uSrc3", sources[3]);
material.setInt("uDstWidth", dstWidth);
material.setInt("uSrcWidth", srcWidth);
material.setInt("uUseMap", useMap);
material.setInt("uSrcBaseOffset", srcBaseOffset);
material.setInt("uDstBaseRow", dstBaseRow);
this._quad.material = material;
target.renderList = [this._quad];
target.render();
}
_createCopyMaterial() {
const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
const material = new ShaderMaterial(GaussianSplattingWorkBufferRelayoutShaderName, this._scene, {
vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
fragmentSource: isWGSL ? GaussianSplattingWorkBufferRelayoutFragmentShaderWGSL : GaussianSplattingWorkBufferRelayoutFragmentShaderGLSL,
}, {
attributes: ["position"],
uniforms: ["uDstWidth", "uSrcWidth", "uUseMap", "uSrcBaseOffset", "uDstBaseRow"],
samplers: ["uMapTex", "uSrc0", "uSrc1", "uSrc2", "uSrc3"],
shaderLanguage: this._shaderLanguage,
});
material.backFaceCulling = false;
material.disableDepthWrite = true;
return material;
}
/**
* Renders one INTEGER SH copy pass (one packed-u32 SH texture) into the target, sampling one integer source.
* Same index/map/base math as {@link _renderRelayoutPass} but for the integer SH format.
* @param target destination single-attachment integer MRT
* @param srcSh the integer SH source texture
* @param mapTexture the R32F destination-to-source index map (sampled only when `useMap` is 1)
* @param useMap 1 to read source indices from the map (gaps discarded), 0 for an identity copy
* @param dstWidth destination width used to linearize the destination texel
* @param srcWidth source width used to convert a linear source index to a texel
* @param srcBaseOffset added to each mapped source index (region-local map -> global atlas texel)
* @param dstBaseRow subtracted from the destination row for an identity copy of a region-local temp
*/
_renderShCopyPass(target, srcSh, mapTexture, useMap, dstWidth = this._textureSize, srcWidth = this._textureSize, srcBaseOffset = 0, dstBaseRow = 0) {
const material = this._shCopyMaterial;
material.setTexture("uMapTex", mapTexture);
material.setTexture("uSrcSh", srcSh);
material.setInt("uDstWidth", dstWidth);
material.setInt("uSrcWidth", srcWidth);
material.setInt("uUseMap", useMap);
material.setInt("uSrcBaseOffset", srcBaseOffset);
material.setInt("uDstBaseRow", dstBaseRow);
this._quad.material = material;
target.renderList = [this._quad];
target.render();
}
_createShCopyMaterial() {
const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
const material = new ShaderMaterial(GaussianSplattingWorkBufferShCopyShaderName, this._scene, {
vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
fragmentSource: isWGSL ? GaussianSplattingWorkBufferShCopyFragmentShaderWGSL : GaussianSplattingWorkBufferShCopyFragmentShaderGLSL,
}, {
attributes: ["position"],
uniforms: ["uDstWidth", "uSrcWidth", "uUseMap", "uSrcBaseOffset", "uDstBaseRow"],
samplers: ["uMapTex", "uSrcSh"],
shaderLanguage: this._shaderLanguage,
});
material.backFaceCulling = false;
material.disableDepthWrite = true;
return material;
}
/**
* Renders one rotation/scale copy pass (the three half-float rotation textures) into the target. Same
* index/map/base math as {@link _renderRelayoutPass} but with three attachments.
* @param target destination 3-attachment MRT
* @param sources the three source rotation textures ([rotA, rotB, rotScale])
* @param mapTexture the R32F destination-to-source index map (sampled only when `useMap` is 1)
* @param useMap 1 to read source indices from the map (gaps discarded), 0 for an identity copy
* @param dstWidth destination width used to linearize the destination texel
* @param srcWidth source width used to convert a linear source index to a texel
* @param srcBaseOffset added to each mapped source index (region-local map -> global atlas texel)
* @param dstBaseRow subtracted from the destination row for an identity copy of a region-local temp
*/
_renderRotCopyPass(target, sources, mapTexture, useMap, dstWidth = this._textureSize, srcWidth = this._textureSize, srcBaseOffset = 0, dstBaseRow = 0) {
const material = this._rotCopyMaterial;
material.setTexture("uMapTex", mapTexture);
material.setTexture("uSrc0", sources[0]);
material.setTexture("uSrc1", sources[1]);
material.setTexture("uSrc2", sources[2]);
material.setInt("uDstWidth", dstWidth);
material.setInt("uSrcWidth", srcWidth);
material.setInt("uUseMap", useMap);
material.setInt("uSrcBaseOffset", srcBaseOffset);
material.setInt("uDstBaseRow", dstBaseRow);
this._quad.material = material;
target.renderList = [this._quad];
target.render();
}
_createRotCopyMaterial() {
const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
const material = new ShaderMaterial(GaussianSplattingWorkBufferRotCopyShaderName, this._scene, {
vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
fragmentSource: isWGSL ? GaussianSplattingWorkBufferRotCopyFragmentShaderWGSL : GaussianSplattingWorkBufferRotCopyFragmentShaderGLSL,
}, {
attributes: ["position"],
uniforms: ["uDstWidth", "uSrcWidth", "uUseMap", "uSrcBaseOffset", "uDstBaseRow"],
samplers: ["uMapTex", "uSrc0", "uSrc1", "uSrc2"],
shaderLanguage: this._shaderLanguage,
});
material.backFaceCulling = false;
material.disableDepthWrite = true;
return material;
}
/**
* Asynchronously reads back the decoded splat centers (stride-4 xyzw, w=1) for a contiguous splat range
* from the work buffer's centers texture, using a non-blocking GPU readback (WebGL2 PBO + fence, or WebGPU
* copyTextureToBuffer + mapAsync) so it never stalls the frame the way a CPU image decode does. The centers
* texture already holds the GPU-decoded positions (identical to the CPU decode), so this replaces decoding
* positions on the CPU from the means images. Returns null when async readback is unsupported (caller should
* fall back to CPU decoding).
* @param splatOffset first splat index of the range
* @param splatCount number of splats in the range
* @returns a stride-4 Float32Array of length `splatCount * 4`, or null when unsupported/failed
*/
async readCentersRangeAsync(splatOffset, splatCount) {
if (this._disposed || splatCount <= 0 || !this.supportsAsyncCentersReadback) {
return null;
}
const width = this._textureSize;
// Shift the region-local offset by the atlas base so the readback targets the same global texels the
// decode wrote (0 base for a standalone work buffer).
const globalOffset = this._baseOffset + splatOffset;
// The range maps to whole texel rows [rowStart, rowEnd); read that rectangle and slice the exact range.
// Splat i lives at texel (i % width, floor(i / width)) in both decode and draw, so the readback (which
// indexes the same texture storage directly, with no UV/flip) yields splat i at buffer position
// i - rowStart * width on every backend.
const rowStart = Math.floor(globalOffset / width);
const rowEnd = Math.ceil((globalOffset + splatCount) / width);
const rowCount = rowEnd - rowStart;
const startInBuffer = (globalOffset - rowStart * width) * 4;
const sliceEnd = startInBuffer + splatCount * 4;
const centers = this._mrt.textures[0];
const engine = this._scene.getEngine();
if (engine.isWebGPU) {
// WebGPU: copyTextureToBuffer of the row span + mapAsync (genuinely non-blocking). noDataConversion
// returns the raw RGBA32F floats tightly packed (the 256-byte row alignment is removed internally).
const result = await centers.readPixels(0, 0, null, true, true, 0, rowStart, width, rowCount);
if (this._disposed || !result) {
return null;
}
const floats = result instanceof Float32Array ? result : new Float32Array(result.buffer, result.byteOffset, result.byteLength / 4);
return floats.length >= sliceEnd ? floats.subarray(startInBuffer, sliceEnd) : null;
}
// WebGL2: read directly from the centers texture via a reused FBO + async PBO readback.
const glEngine = engine;
const gl = glEngine._gl;
const hardware = centers.getInternalTexture()?._hardwareTexture?.underlyingResource;
if (!hardware) {
return null;
}
const buffer = new Float32Array(width * rowCount * 4);
if (!this._readFbo) {
this._readFbo = gl.createFramebuffer();
}
const previousFbo = glEngine._currentFramebuffer;
gl.bindFramebuffer(gl.FRAMEBUFFER, this._readFbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, hardware, 0);
gl.readBuffer(gl.COLOR_ATTACHMENT0);
// _readPixelsAsync issues the readPixels into a PBO synchronously, then resolves once the GPU fence
// signals — so the framebuffer can be restored immediately while the transfer completes off-thread.
const promise = glEngine._readPixelsAsync(0, rowStart, width, rowCount, gl.RGBA, gl.FLOAT, buffer);
gl.bindFramebuffer(gl.FRAMEBUFFER, previousFbo);
// gl.readBuffer is global state: restore the default-framebuffer read source (BACK) so later
// readPixels on the default framebuffer aren't left pointing at COLOR_ATTACHMENT0. When restoring to
// another FBO, COLOR_ATTACHMENT0 is the correct default read buffer, so only reset for the default one.
if (!previousFbo) {
gl.readBuffer(gl.BACK);
}
if (!promise) {
return null;
}
await promise;
if (this._disposed || buffer.length < sliceEnd) {
return null;
}
return buffer.subarray(startInBuffer, sliceEnd);
}
/**
* Disposes the work buffer and its decode resources.
*/
dispose() {
this._disposed = true;
if (this._readFbo) {
this._scene.getEngine()._gl?.deleteFramebuffer(this._readFbo);
this._readFbo = null;
}
this._quad.dispose();
this._material.dispose(true, false);
this._shMaterial?.dispose(true, false);
this._rotMaterial?.dispose(true, false);
this._copyMaterial?.dispose(true, false);
this._shCopyMaterial?.dispose(true, false);
this._rotCopyMaterial?.dispose(true, false);
this._relayoutMapTexture?.dispose();
this._backupMrt?.dispose();
this._backupMrt = null;
if (this._backupShMrts) {
for (const mrt of this._backupShMrts) {
mrt.dispose();
}
this._backupShMrts = null;
}
this._backupRotMrt?.dispose();
this._backupRotMrt = null;
// Only dispose the MRT when we own it; an external atlas belongs to the hosting compound mesh.
if (this._ownsMrt) {
this._mrt.dispose();
}
// Same ownership rule for the SH targets (standalone owns; hosted borrows the compound's shared SH atlas).
if (this._ownsShMrts) {
for (const mrt of this._shMrts) {
mrt.dispose();
}
}
this._shMrts = [];
// Same ownership rule for the rotation target.
if (this._ownsRotMrt) {
this._rotMrt?.dispose();
}
this._rotMrt = null;
}
_createQuad() {
const quad = new Mesh("gsWorkBufferQuad", this._scene);
const vertexData = new VertexData();
// Fullscreen triangle in clip space (the vertex shader passes positions straight through).
vertexData.positions = [-1, -1, 0, 3, -1, 0, -1, 3, 0];
vertexData.indices = [0, 1, 2];
vertexData.applyToMesh(quad);
// Render only inside the work-buffer MRT, never in the main scene pass.
this._scene.removeMesh(quad);
return quad;
}
_createMaterial() {
const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
const material = new ShaderMaterial("gsSogDecode", this._scene, {
vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
fragmentSource: isWGSL ? GaussianSplattingWorkBufferFragmentShaderWGSL : GaussianSplattingWorkBufferFragmentShaderGLSL,
}, {
attributes: ["position"],
uniforms: ["sogMeansMin", "sogMeansMax", "sogScalesMin", "sogScalesMax", "sogSh0Min", "sogSh0Max", "uVersion", "uOffset", "uCount", "uDestWidth", "uSrcWidth"],
samplers: ["sogMeansLTex", "sogMeansUTex", "sogScalesTex", "sogQuatsTex", "sogSh0Tex", "sogCodebookTex"],
shaderLanguage: this._shaderLanguage,
});
material.backFaceCulling = false;
material.disableDepthWrite = true;
return material;
}
_applyPack(pack) {
const material = this._material;
const srcWidth = pack.meansTextureL.getSize().width;
material.setTexture("sogMeansLTex", pack.meansTextureL);
material.setTexture("sogMeansUTex", pack.meansTextureU);
material.setTexture("sogScalesTex", pack.scalesTexture);
material.setTexture("sogQuatsTex", pack.quatsTexture);
material.setTexture("sogSh0Tex", pack.sh0Texture);
// Codebook only used for v2; bind a harmless placeholder otherwise so the sampler is always set.
material.setTexture("sogCodebookTex", pack.codebookTexture ?? pack.sh0Texture);
material.setVector3("sogMeansMin", new Vector3(pack.meansMin[0], pack.meansMin[1], pack.meansMin[2]));
material.setVector3("sogMeansMax", new Vector3(pack.meansMax[0], pack.meansMax[1], pack.meansMax[2]));
const sMin = pack.scalesMin ?? [0, 0, 0];
const sMax = pack.scalesMax ?? [0, 0, 0];
material.setVector3("sogScalesMin", new Vector3(sMin[0], sMin[1], sMin[2]));
material.setVector3("sogScalesMax", new Vector3(sMax[0], sMax[1], sMax[2]));
const c0Min = pack.sh0Min ?? [0, 0, 0, 0];
const c0Max = pack.sh0Max ?? [0, 0, 0, 0];
material.setVector4("sogSh0Min", new Vector4(c0Min[0], c0Min[1], c0Min[2], c0Min[3]));
material.setVector4("sogSh0Max", new Vector4(c0Max[0], c0Max[1], c0Max[2], c0Max[3]));
material.setInt("uVersion", pack.version);
// uOffset is bound at render time in decodeAsync (from the live _baseOffset).
material.setInt("uCount", pack.splatCount);
material.setInt("uDestWidth", this._textureSize);
material.setInt("uSrcWidth", srcWidth);
}
_createShMaterial() {
const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
const material = new ShaderMaterial(GaussianSplattingWorkBufferShDecodeShaderName, this._scene, {
vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
fragmentSource: isWGSL ? GaussianSplattingWorkBufferShDecodeFragmentShaderWGSL : GaussianSplattingWorkBufferShDecodeFragmentShaderGLSL,
}, {
attributes: ["position"],
uniforms: ["sogShnMin", "sogShnMax", "uVersion", "uOffset", "uCount", "uDestWidth", "uSrcWidth", "uCoeffs", "uShTextureIndex"],
samplers: ["sogShLabelsTex", "sogShCentroidsTex", "sogCodebookTex"],
shaderLanguage: this._shaderLanguage,
});
material.backFaceCulling = false;
material.disableDepthWrite = true;
return material;
}
_applyShPack(pack) {
const material = this._shMaterial;
// A file may carry no higher-order SH (coarse LOD): fall back to sh0Texture as a harmless placeholder for the
// label/centroid samplers and set uCoeffs=0 so the shader neutral-fills every coefficient (128 -> 0 lighting).
const hasSh = !!pack.shLabelsTexture && !!pack.shCentroidsTexture;
const labels = pack.shLabelsTexture ?? pack.sh0Texture;
const centroids = pack.shCentroidsTexture ?? pack.sh0Texture;
material.setTexture("sogShLabelsTex", labels);
material.setTexture("sogShCentroidsTex", centroids);
// Codebook only used for v2; bind a harmless placeholder otherwise so the sampler is always set.
material.setTexture("sogCodebookTex", pack.codebookTexture ?? labels);
material.setFloat("sogShnMin", pack.shnMin ?? 0);
material.setFloat("sogShnMax", pack.shnMax ?? 0);
material.setInt("uVersion", pack.version);
// uOffset is bound at render time in decodeAsync (from the live _baseOffset).
material.setInt("uCount", pack.splatCount);
material.setInt("uDestWidth", this._textureSize);
material.setInt("uSrcWidth", labels.getSize().width);
// Higher-order coefficient count for THIS file (bands=3 -> 15). 0 when the file has no SH -> full neutral fill.
material.setInt("uCoeffs", hasSh ? pack.shCoeffCount : 0);
}
_createRotMaterial() {
const isWGSL = this._shaderLanguage === 1 /* ShaderLanguage.WGSL */;
const material = new ShaderMaterial(GaussianSplattingWorkBufferRotationDecodeShaderName, this._scene, {
vertexSource: isWGSL ? GaussianSplattingWorkBufferVertexShaderWGSL : GaussianSplattingWorkBufferVertexShaderGLSL,
fragmentSource: isWGSL ? GaussianSplattingWorkBufferRotationDecodeFragmentShaderWGSL : GaussianSplattingWorkBufferRotationDecodeFragmentShaderGLSL,
}, {
attributes: ["position"],
uniforms: ["sogScalesMin", "sogScalesMax", "uVersion", "uOffset", "uCount", "uDestWidth", "uSrcWidth"],
samplers: ["sogScalesTex", "sogQuatsTex", "sogCodebookTex"],
shaderLanguage: this._shaderLanguage,
});
material.backFaceCulling = false;
material.disableDepthWrite = true;
return material;
}
_applyRotPack(pack) {
const material = this._rotMaterial;
const srcWidth = pack.scalesTexture.getSize().width;
material.setTexture("sogScalesTex", pack.scalesTexture);
material.setTexture("sogQuatsTex", pack.quatsTexture);
// Codebook only used for v2; bind a harmless placeholder otherwise so the sampler is always set.
material.setTexture("sogCodebookTex", pack.codebookTexture ?? pack.scalesTexture);
const sMin = pack.scalesMin ?? [0, 0, 0];
const sMax = pack.scalesMax ?? [0, 0, 0];
material.setVector3("sogScalesMin", new Vector3(sMin[0], sMin[1], sMin[2]));
material.setVector3("sogScalesMax", new Vector3(sMax[0], sMax[1], sMax[2]));
material.setInt("uVersion", pack.version);
// uOffset is bound at render time in decodeAsync (from the live _baseOffset).
material.setInt("uCount", pack.splatCount);
material.setInt("uDestWidth", this._textureSize);
material.setInt("uSrcWidth", srcWidth);
}
}
/**
* Throttles the file downloads issued while streaming a Gaussian Splatting LOD scene.
*
* Mirrors the PlayCanvas gsplat asset loader: at most {@link maxConcurrent} downloads run at once, the
* rest wait in a FIFO queue, each failed download is retried up to {@link maxRetries} times, and requests
* are idempotent — concurrent (queued or in-flight) requests for the same URL share a single download.
*
* Downloads can be tagged with a group id and cancelled together via {@link cancelGroup}: when a node's
* target LOD changes before its file finishes loading, the streamer cancels that file's now-unneeded
* downloads. Cancellation aborts the underlying HTTP request (a queued download is dropped before it
* starts; an in-flight download is aborted and its concurrency slot freed), so no bandwidth is wasted on
* data that is no longer needed.
*
* Without this throttling, every on-demand LOD decode fans out into many parallel image fetches, so the
* browser opens dozens of simultaneous connections that compete for bandwidth and delay the splats the
* camera actually needs.
* @experimental
*/
class GaussianSplattingDownloadManager {
/**
* Creates a download manager.
* @param options concurrency and retry limits
*/
constructor(options) {
this._activeCount = 0;
this._queue = [];
// Idempotency: maps a URL to its task while the download is queued or in flight. The entry is removed
// once the download settles so a later request (after the bytes were consumed) downloads again.
this._pending = new Map();
// Maps a group id to the set of URLs currently downloading (or queued) under it, for bulk cancellation.
this._groups = new Map();
this._disposed = false;
this.maxConcurrent = Math.max(1, options?.maxConcurrent ?? 2);
this.maxRetries = Math.max(0, options?.maxRetries ?? 2);
}
/**
* Whether there are no downloads queued or in flight.
*/
get isIdle() {
return this._pending.size === 0;
}
/**
* Downloads a file as an `ArrayBuffer`, queued behind the concurrency cap and retried on failure.
* Concurrent requests for the same URL resolve from a single shared download.
* @param url the file URL to download
* @param groupId optional group tag so related downloads can be cancelled together via {@link cancelGroup}
* @returns a promise resolving with the downloaded bytes
*/
async loadFileAsync(url, groupId) {
if (this._disposed) {
throw new Error("GaussianSplattingDownloadManager has been disposed.");
}
const existing = this._pending.get(url);
if (existing) {
return await existing.promise;
}
const task = {
url,
groupId,
settled: false,
cancelled: false,
started: false,
slotReleased: false,
};
task.promise = new Promise((resolve, reject) => {
task.resolve = resolve;
task.reject = reject;
});
this._pending.set(url, task);
if (groupId !== undefined) {
let urls = this._groups.get(groupId);
if (!urls) {
urls = new Set();
this._groups.set(groupId, urls);
}
urls.add(url);
}
this._queue.push(task);
this._pump();
return await task.promise;
}
/**
* Cancels a single pending download by URL. A queued download is dropped before it starts; an in-flight
* download has its underlying HTTP request aborted and its concurrency slot freed. No-op if the URL is
* not currently pending.
* @param url the URL to cancel
*/
cancel(url) {
const task = this._pending.get(url);
if (!task) {
return;
}
this._abort(task, new Error(`GaussianSplattingDownloadManager: download cancelled (${url}).`));
}
/**
* Cancels every pending download tagged with the given group id.
* @param groupId the group whose downloads should be cancelled
*/
cancelGroup(groupId) {
const urls = this._groups.get(groupId);
if (!urls) {
return;
}
// Copy first: cancel() mutates the group set as each URL settles.
for (const url of Array.from(urls)) {
this.cancel(url);
}
this._groups.delete(groupId);
}
/**
* Cancels every queued download and aborts every in-flight download, preventing new downloads from
* starting.
*/
dispose() {
if (this._disposed) {
return;
}
this._disposed = true;
this._queue.length = 0;
for (const task of Array.from(this._pending.values())) {
this._abort(task, new Error("GaussianSplattingDownloadManager has been disposed."));
}
}
/**
* Aborts a task: drops it from the queue (if not started), aborts its in-flight HTTP request (if started),
* unwinds its current attempt, settles its promise, and frees its concurrency slot.
* @param task the task to abort
* @param reason the rejection reason
*/
_abort(task, reason) {
if (task.settled) {
return;
}
task.cancelled = true;
const queueIndex = this._queue.indexOf(task);
if (queueIndex !== -1) {
this._queue.splice(queueIndex, 1);
}
// Abort the underlying HTTP request (no-op for a queued task whose request has not been created).
task.request?.abort();
// abort() does not fire the error callback, so unwind the awaited attempt explicitly.
task.cancelAttempt?.(reason);
this._settle(task, () => task.reject(reason));
if (task.started) {
this._releaseSlot(task);
}
}
/**
* Settles a task exactly once, removing it from the pending map and its group.
* @param task the task to settle
* @param settleFn resolves or rejects the task's promise
*/
_settle(task, settleFn) {
if (task.settled) {
return;
}
task.settled = true;
this._pending.delete(task.url);
if (task.groupId !== undefined) {
const urls = this._groups.get(task.groupId);
if (urls) {
urls.delete(task.url);
if (urls.size === 0) {
this._groups.delete(task.groupId);
}
}
}
settleFn();
}
/**
* Releases a task's concurrency slot exactly once and pumps the queue.
* @param task the task whose slot to release
*/
_releaseSlot(task) {
if (task.slotReleased) {
return;
}
task.slotReleased = true;
this._activeCount--;
this._pump();
}
/**
* Starts as many queued downloads as the concurrency cap allows.
*/
_pump() {
while (!this._disposed && this._activeCount < this.maxConcurrent && this._queue.length > 0) {
const task = this._queue.shift();
if (task.settled) {
continue;
}
task.started = true;
this._activeCount++;
void this._runTaskAsync(task).finally(() => {
this._releaseSlot(task);
});
}
}
/**
* Runs a single download with retries, settling the task's shared promise. The idempotency entry is
* removed the moment the task settles so a later request for the same URL starts a fresh download.
* @param task the queued download to run
*/
async _runTaskAsync(task) {
let lastError;
// attempt 0 is the initial try; attempts 1..maxRetries are retries (PlayCanvas retries immediately).
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
if (this._disposed || task.cancelled) {
return;
}
try {
// eslint-disable-next-line no-await-in-loop
const buffer = await this._downloadAttemptAsync(task);
this._settle(task, () => task.resolve(buffer));
return;
}
catch (e) {
task.cancelAttempt = undefined;
if (this._disposed || task.cancelled) {
// The task was already settled by cancel()/dispose(); just stop retrying.
return;
}
lastError = e;
}
}
this._settle(task, () => task.reject(lastError));
}
/**
* Performs one download attempt, exposing the request handle (for abort) and an attempt-rejecter on the
* task so cancellation can both abort the HTTP request and unwind this awaited attempt.
* @param task the download task
* @returns a promise resolving with the downloaded bytes
*/
async _downloadAttemptAsync(task) {
return await new Promise((resolve, reject) => {
task.cancelAttempt = reject;
task.request = Tools.LoadFile(task.url, (data) => resolve(data), undefined, undefined, true, (_request, exception) => reject(exception instanceof Error ? exception : new Error(`GaussianSplattingDownloadManager: failed to load ${task.url}.`)));
});
}
}
/**
* A node in the {@link GaussianSplattingBlockAllocator}'s linked list, representing either an allocated
* block or a free region. Callers receive {@link GaussianSplattingMemBlock} instances as handles from
* {@link GaussianSplattingBlockAllocator.allocate} and must treat their {@link offset}/{@link size} as
* read-only.
*
* Ported from the PlayCanvas engine (`src/core/block-allocator.js`).
* @experimental
*/
class GaussianSplattingMemBlock {
constructor() {
/** @internal Position in the address space. */
this._offset = 0;
/** @internal Size of this block. */
this._size = 0;
/** @internal True if this is a free region, false if allocated. */
this._free = true;
/** @internal Previous node in the main (all-nodes, offset-ordered) list. */
this._prev = null;
/** @internal Next node in the main (all-nodes, offset-ordered) list. */
this._next = null;
/** @internal Previous node in the bucket free-list. */
this._prevFree = null;
/** @internal Next node in the bucket free-list. */
this._nextFree = null;
/** @internal Index of the size bucket this free block belongs to, or -1 if not in any bucket. */
this._bucket = -1;
}
/**
* The offset of this block in the address space.
*/
get offset() {
return this._offset;
}
/**
* The size of this block.
*/
get size() {
return this._size;
}
}
/**
* A general-purpose 1D block allocator backed by a doubly-linked list with segregated free-list buckets.
* Manages a linear address space where contiguous blocks can be allocated and freed.
*
* Free blocks are organized into power-of-2 size buckets for best-fit allocation, which reduces
* fragmentation compared to a single first-fit free list. Supports incremental defragmentation and
* automatic growth. Used to place streamed Gaussian Splatting LOD files into the unified GPU work buffer.
*
* Ported from the PlayCanvas engine (`src/core/block-allocator.js`).
* @experimental
*/
class GaussianSplattingBlockAllocator {
/**
* Creates a new block allocator.
* @param capacity initial address space capacity (defaults to 0)
* @param growMultiplier multiplicative growth factor for auto-grow in {@link updateAllocation} (defaults to 1.1)
*/
constructor(capacity = 0, growMultiplier = 1.1) {
// Head/tail of the main list (all blocks, offset-ordered).
this._headAll = null;
this._tailAll = null;
// Segregated free-list bucket heads. Bucket i covers sizes [2^i, 2^(i+1)). Grows as larger free blocks appear.
this._freeBucketHeads = [];
// Pool of recycled MemBlock objects.
this._pool = [];
this._capacity = 0;
this._usedSize = 0;
this._freeSize = 0;
// Number of free regions; maintained O(1) for the fragmentation metric.
this._freeRegionCount = 0;
this._growMultiplier = growMultiplier;
if (capacity > 0) {
this._capacity = capacity;
this._freeSize = capacity;
const block = this._obtain(0, capacity, true);
this._headAll = block;
this._tailAll = block;
this._addToBucket(block);
}
}
/**
* Total address space capacity.
*/
get capacity() {
return this._capacity;
}
/**
* Total size of all allocated blocks.
*/
get usedSize() {
return this._usedSize;
}
/**
* Total size of all free regions.
*/
get freeSize() {
return this._freeSize;
}
/**
* Fragmentation ratio in the range [0, 1]. Returns 0 when all free space is one contiguous block
* (ideal), and approaches 1 when free space is split into many pieces. Computed O(1).
*/
get fragmentation() {
return this._freeSize > 0 ? 1 - 1 / this._freeRegionCount : 0;
}
/**
* Allocates a contiguous block of the given size.
* @param size the number of units to allocate (must be \> 0)
* @returns a block handle, or null if no space is available
*/
allocate(size) {
if (size <= 0) {
return null;
}
const gap = this._findFreeBlock(size);
if (!gap) {
return null;
}
this._usedSize += size;
this._freeSize -= size;
if (gap._size === size) {
// Perfect fit: convert free block to allocated.
gap._free = false;
this._removeFromBucket(gap);
return gap;
}
// Split: create allocated block at start of gap, shrink gap.
const alloc = this._obtain(gap._offset, size, false);
gap._offset += size;
gap._size -= size;
this._rebucket(gap);
this._insertAfterInMainList(alloc, gap._prev);
return alloc;
}
/**
* Frees a previously allocated block. Adjacent free regions are merged automatically.
* @param block the block to free (must have been returned by {@link allocate})
*/
free(block) {
if (!block || block._free) {
return;
}
block._free = true;
this._usedSize -= block._size;
this._freeSize += block._size;
const prev = block._prev;
const next = block._next;
const prevFree = prev && prev._free;
const nextFree = next && next._free;
if (prevFree && nextFree) {
// Both neighbors free: merge all three into prev.
prev._size += block._size + next._size;
this._removeFromMainList(block);
this._removeFromMainList(next);
this._removeFromBucket(next);
this._release(block);
this._release(next);
this._rebucket(prev);
}
else if (prevFree) {
// Left neighbor free: merge into prev.
prev._size += block._size;
this._removeFromMainList(block);
this._release(block);
this._rebucket(prev);
}
else if (nextFree) {
// Right neighbor free: absorb right into block.
block._size += next._size;
this._removeFromMainList(next);
this._removeFromBucket(next);
this._release(next);
this._addToBucket(block);
}
else {
// Neither neighbor free: insert into bucket.
this._addToBucket(block);
}
}
/**
* Grows the address space. Only increases capacity, never decreases.
* @param newCapacity the new capacity (must be \> current capacity)
*/
grow(newCapacity) {
if (newCapacity <= this._capacity) {
return;
}
const added = newCapacity - this._capacity;
this._capacity = newCapacity;
this._freeSize += added;
if (this._tailAll && this._tailAll._free) {
// Extend existing tail free block.
this._tailAll._size += added;
this._rebucket(this._tailAll);
}
else {
// Append new free block.
const block = this._obtain(this._capacity - added, added, true);
this._insertAfterInMainList(block, this._tailAll);
this._addToBucket(block);
}
}
/**
* Defragments the allocator by moving allocated blocks to reduce fragmentation.
*
* When maxMoves is 0, performs a full compaction in a single O(n) pass: all allocated blocks are packed
* contiguously from offset 0 and a single free block is placed at the end. When maxMoves \> 0, performs
* incremental defragmentation (relocate the last block into the first fitting gap, then slide blocks left).
*
* Moved blocks have their {@link GaussianSplattingMemBlock.offset} updated in place (handles stay valid),
* so callers must relocate the corresponding GPU data for every block in the returned set.
* @param maxMoves maximum number of block moves (0 = full compaction, the default)
* @param result optional set to receive the moved blocks (defaults to a new set)
* @returns the set of blocks that were moved
*/
defrag(maxMoves = 0, result = new Set()) {
result.clear();
if (this._freeRegionCount === 0) {
return result;
}
if (maxMoves === 0) {
this._defragFull(result);
}
else {
this._defragIncremental(maxMoves, result);
}
return result;
}
/**
* Batch update: frees a set of blocks and allocates new ones. Handles growth and compaction internally
* when allocations cannot be satisfied. The `toAllocate` array is modified in place: each numeric size
* entry is replaced with the allocated block.
* @param toFree blocks to release
* @param toAllocate sizes to allocate; modified in place (numbers are replaced with block handles)
* @returns true if a full defrag was performed (all existing blocks have new offsets and must be re-rendered)
*/
updateAllocation(toFree, toAllocate) {
// Phase 1: free old blocks.
for (let i = 0; i < toFree.length; i++) {
this.free(toFree[i]);
}
// Phase 2: try to allocate all new blocks.
for (let i = 0; i < toAllocate.length; i++) {
const size = toAllocate[i];
const block = this.allocate(size);
if (block) {
toAllocate[i] = block;
}
else {
// Allocation failed at index i; entries [0..i-1] are blocks, [i..n-1] are still numbers.
let totalRemaining = size;
for (let j = i + 1; j < toAllocate.length; j++) {
totalRemaining += toAllocate[j];
}
// Grow if the free space would be below the headroom threshold.
const neededCapacity = this._usedSize + totalRemaining;
const headroomCapacity = Math.ceil(neededCapacity * this._growMultiplier);
if (headroomCapacity > this._capacity) {
this.grow(headroomCapacity);
}
// Full defrag: compact everything, then allocate the remainder (guaranteed to succeed).
this.defrag(0);
for (let j = i; j < toAllocate.length; j++) {
toAllocate[j] = this.allocate(toAllocate[j]);
}
return true;
}
}
return false;
}
/**
* Computes the bucket index for a given block size (= floor(log2(size))).
* @param size block size (must be \> 0)
* @returns the bucket index
*/
_bucketFor(size) {
return 31 - Math.clz32(size);
}
_addToBucket(block) {
const b = this._bucketFor(block._size);
block._bucket = b;
while (b >= this._freeBucketHeads.length) {
this._freeBucketHeads.push(null);
}
block._prevFree = null;
block._nextFree = this._freeBucketHeads[b];
if (this._freeBucketHeads[b]) {
this._freeBucketHeads[b]._prevFree = block;
}
this._freeBucketHeads[b] = block;
this._freeRegionCount++;
}
_removeFromBucket(block) {
const b = block._bucket;
if (block._prevFree) {
block._prevFree._nextFree = block._nextFree;
}
else {
this._freeBucketHeads[b] = block._nextFree;
}
if (block._nextFree) {
block._nextFree._prevFree = block._prevFree;
}
block._prevFree = null;
block._nextFree = null;
block._bucket = -1;
this._freeRegionCount--;
}
_rebucket(block) {
const newBucket = this._bucketFor(block._size);
if (newBucket !== block._bucket) {
this._removeFromBucket(block);
this._addToBucket(block);
}
}
_obtain(offset, size, free) {
const block = this._pool.length > 0 ? this._pool.pop() : new GaussianSplattingMemBlock();
block._offset = offset;
block._size = size;
block._free = free;
block._prev = null;
block._next = null;
block._prevFree = null;
block._nextFree = null;
block._bucket = -1;
return block;
}
_release(block) {
block._prev = null;
block._next = null;
block._prevFree = null;
block._nextFree = null;
block._bucket = -1;
this._pool.push(block);
}
_insertAfterInMainList(block, after) {
if (after === null) {
block._prev = null;
block._next = this._headAll;
if (this._headAll) {
this._headAll._prev = block;
}
this._headAll = block;
if (!this._tailAll) {
this._tailAll = block;
}
}
else {
block._prev = after;
block._next = after._next;
if (after._next) {
after._next._prev = block;
}
after._next = block;
if (this._tailAll === after) {
this._tailAll = block;
}
}
}
_removeFromMainList(block) {
if (block._prev) {
block._prev._next = block._next;
}
else {
this._headAll = block._next;
}
if (block._next) {
block._next._prev = block._prev;
}
else {
this._tailAll = block._prev;
}
block._prev = null;
block._next = null;
}
_findFreeBlock(size) {
const startBucket = this._bucketFor(size);
const len = this._freeBucketHeads.length;
// Target bucket: best-fit (smallest block >= size).
if (startBucket < len) {
let best = null;
let node = this._freeBucketHeads[startBucket];
while (node) {
if (node._size >= size) {
if (!best || node._size < best._size) {
best = node;
if (node._size === size) {
break;
}
}
}
node = node._nextFree;
}
if (best) {
return best;
}
}
// Higher buckets: first-fit (any block is large enough).
for (let b = startBucket + 1; b < len; b++) {
if (this._freeBucketHeads[b]) {
return this._freeBucketHeads[b];
}
}
return null;
}
_defragFull(result) {
// Remove all free blocks from all buckets and pool them.
for (let b = 0; b < this._freeBucketHeads.length; b++) {
let node = this._freeBucketHeads[b];
while (node) {
const nextFree = node._nextFree;
this._removeFromMainList(node);
node._prevFree = null;
node._nextFree = null;
node._bucket = -1;
this._pool.push(node);
node = nextFree;
}
this._freeBucketHeads[b] = null;
}
this._freeRegionCount = 0;
// Walk remaining (all allocated) blocks, assigning sequential offsets.
let offset = 0;
let block = this._headAll;
while (block) {
if (block._offset !== offset) {
block._offset = offset;
result.add(block);
}
offset += block._size;
block = block._next;
}
// Create a single free block at the end if there is remaining capacity.
const remaining = this._capacity - offset;
if (remaining > 0) {
const freeBlock = this._obtain(offset, remaining, true);
this._insertAfterInMainList(freeBlock, this._tailAll);
this._addToBucket(freeBlock);
}
}
_defragIncremental(maxMoves, result) {
const phase1Moves = Math.ceil(maxMoves / 2);
const phase2Moves = maxMoves - phase1Moves;
// Phase 1: relocate the last allocated block to the first fitting gap (maximizes tail free space).
for (let i = 0; i < phase1Moves; i++) {
let lastAlloc = this._tailAll;
while (lastAlloc && lastAlloc._free) {
lastAlloc = lastAlloc._prev;
}
if (!lastAlloc) {
break;
}
const gap = this._findFreeBlock(lastAlloc._size);
if (!gap || gap._offset >= lastAlloc._offset) {
break;
}
this._moveBlock(lastAlloc, gap);
result.add(lastAlloc);
}
// Phase 2: slide allocated blocks left into adjacent free gaps (cleans up interior fragmentation).
let block = this._headAll;
for (let i = 0; i < phase2Moves && block;) {
const next = block._next;
if (block._free && next && !next._free) {
const allocBlock = next;
const freeBlock = block;
allocBlock._offset = freeBlock._offset;
freeBlock._offset = allocBlock._offset + allocBlock._size;
// Swap in the main list.
const a = freeBlock._prev;
const b = allocBlock._next;
allocBlock._prev = a;
allocBlock._next = freeBlock;
freeBlock._prev = allocBlock;
freeBlock._next = b;
if (a) {
a._next = allocBlock;
}
else {
this._headAll = allocBlock;
}
if (b) {
b._prev = freeBlock;
}
else {
this._tailAll = freeBlock;
}
// Merge the free block with its new right neighbor if also free.
if (freeBlock._next && freeBlock._next._free) {
const right = freeBlock._next;
freeBlock._size += right._size;
this._removeFromMainList(right);
this._removeFromBucket(right);
this._release(right);
this._rebucket(freeBlock);
}
result.add(allocBlock);
i++;
// Continue from the block after freeBlock to find more opportunities.
block = freeBlock._next;
}
else {
block = next;
}
}
}
_moveBlock(block, gap) {
const blockSize = block._size;
const newOffset = gap._offset;
// 1. Remove the block from its current position, freeing that space.
const prev = block._prev;
this._removeFromMainList(block);
// Create a free region where the block was.
const freed = this._obtain(block._offset, blockSize, true);
this._insertAfterInMainList(freed, prev);
this._addToBucket(freed);
// Merge freed with its right neighbor.
if (freed._next && freed._next._free) {
const right = freed._next;
freed._size += right._size;
this._removeFromMainList(right);
this._removeFromBucket(right);
this._release(right);
this._rebucket(freed);
}
// Merge freed with its left neighbor.
if (freed._prev && freed._prev._free) {
const left = freed._prev;
left._size += freed._size;
this._removeFromMainList(freed);
this._removeFromBucket(freed);
this._release(freed);
this._rebucket(left);
}
// 2. Place the block at the gap.
block._offset = newOffset;
if (gap._size === blockSize) {
// Perfect fit: replace the gap with the block.
const gapPrev = gap._prev;
this._removeFromMainList(gap);
this._removeFromBucket(gap);
this._release(gap);
this._insertAfterInMainList(block, gapPrev);
}
else {
// Partial fit: shrink the gap, insert the block before it.
gap._offset += blockSize;
gap._size -= blockSize;
this._rebucket(gap);
this._insertAfterInMainList(block, gap._prev);
}
}
}
/**
* Tracks which streamed Gaussian Splatting files are resident in the GPU work buffer and where, evicting
* unreferenced files after a cooldown to keep the resident set within a fixed budget.
*
* Built on {@link GaussianSplattingBlockAllocator}: each resident file owns a contiguous block of the work
* buffer's splat-index address space. A file with no remaining references is scheduled for eviction; after
* `cooldownFrames` ticks (or sooner, if the space is needed by a new allocation — "evict-to-fit") its block
* is freed and reused. Pinned files (e.g. the always-rendered environment and the padding splat) are never
* evicted. The {@link onEvict} callback fires for every file the controller evicts so the owner can drop its
* own bookkeeping (e.g. mark it no longer decoded).
*
* This controller owns only memory/residency bookkeeping — it has no knowledge of the scene, GPU, downloads,
* or reference counting (the caller decides when a file's reference count reaches zero and calls
* {@link scheduleEviction}).
* @experimental
*/
class GaussianSplattingResidencyController {
/**
* Creates a residency controller.
* @param capacity total splat-index capacity of the work buffer
* @param cooldownFrames number of ticks an unreferenced file stays resident before being evicted
* @param onEvict called with the file index whenever the controller evicts a file (via tick or evict-to-fit)
*/
constructor(capacity, cooldownFrames, onEvict) {
this._blocks = new Map();
// file -> frames remaining before eviction (only present for unreferenced, evictable files).
this._cooldown = new Map();
this._pinned = new Set();
this._allocator = new GaussianSplattingBlockAllocator(capacity);
this._cooldownFrames = Math.max(0, cooldownFrames);
this._onEvict = onEvict;
}
/**
* Total splat-index capacity.
*/
get capacity() {
return this._allocator.capacity;
}
/**
* Number of files currently resident.
*/
get residentCount() {
return this._blocks.size;
}
/**
* Total free splat capacity (sum of all gaps, which may be fragmented). After {@link compact} an
* allocation of up to this size is guaranteed to fit.
*/
get freeSize() {
return this._allocator.freeSize;
}
/**
* Whether the given file currently has a block in the work buffer.
* @param file file index
* @returns true if resident
*/
has(file) {
return this._blocks.has(file);
}
/**
* The work-buffer splat offset of a resident file, or undefined if not resident.
* @param file file index
* @returns the splat offset, or undefined
*/
offset(file) {
return this._blocks.get(file)?.offset;
}
/**
* Allocates a contiguous block for a file about to be decoded. If there is no room, evicts files whose
* eviction cooldown is pending (they are unreferenced) and retries once. Returns the splat offset, or null
* if it still does not fit (the caller should refuse the decode and keep the node's current LOD).
* @param file file index
* @param count number of splats the file needs
* @returns the allocated splat offset, or null if it cannot fit
*/
allocate(file, count) {
const existing = this._blocks.get(file);
if (existing) {
return existing.offset;
}
let block = this._allocator.allocate(count);
if (!block) {
// Evict-to-fit: reclaim every unreferenced (cooldown-scheduled) file, then retry once.
this._evictAllCooled();
block = this._allocator.allocate(count);
if (!block) {
return null;
}
}
this._blocks.set(file, block);
return block.offset;
}
/**
* Allocates a block for a file that must never be evicted (e.g. the environment or padding splat).
* @param file file index (use a sentinel that cannot collide with real file indices)
* @param count number of splats
* @returns the allocated splat offset, or null if it cannot fit
*/
pin(file, count) {
const offset = this.allocate(file, count);
if (offset !== null) {
this._pinned.add(file);
}
return offset;
}
/**
* Frees a file's block immediately (e.g. when a decode was cancelled before completing). Does not fire
* {@link onEvict}. No-op for pinned or non-resident files.
* @param file file index
*/
free(file) {
if (this._pinned.has(file)) {
return;
}
const block = this._blocks.get(file);
if (!block) {
return;
}
this._allocator.free(block);
this._blocks.delete(file);
this._cooldown.delete(file);
}
/**
* Compacts the resident blocks to defragment free space (capacity is unchanged), returning every block
* that moved so the caller can relocate the corresponding GPU/CPU splat data. Call when an allocation
* fails despite sufficient total free space ({@link freeSize}); afterwards that allocation will fit.
* @returns the relocations to apply (empty when nothing moved)
*/
compact() {
const before = new Map();
for (const [file, block] of Array.from(this._blocks)) {
before.set(file, block.offset);
}
this._allocator.defrag(0);
const moves = [];
for (const [file, block] of Array.from(this._blocks)) {
const oldOffset = before.get(file);
if (oldOffset !== block.offset) {
moves.push({ file, oldOffset, newOffset: block.offset, count: block.size });
}
}
return moves;
}
/**
* Returns the current resident blocks (file index, splat offset, splat count). Used to relocate GPU/CPU
* data after {@link compact}.
* @returns one entry per resident file
*/
getResidentBlocks() {
const result = [];
for (const [file, block] of Array.from(this._blocks)) {
result.push({ file, offset: block.offset, count: block.size });
}
return result;
}
/**
* Schedules an unreferenced resident file for eviction after the cooldown. No-op for pinned or
* non-resident files.
* @param file file index
*/
scheduleEviction(file) {
if (this._pinned.has(file) || !this._blocks.has(file)) {
return;
}
this._cooldown.set(file, this._cooldownFrames);
}
/**
* Cancels a pending eviction because the file was referenced again.
* @param file file index
*/
cancelEviction(file) {
this._cooldown.delete(file);
}
/**
* Advances all eviction cooldowns by one frame, evicting any that expire. Each evicted file fires
* {@link onEvict}.
* @returns the file indices evicted this tick
*/
tick() {
if (this._cooldown.size === 0) {
return [];
}
const evicted = [];
for (const [file, frames] of Array.from(this._cooldown)) {
if (frames <= 1) {
evicted.push(file);
}
else {
this._cooldown.set(file, frames - 1);
}
}
for (const file of evicted) {
this._evict(file);
}
return evicted;
}
/**
* Releases all bookkeeping. The allocator and maps are cleared.
*/
dispose() {
this._blocks.clear();
this._cooldown.clear();
this._pinned.clear();
}
_evictAllCooled() {
const files = Array.from(this._cooldown.keys());
for (const file of files) {
this._evict(file);
}
}
_evict(file) {
const block = this._blocks.get(file);
if (block) {
this._allocator.free(block);
this._blocks.delete(file);
}
this._cooldown.delete(file);
this._onEvict(file);
}
}
// tan(22.5deg): reference half-FOV for a 45-degree vertical FOV, used for FOV compensation (matches PlayCanvas).
const RefTanHalfFov = Math.tan((22.5 * Math.PI) / 180);
// Sentinel "file" ids for the residency controller's pinned (never-evicted) allocations.
const PaddingFileId = -2;
const EnvironmentFileId = -1;
// Core bytes per resident splat: the four work-buffer textures cost 16+16+16+4 = 52 bytes on the GPU, plus ~32
// bytes of CPU position/sort data. `_resolveResidentBudget` adds the SH and rotation/scale texture cost on top.
const BytesPerResidentSplat = 84;
// Scratch objects reused by the per-frame optimal-LOD evaluation (avoids per-call allocations).
const TmpInvWorld = new Matrix();
const TmpLocalCamera = new Vector3();
const TmpLocalForward = new Vector3();
const TmpWorldForward = new Vector3();
// Camera-local forward axis (+Z) used to derive the world-space view direction.
const LocalForwardAxis = new Vector3(0, 0, 1);
// The 12 edges of a box, as index pairs into its 8 corners. 12 edges x 2 endpoints = 24 vertices per box.
const BoxEdges = [
[0, 1],
[1, 2],
[2, 3],
[3, 0],
[4, 5],
[5, 6],
[6, 7],
[7, 4],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
];
// Vertices generated per leaf box (BoxEdges.length * 2).
const VerticesPerBox = BoxEdges.length * 2;
/**
* Wireframe colors per LOD level (cycled by `node.activeLod`).
*/
const GsLodDebugColors = [
new Color4(1.0, 0.2, 0.2, 1.0), // LOD 0 - red
new Color4(1.0, 0.6, 0.1, 1.0), // LOD 1 - orange
new Color4(1.0, 1.0, 0.2, 1.0), // LOD 2 - yellow
new Color4(0.3, 1.0, 0.3, 1.0), // LOD 3 - green
new Color4(0.2, 1.0, 1.0, 1.0), // LOD 4 - cyan
new Color4(0.4, 0.5, 1.0, 1.0), // LOD 5 - blue
new Color4(0.9, 0.4, 1.0, 1.0), // LOD 6 - magenta
new Color4(1.0, 1.0, 1.0, 1.0), // LOD 7 - white
];
/**
* Streams a PlayCanvas-style SOG LOD scene (`lod-meta.json`) into a single Gaussian Splatting mesh.
*
* Each selected SOG file (plus the environment) is loaded directly as GPU textures and decoded on the
* GPU into one unified, PlayCanvas-style square work buffer (no CPU splat decode or `updateData`). Only
* the splats of each node's currently-selected LOD are rendered/sorted via the mesh's interval filter.
*
* The coarsest (least-detail) LOD of every node is streamed first as a permanent base layer so the whole
* scene is visible quickly with no holes. A distance-based "optimal" LOD is then computed per node (see
* {@link evaluateOptimalLods}); finer LOD source files are streamed on demand and a node only switches to
* a finer LOD once that file is decoded, so transitions never flash or leave gaps.
*
* @experimental
*/
class GaussianSplattingStream extends GaussianSplattingMesh {
/**
* Returns true when the parsed JSON looks like a PlayCanvas-style `lod-meta.json` payload.
* @param data parsed JSON
* @returns whether the data is SOG LOD metadata
*/
static IsLODMetadata(data) {
if (typeof data !== "object" || data === null) {
return false;
}
const meta = data;
return typeof meta.lodLevels === "number" && Array.isArray(meta.filenames) && typeof meta.tree === "object" && meta.tree !== null;
}
/**
* Creates a new SOG LOD streaming mesh and immediately starts streaming (non-blocking).
* @param name mesh name
* @param metadata parsed `lod-meta.json`
* @param rootUrl base URL the metadata's relative paths resolve against
* @param scene hosting scene
* @param options streaming options
*/
constructor(name, metadata, rootUrl, scene, options = {}) {
super(name, null, scene, false);
// Flat list of leaf nodes that carry renderable LOD entries (used by the LOD heuristic and debug).
this._leafNodes = [];
// LOD heuristic parameters (PlayCanvas-aligned defaults).
this._lodBaseDistance = 5;
this._lodMultiplier = 3;
this._lodBehindPenalty = 1;
this._lodRangeMin = 0;
this._maxDecodesPerFrame = 1;
this._lodCooldownFrames = 10;
// Minimum frames between LOD re-evaluations, and minimum camera movement (world units) to re-evaluate.
this._lodUpdateInterval = 4;
this._lodUpdateDistance = 0.5;
this._maxDetailLod = 0;
// Frustum LOD bias: when enabled, nodes outside the camera frustum are rendered at their coarsest LOD.
this._frustumCulling = true;
// Reused world-space frustum planes and view-projection scratch matrix (avoids per-frame allocation).
this._frustumPlanes = [
new Plane(0, 0, 0, 0),
new Plane(0, 0, 0, 0),
new Plane(0, 0, 0, 0),
new Plane(0, 0, 0, 0),
new Plane(0, 0, 0, 0),
new Plane(0, 0, 0, 0),
];
this._cullViewProj = new Matrix();
// GPU work buffer holding all decoded splats; created once the total capacity is known.
this._workBuffer = null;
this._streamShDegree = 0;
this._shTextureCount = 0;
// Rotation/scale for voxel-IBL shadows. Enabled via options.needsRotationScale.
this._needsRotationScale = false;
// True once GPU position readback has been validated against a CPU decode (see _probeReadbackAsync). While
// false, positions are decoded on the CPU from the means images; once validated, every SOG image uses the
// fast direct upload and positions are read back from the work buffer (non-blocking).
this._useGpuPositionReadback = false;
// Whether the engine reports GPU readback support (candidate to validate on the first decode).
this._readbackCandidate = false;
// Set once the one-time readback validation has run (success or failure).
this._readbackProbed = false;
// Residency controller: owns the work-buffer slot allocator, per-file blocks, and eviction cooldowns.
this._residency = null;
// Splat count of each source file (learned from its metadata before allocation).
this._fileCounts = new Map();
// Cached SOG metadata per file so on-demand decodes don't refetch the meta.json.
this._fileMeta = new Map();
// Files whose splats have been fully GPU-decoded into the work buffer (render-safe).
this._decodedFiles = new Set();
// Files whose decode is currently in flight (dedupes concurrent requests).
this._loadingFiles = new Set();
// FIFO of file ids waiting to be decoded (drained under a per-frame budget).
this._decodeQueue = [];
// Per-file reference count: number of leaf nodes whose active LOD renders, or whose pending target points
// at, each file. At zero, a decoded file is scheduled for eviction and a still-downloading file is cancelled.
this._fileRefs = new Map();
// Files whose in-flight decode was cancelled; checked at decode checkpoints to bail out cooperatively.
this._cancelledDecodes = new Set();
// Eviction streaming config: enabled only when a budget smaller than the full dataset is configured.
this._evictionEnabled = false;
this._residentBudget = 0;
// Raw budget options; the final `_residentBudget` is resolved from these once the SH/rotation byte cost is known
// (after the metadata pre-pass), so the memory budget accounts for the extra baked SH and rotation textures.
this._maxResidentSplats = 0;
this._memoryBudgetMb = 0;
this._evictionCooldownFrames = 100;
// Serializes the allocate -> decode -> readback critical section so a defrag relayout (which runs inside it)
// never overlaps another file's decode writing the work buffer, which would corrupt the moved data.
this._decodeGate = Promise.resolve();
// Reusable scratch for the (rare) defrag relayout, to avoid per-relayout allocations during streaming.
this._relayoutOldOffsets = new Map();
this._relayoutSrcIndex = null;
// Global range covered by the environment file (always rendered), or null until it loads.
this._environmentRange = null;
// Unzipped environment bundle contents, retained between count-gathering and decode.
this._environmentFiles = null;
// Per-frame LOD streaming loop; installed once the base layer is ready.
this._lodObserver = null;
this._baseLayerReady = false;
// Throttling state for the per-frame LOD loop.
this._framesSinceLodUpdate = 0;
this._lastLodCamPos = new Vector3(Infinity, Infinity, Infinity);
// Forces the next LOD update to run regardless of the throttle (e.g. after a budget change).
this._forceLodUpdate = false;
// Running local-space bounds of all decoded splat centers (for frustum culling / picking).
this._boundsMin = new Vector3(Number.MAX_VALUE, Number.MAX_VALUE, Number.MAX_VALUE);
this._boundsMax = new Vector3(-Number.MAX_VALUE, -Number.MAX_VALUE, -Number.MAX_VALUE);
// Debug LOD-node wireframe display.
this._debugDisplay = false;
this._debugLodSource = "optimal";
this._debugMesh = null;
this._debugObserver = null;
// Per-vertex RGBA color buffer mirror, updated in place when LOD colors change (avoids mesh rebuild flicker).
this._debugColorData = null;
// Signature of the per-leaf displayed LOD levels, used to skip rebuilding unchanged debug geometry.
this._debugSignature = 0;
this._disposed = false;
// Hosted mode: when set, the stream decodes/sorts into a reserved region of a compound mesh instead of
// rendering itself. `_host` is the reserved-part handle (resolved once the total capacity is known),
// `_positionBase` is the region's first splat index in the compound's shared position buffer.
this._hostCompound = null;
this._host = null;
this._positionBase = 0;
// Unsubscribe functions for the host's atlas-rebuild hooks (backup/restore the region across a grow).
this._unsubBeforeRebuild = null;
this._unsubAfterRebuild = null;
// Unsubscribe functions binding this controller's lifetime to its host compound: removing the part or disposing
// the compound disposes this stream, even mid-load. Registered at reservation so the window is never open.
this._hostUnsubRemove = null;
this._hostUnsubDispose = null;
// True once the host has released this stream's part (removePart, or the compound is being disposed), so dispose()
// must NOT call back into the compound to remove the part again.
this._partReleasedByHost = false;
// CPU snapshot of this region's shared `_splatPositions` taken before an atlas grow and restored after it —
// the grow rebuilds `_splatPositions` from CPU part sources, and a streamed region has none, so without this
// its sort-worker positions would be zeroed (the streamed splats would collapse to the origin).
this._positionSnapshot = null;
// Hosted mode: resolves once the reserved part exists AND its base layer has decoded (proxy bounds are
// real); rejects if streaming fails/disposes before that. Lets AddGaussianSplattingStreamPartAsync hand
// back a ready part proxy, replacing the standalone waitForEnabled/waitForStreamedBounds handshake.
this._partReadyPromise = null;
this._partReadyResolve = null;
this._partReadyReject = null;
this._partReadySettled = false;
this._metadata = metadata;
this._rootUrl = rootUrl;
this._streamOptions = options;
this._hostCompound = options.hostCompound ?? null;
this._decodeSh = options.decodeSh ?? true;
this._needsRotationScale = options.needsRotationScale ?? false;
// LOD heuristic parameters: take the provided values, otherwise keep the PlayCanvas-aligned defaults.
const maxLod = Math.max(0, metadata.lodLevels - 1);
this._lodRangeMax = maxLod;
if (options.lodBaseDistance !== undefined) {
this._lodBaseDistance = Math.max(0.1, options.lodBaseDistance);
}
if (options.lodMultiplier !== undefined) {
this._lodMultiplier = Math.max(1.2, options.lodMultiplier);
}
if (options.lodBehindPenalty !== undefined) {
this._lodBehindPenalty = Math.max(1, options.lodBehindPenalty);
}
if (options.lodRangeMin !== undefined) {
this._lodRangeMin = Math.max(0, Math.min(options.lodRangeMin, maxLod));
}
if (options.lodRangeMax !== undefined) {
this._lodRangeMax = Math.max(this._lodRangeMin, Math.min(options.lodRangeMax, maxLod));
}
if (options.maxDecodesPerFrame !== undefined) {
this._maxDecodesPerFrame = Math.max(1, options.maxDecodesPerFrame);
}
if (options.lodCooldownFrames !== undefined) {
this._lodCooldownFrames = Math.max(0, options.lodCooldownFrames);
}
if (options.lodUpdateInterval !== undefined) {
this._lodUpdateInterval = Math.max(1, options.lodUpdateInterval);
}
if (options.lodUpdateDistance !== undefined) {
this._lodUpdateDistance = Math.max(0, options.lodUpdateDistance);
}
if (options.maxDetailLod !== undefined) {
this._maxDetailLod = Math.max(0, Math.floor(options.maxDetailLod));
}
if (options.frustumCulling !== undefined) {
this._frustumCulling = options.frustumCulling;
}
if (options.debugLodSource) {
this._debugLodSource = options.debugLodSource;
}
if (options.evictionCooldownFrames !== undefined) {
this._evictionCooldownFrames = Math.max(0, Math.floor(options.evictionCooldownFrames));
}
// Capture the raw budget options; `_residentBudget` is resolved in _streamAllAsync once the SH/rotation
// per-splat cost is known (a memory budget must count the extra baked SH and rotation textures, not just core).
if (options.maxResidentSplats !== undefined && options.maxResidentSplats > 0) {
this._maxResidentSplats = Math.floor(options.maxResidentSplats);
}
if (options.memoryBudgetMb !== undefined && options.memoryBudgetMb > 0) {
this._memoryBudgetMb = options.memoryBudgetMb;
}
this._downloadManager = new GaussianSplattingDownloadManager({
maxConcurrent: options.maxConcurrentDownloads,
maxRetries: options.maxDownloadRetries,
});
// PlayCanvas SOG data is authored with a flipped Y and Z-up. Standalone: bake the orientation into this
// mesh's transform. Hosted: this mesh does not render — the orientation is applied to the reserved part's
// proxy transform instead (see _streamAllAsync), so it composes with the compound's per-part world matrix.
if (!this._hostCompound) {
this.scaling.y *= -1;
this.rotation.x = -Math.PI / 2;
}
else {
// Hidden controller: never rendered/picked/serialized; the compound renders the streamed splats.
this.setEnabled(false);
this.isPickable = false;
this.doNotSerialize = true;
// Created before _streamAllAsync is kicked off (below) so there is no resolve-before-await race.
this._partReadyPromise = new Promise((resolve, reject) => {
this._partReadyResolve = resolve;
this._partReadyReject = reject;
});
// Attach a no-op rejection handler so a caller that never awaits whenPartReadyAsync() (e.g. the synchronous
// AddGaussianSplattingStreamPart) does not produce an unhandled promise rejection on failure; real
// consumers still observe the rejection through their own await.
// eslint-disable-next-line github/no-then
this._partReadyPromise.catch(() => { });
// Bind to the host's disposal FROM CONSTRUCTION (not just from reservation): the metadata pre-pass in
// _streamAllAsync runs before the region is reserved, so a compound disposed during that download would
// otherwise be missed and the controller would reserve into a disposed host. dispose() -> _disposed, so
// _streamAllAsync's post-download check bails before reserving.
const disposeObserver = this._hostCompound.onDisposeObservable.add(() => {
if (!this._disposed) {
this._partReleasedByHost = true;
this.dispose();
}
});
this._hostUnsubDispose = () => this._hostCompound.onDisposeObservable.remove(disposeObserver);
}
this._collectLodEntries(metadata.tree);
if (options.debugDisplay) {
this.debugDisplay = true;
}
// Kick off streaming without blocking the caller or the render loop. In hosted mode settle the part-ready
// deferred: _streamAllAsync resolves it once the base layer has decoded. If it finishes WITHOUT the part ever
// becoming ready (empty stream) or throws, dispose the controller so a hosted stream doesn't leave its work
// buffer and reserved region allocated — the synchronous AddGaussianSplattingStreamPart never awaits, so it
// can't clean up itself. `_partReadySettled` distinguishes a genuine success (leave it running) from a
// finished-but-never-ready result (dispose).
// eslint-disable-next-line github/no-then
void this._streamAllAsync().then(() => {
const becameReady = this._partReadySettled;
this._rejectPartReady("GaussianSplattingStream: stream produced no splats.");
if (!becameReady && this._hostCompound && !this._disposed) {
this._disposeAndReclaim();
}
}, (e) => {
Logger.Error("GaussianSplattingStream: streaming failed: " + (e?.message ?? e));
this._rejectPartReady("GaussianSplattingStream: streaming failed: " + (e?.message ?? e));
if (this._hostCompound && !this._disposed) {
this._disposeAndReclaim();
}
});
}
getClassName() {
return "GaussianSplattingStream";
}
/**
* When `_hostCompound` is set (i.e. this stream was created via {@link AddGaussianSplattingStreamPart}
* to drive a reserved region of another compound mesh, rather than rendering itself), this instance is
* disabled and never drawn — so it never runs its own depth-sort worker and the base class's readiness
* check (which waits for one) would never pass. Report ready unconditionally in that case; the host
* compound is the one actually rendering, and its own `isReady()` already covers real sort completion.
* @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) {
if (this._hostCompound) {
return true;
}
return super.isReady(completeCheck);
}
/**
* Hosted mode only: the compound part proxy this stream drives (world transform + visibility of the
* reserved region), or null before the part has been reserved (or when running standalone).
*/
get streamingPartProxy() {
return this._host?.proxy ?? null;
}
/**
* Hosted mode only: resolves once the reserved part exists and its base layer has decoded (so the proxy's
* bounds are real and the part is ready to be placed/framed), or rejects if streaming fails/disposes first.
* Resolves immediately for a standalone stream. Used by {@link AddGaussianSplattingStreamPartAsync}.
* @returns a promise that settles when the hosted part is ready to use
*/
async whenPartReadyAsync() {
await (this._partReadyPromise ?? Promise.resolve());
}
/** Resolves the part-ready deferred (hosted mode); no-op if already settled or standalone. */
_resolvePartReady() {
if (this._partReadySettled) {
return;
}
this._partReadySettled = true;
this._partReadyResolve?.();
}
/**
* Rejects the part-ready deferred (hosted mode); no-op if already settled or standalone.
* @param message failure reason surfaced to the awaiter
*/
_rejectPartReady(message) {
if (this._partReadySettled) {
return;
}
this._partReadySettled = true;
this._partReadyReject?.(new Error(message));
}
/**
* Resolves once the scene is fully streamed and displayed for the current camera: a LOD re-evaluation has
* run for the current point of view, every reachable LOD file has finished downloading and decoding (no
* downloads, decodes, or queued work remain), and the depth sort for the resulting splats has been applied
* and rendered. Intended for deterministic automated testing and screenshot/image comparison.
*
* Streaming and settling require rendered frames. If an external render loop is already running, this waits
* on it passively; otherwise (e.g. when awaited inside an async `createScene` before the host starts its
* render loop) it drives `scene.render()` itself until settled, so it never deadlocks.
*
* Note: the promise only resolves while the camera is still — if the camera keeps moving, the target LODs
* (and the depth sort) keep changing and the stream never settles. Position the camera, then await this.
* @param stableFrames number of consecutive settled frames to require before resolving (defaults to 3), so
* the final sorted frame is actually on screen
* @returns a promise that resolves when loading and rendering are complete for the current view
*/
async whenSettledAsync(stableFrames = 3) {
if (this._disposed) {
return;
}
// Re-evaluate LODs immediately so the target levels reflect the current camera before we wait.
this._forceLodUpdate = true;
const required = Math.max(1, stableFrames);
const scene = this._scene;
let stable = 0;
const isSettled = () => {
if (this._isLoadingIdle() && this._sinkIsDepthSortSettled) {
return ++stable >= required;
}
stable = 0;
return false;
};
// An external render loop is already driving frames: observe it passively.
if (scene.getEngine().activeRenderLoops.length > 0) {
await new Promise((resolve) => {
let observer = null;
observer = scene.onAfterRenderObservable.add(() => {
if (this._disposed || isSettled()) {
if (observer) {
scene.onAfterRenderObservable.remove(observer);
observer = null;
}
resolve();
}
});
});
return;
}
// No render loop yet (e.g. awaited inside createScene): drive rendering ourselves so the streaming
// decodes and depth sort can progress, yielding between frames so async downloads/readbacks resolve.
// Wrap each render in beginFrame/endFrame exactly like the engine's own render loop: on WebGPU,
// endFrame submits the frame's command buffers and presents the swapchain, so a bare scene.render()
// would leave the acquired swapchain texture to be destroyed at the frame boundary before its command
// buffer is submitted ("destroyed texture used in a submit").
const engine = scene.getEngine();
const requestFrame = globalThis.requestAnimationFrame;
while (!this._disposed) {
engine.beginFrame();
scene.render();
engine.endFrame();
if (isSettled()) {
return;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
if (typeof requestFrame === "function") {
requestFrame(() => resolve());
}
else {
setTimeout(resolve, 16);
}
});
}
}
/**
* Whether the base layer is ready and there is no streaming work in flight (nothing queued for decode, no
* decode running, and no downloads pending).
* @returns true when no loading work remains
*/
_isLoadingIdle() {
return this._baseLayerReady && this._decodeQueue.length === 0 && this._loadingFiles.size === 0 && this._downloadManager.isIdle;
}
/**
* Finest (most detailed) LOD level any node is allowed to render. `0` allows full detail (level 0);
* `1` caps detail at the next-coarser level, and so on. Nodes already coarser than this cap (by
* distance) are unaffected. Changes take effect in real time.
*/
get maxDetailLod() {
return this._maxDetailLod;
}
set maxDetailLod(value) {
const level = Math.max(0, Math.floor(value));
if (this._maxDetailLod === level) {
return;
}
this._maxDetailLod = level;
// Re-evaluate LODs on the next frame regardless of the movement throttle so the change is immediate.
this._forceLodUpdate = true;
}
/**
* Coarsest LOD level index in the scene (number of LOD levels minus one). Useful as the upper bound
* for {@link maxDetailLod}.
*/
get maxLodLevel() {
return Math.max(0, this._metadata.lodLevels - 1);
}
/**
* When true (default), nodes whose bounding box is outside the camera frustum are biased to the coarsest
* LOD instead of being hidden. They stay in the sort/render set (their off-screen splats are clipped), so
* turning the camera toward them shows low detail immediately with no invisible frames, then refines.
* Changes take effect in real time.
*/
get frustumCulling() {
return this._frustumCulling;
}
set frustumCulling(value) {
if (this._frustumCulling === value) {
return;
}
this._frustumCulling = value;
// Re-evaluate LODs next frame so the off-screen bias is applied/removed immediately.
this._forceLodUpdate = true;
}
/**
* When true, renders a wireframe box per LOD node, colored by the LOD level selected by {@link debugLodSource}.
*/
get debugDisplay() {
return this._debugDisplay;
}
set debugDisplay(value) {
if (this._debugDisplay === value) {
return;
}
this._debugDisplay = value;
if (value) {
this._refreshDebugDisplay();
}
else {
this._clearDebugDisplay();
}
}
/**
* Selects which LOD value drives the debug wireframe colors: the distance-based `"optimal"` LOD
* (default, recomputed as the camera moves) or the `"current"` streamed/rendered LOD.
*/
get debugLodSource() {
return this._debugLodSource;
}
set debugLodSource(value) {
if (this._debugLodSource === value) {
return;
}
this._debugLodSource = value;
if (this._debugDisplay) {
this._refreshDebugDisplay();
}
}
dispose(doNotRecurse) {
if (this._disposed) {
// Idempotent: a failed load disposes from its own _streamAllAsync handler, and the awaiter's catch may
// dispose again — don't re-fire cleanup/observables (and super.dispose) a second time.
return;
}
this._disposed = true;
this._rejectPartReady("GaussianSplattingStream: disposed before the part was ready.");
this._unsubBeforeRebuild?.();
this._unsubAfterRebuild?.();
this._unsubBeforeRebuild = null;
this._unsubAfterRebuild = null;
this._hostUnsubRemove?.();
this._hostUnsubDispose?.();
this._hostUnsubRemove = null;
this._hostUnsubDispose = null;
// If this stream disposes on its own rather than because the host removed its part, release the reserved
// region (tombstone). Reclaiming the rows is a separate compaction — cheap disposal here so tearing down N
// parts doesn't trigger N atlas rebuilds; the caller/host reclaims when appropriate (a failed load compacts
// once, see the _streamAllAsync handler). Skipped when the host removed the part (it owns that policy).
if (this._host && this._hostCompound && !this._partReleasedByHost && !this._hostCompound.isDisposed()) {
this._hostCompound.removePart(this._host.partIndex);
}
this._host = null;
if (this._lodObserver) {
this._scene.onBeforeRenderObservable.remove(this._lodObserver);
this._lodObserver = null;
}
this._clearDebugDisplay();
this._downloadManager.dispose();
this._residency?.dispose();
this._residency = null;
this._workBuffer?.dispose();
this._workBuffer = null;
super.dispose(doNotRecurse);
}
/**
* Disposes this stream (which tombstones its region) and then compacts the host once to actually reclaim the
* reserved rows. Used on a definitive load failure / empty result — a discrete, one-off reclaim, versus a bare
* {@link dispose} that only tombstones so tearing down several parts doesn't rebuild the atlas repeatedly.
*/
_disposeAndReclaim() {
const compound = this._hostCompound;
const hadPart = !!this._host && !this._partReleasedByHost;
this.dispose();
if (hadPart && compound && !compound.isDisposed()) {
compound.compactAtlas();
}
}
/**
* The world matrix that actually places this stream's splats, used to map the camera into the space the
* node bounds live in (for LOD distance) and to build per-node world AABBs (for frustum culling). Standalone:
* this controller mesh carries the transform. Hosted: this controller is a hidden, unplaced node — the splats
* are placed by the reserved part's proxy (SOG up-axis basis composed with the host's placement), so LOD and
* culling MUST use the proxy's world matrix or they compute distances/frustum tests in the wrong space
* (producing wrong per-chunk LODs, i.e. holes, whenever the host applies a non-identity transform).
* @param force when true, forces a full world-matrix recompute (else uses the renderId/sync fast-path)
* @returns the effective world matrix for LOD/culling
*/
_getEffectiveWorldMatrix(force) {
if (this._host) {
return this._host.proxy.computeWorldMatrix(force);
}
return this.computeWorldMatrix(force);
}
/**
* Re-evaluates the optimal LOD for every node based on the camera position. The result is stored in
* each node's `optimalLod`. Rendering is unaffected; this currently drives only diagnostics and the
* debug wireframe display.
* @param camera camera to evaluate against (defaults to the scene's active camera)
*/
evaluateOptimalLods(camera = this._scene.activeCamera) {
if (!camera || this._leafNodes.length === 0) {
return;
}
const maxLod = Math.max(0, this._metadata.lodLevels - 1);
const base = this._lodBaseDistance;
const mult = this._lodMultiplier;
const behindPenalty = this._lodBehindPenalty;
const rangeMin = this._lodRangeMin;
const rangeMax = this._lodRangeMax;
// FOV compensation: use min(tanHalfV, tanHalfH) so transitions stay perceptually uniform (matches PlayCanvas).
const aspect = this._scene.getEngine().getAspectRatio(camera) || 1;
let tanHalfV = Math.tan(camera.fov * 0.5);
if (camera.fovMode === Camera.FOVMODE_HORIZONTAL_FIXED) {
tanHalfV /= aspect;
}
const tanHalfH = tanHalfV * aspect;
const fovScale = Math.min(tanHalfV, tanHalfH) / RefTanHalfFov;
// Transform the camera into the mesh's local space (where the node bounds live).
this._getEffectiveWorldMatrix(false).invertToRef(TmpInvWorld);
const localCamera = Vector3.TransformCoordinatesToRef(camera.globalPosition, TmpInvWorld, TmpLocalCamera);
const px = localCamera.x;
const py = localCamera.y;
const pz = localCamera.z;
let fwx = 0;
let fwy = 0;
let fwz = 0;
if (behindPenalty > 1) {
camera.getDirectionToRef(LocalForwardAxis, TmpWorldForward);
const localForward = Vector3.TransformNormalToRef(TmpWorldForward, TmpInvWorld, TmpLocalForward);
localForward.normalize();
fwx = localForward.x;
fwy = localForward.y;
fwz = localForward.z;
}
for (const node of this._leafNodes) {
const mn = node.bound.min;
const mx = node.bound.max;
// Distance from the camera to the closest point on this node's AABB (local space).
const qx = px < mn[0] ? mn[0] : px > mx[0] ? mx[0] : px;
const qy = py < mn[1] ? mn[1] : py > mx[1] ? mx[1] : py;
const qz = pz < mn[2] ? mn[2] : pz > mx[2] ? mx[2] : pz;
const dx = qx - px;
const dy = qy - py;
const dz = qz - pz;
const actualDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
// Push nodes behind the camera toward coarser LODs when a penalty is configured.
let penalizedDistance = actualDistance;
if (behindPenalty > 1 && actualDistance > 0.01) {
const dotOverDistance = (fwx * dx + fwy * dy + fwz * dz) / actualDistance;
if (dotOverDistance < 0) {
penalizedDistance = actualDistance * (1 + -dotOverDistance * (behindPenalty - 1));
}
}
// Geometric LOD bands: threshold[k] = base * mult^(k-1).
const fovAdjustedDistance = penalizedDistance * fovScale;
let optimalLod;
if (maxLod === 0 || fovAdjustedDistance < base) {
optimalLod = 0;
}
else {
optimalLod = maxLod;
while (optimalLod > 1 && fovAdjustedDistance < base * Math.pow(mult, optimalLod - 1)) {
optimalLod--;
}
}
if (optimalLod < rangeMin) {
optimalLod = rangeMin;
}
else if (optimalLod > rangeMax) {
optimalLod = rangeMax;
}
// Frustum-based LOD bias: nodes outside the camera frustum are pushed to the coarsest allowed
// level instead of being hidden. They stay in the render/sort set (their splats are off-screen
// and clipped anyway), so when the camera turns to include them they are already present at low
// detail with no invisible frames, then refine to the distance-optimal level.
if (this._frustumCulling && node.inFrustum === false) {
optimalLod = rangeMax;
}
node.optimalLod = optimalLod;
}
}
/**
* The LOD level used to color a node's debug box, per {@link debugLodSource}.
* @param node leaf node
* @returns the displayed LOD level
*/
_displayedLodLevel(node) {
if (this._debugLodSource === "optimal") {
return node.optimalLod ?? node.activeLod ?? 0;
}
return node.activeLod ?? 0;
}
/**
* Rebuilds the debug wireframe (evaluating the optimal LOD first when needed) and wires up the per-frame
* recolor observer. The observer runs for both LOD sources: "optimal" colors track the camera, and
* "current" colors track LOD levels as they stream in/out.
*/
_refreshDebugDisplay() {
if (this._debugLodSource === "optimal") {
this.evaluateOptimalLods();
}
this._buildDebugMesh();
const needsObserver = this._debugDisplay;
if (needsObserver && !this._debugObserver) {
this._debugObserver = this._scene.onBeforeRenderObservable.add(() => this._onDebugFrame());
}
else if (!needsObserver && this._debugObserver) {
this._scene.onBeforeRenderObservable.remove(this._debugObserver);
this._debugObserver = null;
}
}
/**
* Per-frame debug update: recolors the existing wireframe in place whenever the displayed LOD levels
* change. For the "optimal" source the optimal LOD is recomputed first (it tracks the camera); for the
* "current" source the levels are driven by the streaming loop, so no recomputation is needed here. The
* geometry is never rebuilt, which avoids the dispose/recreate flicker while the camera moves.
*/
_onDebugFrame() {
if (this._debugLodSource === "optimal") {
this.evaluateOptimalLods();
}
if (this._computeDebugSignature() !== this._debugSignature) {
this._updateDebugColors();
}
}
/**
* Builds the LOD-node wireframe boxes once (one box per leaf node), colored by the displayed LOD level.
* The color vertex buffer is created updatable so subsequent recolors can happen in place.
*/
_buildDebugMesh() {
if (this._debugMesh) {
this._debugMesh.dispose();
this._debugMesh = null;
}
this._debugColorData = null;
const lines = [];
const colors = [];
for (const node of this._leafNodes) {
const color = GsLodDebugColors[this._displayedLodLevel(node) % GsLodDebugColors.length];
const mn = node.bound.min;
const mx = node.bound.max;
const corners = [
new Vector3(mn[0], mn[1], mn[2]),
new Vector3(mx[0], mn[1], mn[2]),
new Vector3(mx[0], mx[1], mn[2]),
new Vector3(mn[0], mx[1], mn[2]),
new Vector3(mn[0], mn[1], mx[2]),
new Vector3(mx[0], mn[1], mx[2]),
new Vector3(mx[0], mx[1], mx[2]),
new Vector3(mn[0], mx[1], mx[2]),
];
for (const edge of BoxEdges) {
lines.push([corners[edge[0]], corners[edge[1]]]);
colors.push([color, color]);
}
}
this._debugSignature = this._computeDebugSignature();
if (lines.length === 0) {
return;
}
const mesh = CreateLineSystem(this.name + "_lodDebug", { lines, colors, updatable: true, useVertexAlpha: false }, this._scene);
mesh.parent = this;
mesh.isPickable = false;
mesh.doNotSerialize = true;
mesh.reservedDataStore = { hidden: true };
this._debugMesh = mesh;
this._debugColorData = new Float32Array(this._leafNodes.length * VerticesPerBox * 4);
}
/**
* Recolors the existing wireframe in place from the current displayed LOD levels, without rebuilding geometry.
*/
_updateDebugColors() {
if (!this._debugMesh || !this._debugColorData) {
return;
}
const data = this._debugColorData;
let offset = 0;
for (const node of this._leafNodes) {
const color = GsLodDebugColors[this._displayedLodLevel(node) % GsLodDebugColors.length];
for (let v = 0; v < VerticesPerBox; v++) {
data[offset++] = color.r;
data[offset++] = color.g;
data[offset++] = color.b;
data[offset++] = color.a;
}
}
this._debugMesh.updateVerticesData(VertexBuffer.ColorKind, data);
this._debugSignature = this._computeDebugSignature();
}
/**
* Computes a cheap 32-bit rolling hash of every leaf's displayed LOD level, used to detect when the
* debug wireframe needs recoloring. Avoids per-frame string allocation in the render loop.
* @returns a numeric signature of the current displayed LOD levels
*/
_computeDebugSignature() {
let hash = 0;
for (const node of this._leafNodes) {
hash = (hash * 31 + this._displayedLodLevel(node)) | 0;
}
return hash;
}
/**
* Disposes the LOD-node wireframe boxes and stops live debug updates.
*/
_clearDebugDisplay() {
if (this._debugObserver) {
this._scene.onBeforeRenderObservable.remove(this._debugObserver);
this._debugObserver = null;
}
if (this._debugMesh) {
this._debugMesh.dispose();
this._debugMesh = null;
}
this._debugColorData = null;
this._debugSignature = 0;
}
/**
* Walks the LOD tree and records every leaf that carries renderable LOD entries, capturing the set of
* available levels and the coarsest (base) level for each.
* @param node current tree node
*/
_collectLodEntries(node) {
if (node.children) {
for (const child of node.children) {
this._collectLodEntries(child);
}
return;
}
if (!node.lods) {
return;
}
// Collect all levels that hold splats (PlayCanvas convention: level 0 is the finest, higher = coarser).
const levels = [];
for (const key of Object.keys(node.lods)) {
const level = Number(key);
const entry = node.lods[key];
if (Number.isFinite(level) && entry && entry.count > 0) {
levels.push(level);
}
}
if (levels.length === 0) {
return;
}
levels.sort((a, b) => a - b);
node.availableLevels = levels;
node.baseLod = levels[levels.length - 1];
node.activeLod = undefined;
node.lodCooldown = 0;
node.inFrustum = true;
// Local-space bounds for the per-node frustum test; the mesh world matrix is applied per evaluation.
node.cullBounds = new BoundingInfo(Vector3.FromArray(node.bound.min), Vector3.FromArray(node.bound.max));
this._leafNodes.push(node);
}
/**
* Streams the scene: learns every source file's splat count, allocates one unified GPU work buffer
* sized for all LOD files, decodes the environment and the coarsest LOD of every node as a permanent
* base layer, then installs the per-frame loop that streams finer LODs on demand.
*/
async _streamAllAsync() {
// Step 1: learn splat counts for the environment and every referenced LOD file (cheap meta only). This also
// resolves the max SH degree, so the resident-splat budget can now be sized with the SH/rotation byte cost.
const fileIds = this._collectAllFileIds();
const envCount = await this._gatherCountsAsync(fileIds);
if (this._disposed) {
return;
}
this._resolveResidentBudget();
// Step 2: learn the full dataset size (padding + environment + every LOD file). The work buffer is
// sized to this unless a smaller budget enables eviction-based streaming.
// Index 0 is reserved as a never-decoded padding splat: the sort worker and index buffer pad unused
// slots with index 0, and leaving that slot zeroed (center.w = 0 => zero covariance, alpha 0) makes
// the padding invisible instead of ghosting a copy of the first real splat.
let fullCapacity = 1;
if (envCount > 0) {
fullCapacity += envCount;
}
for (const fileId of fileIds) {
const count = this._fileCounts.get(fileId);
if (count !== undefined && count > 0) {
fullCapacity += count;
}
}
if (fullCapacity <= 1) {
return;
}
// Eviction streams the dataset through a fixed budget; only enabled when that budget is below the full set.
this._evictionEnabled = this._residentBudget > 0 && this._residentBudget < fullCapacity;
const capacity = this._evictionEnabled ? Math.max(this._residentBudget, 1) : fullCapacity;
this._residency = new GaussianSplattingResidencyController(capacity, this._evictionCooldownFrames, (file) => this._onFileEvicted(file));
// Pin splat 0 as the invisible padding splat, then the environment (always rendered) — neither is evicted.
this._residency.pin(PaddingFileId, 1);
if (envCount > 0) {
const envOffset = this._residency.pin(EnvironmentFileId, envCount);
if (envOffset !== null) {
this._environmentRange = { offset: envOffset, count: envCount };
}
else {
Logger.Warn("GaussianSplattingStream: environment does not fit the memory budget; skipping it.");
this._environmentFiles = null;
}
}
if (this._hostCompound) {
// Hosted: reserve a region of the compound sized to the work buffer, orient the part's proxy for the
// SOG up-axis, and decode straight into the compound's shared atlas so the streamed splats sort/draw
// in one pass with the compound's other parts. The compound owns the worker/render; this mesh stays
// a hidden controller.
const sogWorld = Matrix.Compose(new Vector3(1, -1, 1), Quaternion.RotationYawPitchRoll(0, -Math.PI / 2, 0), Vector3.ZeroReadOnly);
// Reserve with SH so the compound converts its SH textures to shared render-targetable integer MRTs and
// sets its SH degree; the hosted work buffer bakes into those shared targets at the region base offset.
const host = this._hostCompound.reserveStreamingPart(capacity, sogWorld, this.name + "_part", this._shTextureCount, this._streamShDegree, this._needsRotationScale);
this._host = host;
this._positionBase = host.base;
// Bind this controller's lifetime to its part FROM RESERVATION (not after readiness): removing the part or
// disposing the compound — even while still downloading/decoding — disposes this stream so it stops writing
// into the compound's borrowed textures. `_partReleasedByHost` stops dispose() from removing the part again.
const compound = this._hostCompound;
// The remove observer needs the assigned part index, so it is registered here (at reservation); the
// compound-disposal observer was already registered at construction (see the ctor) to cover the pre-pass.
const removeObserver = compound.onPartRemovedObservable.add((removedIndex) => {
if (!this._disposed && this._host && removedIndex === this._host.partIndex) {
this._partReleasedByHost = true;
this.dispose();
}
});
this._hostUnsubRemove = () => compound.onPartRemovedObservable.remove(removeObserver);
const shExternal = this._shTextureCount > 0 && host.shMrtAtlas ? { textureCount: this._shTextureCount, externalMrts: host.shMrtAtlas } : undefined;
const rotExternal = this._needsRotationScale && host.rotMrtAtlas ? { externalMrt: host.rotMrtAtlas } : undefined;
// Use the region's ROW-ALIGNED capacity (host.capacity), not the raw stream capacity: backup/restore/
// relayout scope to whole atlas rows, so an unaligned capacity would drop the region's partial final row.
this._workBuffer = new GaussianSplattingWorkBuffer(this._scene, host.capacity, {
mrt: host.mrtAtlas,
width: host.atlasWidth,
baseOffset: host.base,
}, shExternal, rotExternal);
this._readbackCandidate = this._workBuffer.supportsAsyncCentersReadback;
// Write decoded centers directly into the compound's shared position buffer (offset by the region base).
this._splatPositions = host.splatPositions;
this._vertexCount = capacity;
// Preserve this region's GPU-only data when the compound grows its atlas (adding a part / another
// stream): back it up before the old atlas is disposed, then rebind + restore into the new atlas.
const wb = this._workBuffer;
this._unsubBeforeRebuild = host.onBeforeAtlasRebuild(() => {
// Back up the region's atlas texels, and snapshot its CPU positions: the grow reallocates the shared
// `_splatPositions` and rebuilds it from CPU part sources, but this region has none, so its positions
// would be lost. `this._splatPositions` is still the pre-grow array and holds the real positions.
wb.backupRegion();
this._positionSnapshot = this._splatPositions ? this._splatPositions.slice(this._positionBase * 4, (this._positionBase + this._vertexCount) * 4) : null;
});
this._unsubAfterRebuild = host.onAfterAtlasRebuild(() => {
if (host.mrtAtlas) {
wb.rebindAtlas(host.mrtAtlas);
}
// Rebind to the recreated shared SH and rotation atlases; restoreRegion() writes the backups into them.
wb.rebindShAtlas(host.shMrtAtlas);
wb.rebindRotAtlas(host.rotMrtAtlas);
// A plain grow keeps `host.base`; a compaction relocates the region to a new base. Update the base
// before restoring so the region's texels and positions land there.
this._positionBase = host.base;
wb.setBaseOffset(host.base);
wb.restoreRegion();
// Re-cache the reallocated shared array and restore the region's CPU positions at the (new) base.
this._splatPositions = host.splatPositions;
if (this._positionSnapshot && this._splatPositions) {
this._splatPositions.set(this._positionSnapshot, this._positionBase * 4);
this._positionSnapshot = null;
}
});
// Nothing active until a resource is decoded (as a range on the reserved part).
host.setActiveRanges([]);
}
else {
// Bake higher-order SH when requested and present: the work buffer owns `_shTextureCount` integer SH
// targets and the draw path lights the decoded splats with them (SH degree = max across files).
const sh = this._shTextureCount > 0 ? { textureCount: this._shTextureCount } : undefined;
// Decode rotation/scale into an owned 3-attachment half-float target when voxel-IBL shadows are requested.
const rot = this._needsRotationScale ? {} : undefined;
this._workBuffer = new GaussianSplattingWorkBuffer(this._scene, capacity, undefined, sh, rot);
// GPU readback is only enabled after it is validated against a CPU decode on the first file (see
// _probeReadbackAsync); until then positions are decoded on the CPU so there is always a correct result.
this._readbackCandidate = this._workBuffer.supportsAsyncCentersReadback;
const splatPositions = new Float32Array(capacity * 4);
const textures = this._workBuffer.textures;
const shTextures = sh ? this._workBuffer.shTextures : undefined;
const rotTextures = rot ? this._workBuffer.rotationTextures : undefined;
this._setExternalWorkBuffer(textures[0], textures[1], textures[2], textures[3], splatPositions, capacity, shTextures, this._streamShDegree, rotTextures);
// Nothing is active until at least one resource has been decoded.
this.setSplatIndexRanges([]);
this.setEnabled(true);
}
// Hosted only: compile the region's backup/restore copy shaders BEFORE decoding any data, so a later
// grow/compaction (which synchronously backs this region up) can never race shader compilation and lose it.
if (this._host && this._workBuffer) {
await this._waitForCanBackupAsync(this._workBuffer);
if (this._disposed) {
return;
}
}
// Step 3: decode the environment, then every node's coarsest LOD as the permanent base layer.
if (this._environmentRange && this._environmentFiles) {
await this._decodeEnvironmentAsync();
}
this._environmentFiles = null;
const baseFiles = new Set();
for (const node of this._leafNodes) {
const entry = node.lods[String(node.baseLod)];
if (entry && this._fileCounts.has(entry.file)) {
baseFiles.add(entry.file);
}
}
for (const fileId of Array.from(baseFiles)) {
if (this._disposed) {
return;
}
// eslint-disable-next-line no-await-in-loop
await this._decodeFileAsync(fileId);
}
if (this._disposed) {
return;
}
// Step 4: hand off to the per-frame LOD streaming loop.
this._baseLayerReady = true;
if (!this._lodObserver) {
this._lodObserver = this._scene.onBeforeRenderObservable.add(() => this._onLodFrame());
}
// Hosted: the reserved part now exists with a decoded base layer and real bounds — release awaiters.
this._resolvePartReady();
}
/**
* Waits (up to a frame cap) until the work buffer's backup/restore copy shaders are compiled, so a later
* grow/compaction can preserve this hosted region (see {@link GaussianSplattingWorkBuffer.backupRegion}).
* Polls per rendered frame: shader readiness here depends on the render loop (and the shared atlas can be
* rebuilt concurrently), so this stays synchronized with the render-driven decode and always makes progress.
* On timeout it proceeds best-effort — a subsequent grow/compaction then warns rather than blocking decode.
* @param wb the hosted work buffer to wait on
*/
async _waitForCanBackupAsync(wb) {
for (let frame = 0; frame < 600 && !this._disposed; frame++) {
if (wb.canBackup) {
return;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => this._scene.onBeforeRenderObservable.addOnce(() => resolve()));
}
if (!this._disposed && !wb.canBackup) {
Logger.Warn("GaussianSplattingStream: backup/restore copy shaders did not compile in time; a grow/compaction before they are ready may drop streamed data.");
}
}
/**
* Resolves the resident-splat budget from the raw options, sizing a memory (MB) budget with the actual per-splat
* GPU+CPU cost — core data plus the baked SH textures and rotation/scale textures when enabled — so SH/rotation
* assets don't silently consume up to double the configured budget. Requires the SH degree (from the metadata
* pre-pass) to be known. The smaller of the splat-count and memory budgets wins.
*/
_resolveResidentBudget() {
let budget = this._maxResidentSplats;
if (this._memoryBudgetMb > 0) {
// Per resident splat: core 84 B, + 16 B per packed-u32 SH texture, + the 3 RGBA rotation textures. The
// work buffer uses half-float rotation textures (8 B each = 24 B) when the engine can render to them,
// else full float (16 B each = 48 B) — match that so fallback devices aren't under-budgeted.
const rotBytes = this._scene.getEngine().getCaps().textureHalfFloatRender ? 24 : 48;
const bytesPerSplat = BytesPerResidentSplat + this._shTextureCount * 16 + (this._needsRotationScale ? rotBytes : 0);
const fromMB = Math.floor((this._memoryBudgetMb * 1024 * 1024) / bytesPerSplat);
budget = budget > 0 ? Math.min(budget, fromMB) : fromMB;
}
this._residentBudget = budget;
}
/**
* Collects the unique set of source file indices referenced by any LOD of any leaf, sorted ascending.
* @returns sorted unique file indices
*/
_collectAllFileIds() {
const ids = new Set();
for (const node of this._leafNodes) {
for (const level of node.availableLevels) {
const entry = node.lods[String(level)];
if (entry) {
ids.add(entry.file);
}
}
}
return Array.from(ids).sort((a, b) => a - b);
}
/**
* Fetches the environment bundle and every referenced file's metadata to learn splat counts, caching
* each file's parsed metadata for the later on-demand decode. Metadata fetches run in parallel.
* @param fileIds file indices to fetch metadata for
* @returns the environment splat count (0 when there is no environment)
*/
async _gatherCountsAsync(fileIds) {
let envCount = 0;
// Track the max SH degree/coeffs across every streamed file (+ environment): the baked SH atlas is sized
// for the max once, up front, so no mid-stream resize — lower-degree files neutral-fill their higher bands.
let maxShDegree = 0;
let maxCoeffs = 0;
const foldSh = (data) => {
const info = GaussianSplattingStream._GetShInfo(data);
if (info.degree > maxShDegree) {
maxShDegree = info.degree;
}
if (info.coeffs > maxCoeffs) {
maxCoeffs = info.coeffs;
}
};
if (this._metadata.environment) {
try {
const url = this._rootUrl + this._metadata.environment;
const buffer = await this._downloadManager.loadFileAsync(url);
const files = await this._unzipAsync(new Uint8Array(buffer));
const metaBytes = files.get("meta.json");
if (metaBytes) {
const meta = JSON.parse(new TextDecoder().decode(metaBytes));
envCount = GaussianSplattingStream._GetSplatCount(meta);
foldSh(meta);
this._environmentFiles = files;
}
}
catch (e) {
// The environment is non-essential — keep streaming the LOD tree even if it fails.
Logger.Warn("GaussianSplattingStream: failed to load environment: " + (e?.message ?? e));
}
}
await Promise.all(fileIds.map(async (fileId) => {
const relativePath = this._metadata.filenames[fileId];
if (!relativePath) {
Logger.Warn(`GaussianSplattingStream: missing filename for file index ${fileId}.`);
return;
}
try {
const metaUrl = this._rootUrl + relativePath;
const subRootUrl = metaUrl.substring(0, metaUrl.lastIndexOf("/") + 1);
const metaBuffer = await this._downloadManager.loadFileAsync(metaUrl);
const sogData = JSON.parse(new TextDecoder().decode(new Uint8Array(metaBuffer)));
this._fileCounts.set(fileId, GaussianSplattingStream._GetSplatCount(sogData));
this._fileMeta.set(fileId, { sogData, subRootUrl });
}
catch (e) {
Logger.Warn(`GaussianSplattingStream: failed to load metadata for ${relativePath}: ${e?.message ?? e}`);
}
}));
// Fold in every file's SH (done after the parallel fetch so _fileMeta is fully populated).
for (const { sogData } of this._fileMeta.values()) {
foldSh(sogData);
}
// Resolve the stream's baked-SH configuration: enabled only when requested AND the data carries shN.
if (this._decodeSh && maxShDegree > 0 && maxCoeffs > 0) {
this._streamShDegree = maxShDegree;
// Packed-u32 SH textures: 16 SH scalar-bytes per texel, 3 channels per coefficient (matches ParseSogDatas).
this._shTextureCount = Math.ceil((maxCoeffs * 3) / 16);
}
return envCount;
}
/**
* Queues a file for on-demand decode if it isn't already decoded, in flight, or already queued.
* @param fileId file index to decode
*/
_enqueueDecode(fileId) {
if (this._decodedFiles.has(fileId) || this._loadingFiles.has(fileId) || !this._fileMeta.has(fileId)) {
return;
}
if (this._decodeQueue.indexOf(fileId) === -1) {
this._decodeQueue.push(fileId);
}
}
/**
* Starts up to {@link _maxDecodesPerFrame} queued decodes for this frame. Decodes run asynchronously
* and promote any waiting nodes once they complete.
*/
_pumpDecodeQueue() {
let started = 0;
while (this._decodeQueue.length > 0 && started < this._maxDecodesPerFrame) {
const fileId = this._decodeQueue.shift();
if (this._decodedFiles.has(fileId) || this._loadingFiles.has(fileId)) {
continue;
}
started++;
// eslint-disable-next-line github/no-then
this._decodeFileAsync(fileId).catch((e) => {
Logger.Warn("GaussianSplattingStream: decode failed: " + (e?.message ?? e));
});
}
}
/**
* Writes a decoded splat range's positions into the shared buffer, expands the bounds, and incrementally
* patches the sort worker.
* @param positions stride-4 positions for the range
* @param base first splat index of the range in the work buffer
* @param count number of splats in the range
*/
_applyPositions(positions, base, count) {
// In hosted mode _splatPositions is the compound's shared buffer; the region starts at _positionBase.
this._splatPositions.set(positions, (this._positionBase + base) * 4);
this._updateBounds(positions, count);
// Incrementally patch only this range in the sort worker (avoids the full position-buffer re-copy).
this._sinkPostPositionsRange(base, count);
}
// ---- Sink routing: standalone drives this mesh; hosted drives the compound's reserved-part handle. ----
/**
* Sets the active source ranges (local to the stream's buffer) on the render sink.
* @param localRanges active ranges in the stream's local index space
*/
_sinkSetActiveRanges(localRanges) {
if (this._host) {
this._host.setActiveRanges(localRanges);
}
else {
this.setSplatIndexRanges(localRanges);
}
}
/**
* Patches a decoded position range (local offset) into the render sink's sort worker.
* @param base first splat index of the range, local to the stream's buffer
* @param count number of splats in the range
*/
_sinkPostPositionsRange(base, count) {
if (this._host) {
this._host.postPositionsRange(base, count);
}
else {
this._postWorkerPositionsRange(base, count);
}
}
/** Re-posts the full position/part set to the render sink's worker (after a relayout moved the region). */
_sinkNotifyDataChanged() {
if (this._host) {
this._host.notifyDataChanged();
}
else {
this._notifyWorkerNewData();
}
}
/** Whether the render sink's depth sort is settled. */
get _sinkIsDepthSortSettled() {
return this._host ? this._host.isDepthSortSettled : this._isDepthSortSettled;
}
/**
* One-time validation of GPU position readback: reads a sample of the just-decoded range back from the work
* buffer and compares it to the CPU-decoded positions. Enables {@link _useGpuPositionReadback} only on an
* exact (within float tolerance) match, so an unsupported or incorrect readback (e.g. a backend without the
* required texture usage, or an orientation mismatch) safely keeps the CPU decode path.
* @param base first splat index of the validated range
* @param count number of splats in the range
* @param cpuPositions the CPU-decoded stride-4 positions for the range (ground truth)
*/
async _probeReadbackAsync(base, count, cpuPositions) {
this._readbackProbed = true;
if (!this._workBuffer) {
return;
}
const sampleCount = Math.min(count, 1024);
let ok = false;
try {
const gpu = await this._workBuffer.readCentersRangeAsync(base, sampleCount);
if (this._disposed) {
return;
}
if (gpu && gpu.length >= sampleCount * 4) {
ok = true;
for (let i = 0; i < sampleCount && ok; i++) {
for (let j = 0; j < 3; j++) {
const a = gpu[i * 4 + j];
const b = cpuPositions[i * 4 + j];
if (Math.abs(a - b) > 1e-2 * (1 + Math.abs(b))) {
ok = false;
break;
}
}
}
}
}
catch {
ok = false;
}
this._useGpuPositionReadback = ok;
Logger.Log(ok
? "GaussianSplattingStream: GPU position readback validated; streamed LOD positions are read back from the GPU."
: "GaussianSplattingStream: GPU position readback unavailable; decoding LOD positions on the CPU.");
}
/**
* Resolves the decoded positions for a splat range and applies them. Once GPU readback has been validated,
* positions are read back from the work buffer (non-blocking) and `pack.positions` is empty; otherwise the
* CPU-decoded `pack.positions` are used, and — on the first such decode — the GPU readback is validated
* against them so subsequent decodes can use the fast path.
* @param pack the parsed SOG pack (its `positions` is populated only on the CPU path)
* @param base first splat index of the range in the work buffer
* @param count number of splats in the range
* @returns whether positions were applied
*/
async _applyDecodedPositionsAsync(pack, base, count) {
if (this._useGpuPositionReadback && this._workBuffer) {
const positions = await this._workBuffer.readCentersRangeAsync(base, count);
if (this._disposed) {
return false;
}
if (positions && this._splatPositions) {
this._applyPositions(positions, base, count);
return true;
}
// Validated readback unexpectedly returned nothing; fall through to the (likely empty) CPU positions.
}
const cpu = pack.positions.length >= count * 4 ? pack.positions.subarray(0, count * 4) : null;
if (!cpu || !this._splatPositions) {
return false;
}
this._applyPositions(cpu, base, count);
// First CPU decode while readback is a candidate: validate it so later decodes can use the fast path.
if (!this._readbackProbed && this._readbackCandidate) {
await this._probeReadbackAsync(base, count, cpu);
}
return true;
}
/**
* Decodes the always-on environment bundle into its work-buffer block and activates its range.
*/
async _decodeEnvironmentAsync() {
if (!this._environmentRange || !this._environmentFiles || !this._workBuffer) {
return;
}
const range = this._environmentRange;
try {
const parsed = await ParseSogMetaAsTextures(this._environmentFiles, "", this._scene, !this._useGpuPositionReadback, this._downloadManager);
const pack = parsed.sogTextures;
if (!pack) {
return;
}
try {
if (this._disposed || !this._workBuffer) {
return;
}
await this._workBuffer.decodeAsync(pack, range.offset);
if (this._disposed) {
return;
}
await this._applyDecodedPositionsAsync(pack, range.offset, range.count);
if (this._disposed) {
return;
}
this._refreshActiveRanges();
}
finally {
// Always release the GPU source textures (the decode pass is the only consumer).
GaussianSplattingStream._DisposePack(pack);
}
}
catch (e) {
Logger.Warn("GaussianSplattingStream: failed to decode environment: " + (e?.message ?? e));
}
}
/**
* Loads one LOD source file as GPU textures, decodes it into its fixed work-buffer block, records its
* CPU centers for sorting, frees the source textures, then promotes any nodes that were waiting for it.
* Concurrent or repeat requests for the same file are ignored. If the file is cancelled mid-flight
* (because every node that wanted it retargeted), the decode bails cooperatively at the next checkpoint.
* @param fileId file index to decode
*/
async _decodeFileAsync(fileId) {
if (this._decodedFiles.has(fileId) || this._loadingFiles.has(fileId) || !this._residency) {
return;
}
const meta = this._fileMeta.get(fileId);
const count = this._fileCounts.get(fileId);
if (!meta || count === undefined) {
return;
}
this._loadingFiles.add(fileId);
this._cancelledDecodes.delete(fileId);
let allocated = false;
try {
const parsed = await ParseSogMetaAsTextures(meta.sogData, meta.subRootUrl, this._scene, !this._useGpuPositionReadback, this._downloadManager, fileId);
const pack = parsed.sogTextures;
if (!pack) {
return;
}
// Serialize the allocate -> decode -> readback section: a relayout runs only inside it (see
// _relayoutAndAllocateAsync), so it never moves a file whose decode has not finished writing.
const release = await this._acquireDecodeGateAsync();
try {
if (this._disposed || !this._workBuffer || this._cancelledDecodes.has(fileId)) {
return;
}
let base = this._residency.allocate(fileId, count);
if (base === null) {
// Defragment the work buffer to reclaim fragmented free space, then retry.
base = await this._relayoutAndAllocateAsync(fileId, count);
}
if (base === null) {
// No room even after evicting and compacting: refuse and keep nodes on their current LOD.
// A file cancelled mid-flight isn't a budget problem, so don't warn for it.
if (!this._cancelledDecodes.has(fileId)) {
Logger.Warn(`GaussianSplattingStream: resident memory budget full; skipping LOD file ${fileId}.`);
}
return;
}
allocated = true;
if (this._disposed || !this._workBuffer || this._cancelledDecodes.has(fileId)) {
return;
}
await this._workBuffer.decodeAsync(pack, base);
if (this._disposed || this._cancelledDecodes.has(fileId)) {
return;
}
await this._applyDecodedPositionsAsync(pack, base, count);
if (this._disposed) {
return;
}
this._decodedFiles.add(fileId);
// Promote any nodes that can now reach their desired LOD via this newly decoded file.
if (this._applyDesiredLods()) {
this._refreshActiveRanges();
}
}
finally {
GaussianSplattingStream._DisposePack(pack);
release();
}
}
catch (e) {
// A cancelled file rejects its downloads on purpose — swallow that; re-throw genuine failures.
if (!this._cancelledDecodes.has(fileId)) {
throw e;
}
}
finally {
// If a slot was allocated but the decode did not complete (cancelled/disposed), release it.
if (allocated && !this._decodedFiles.has(fileId)) {
this._residency.free(fileId);
}
this._loadingFiles.delete(fileId);
this._cancelledDecodes.delete(fileId);
}
}
/**
* Acquires the decode gate (a simple async mutex). Resolves once any prior holder releases, returning a
* release function the caller must invoke in a `finally`.
* @returns the release function
*/
async _acquireDecodeGateAsync() {
const previous = this._decodeGate;
let release;
this._decodeGate = new Promise((resolve) => {
release = resolve;
});
await previous;
return release;
}
/**
* Defragments the work buffer to make room for a file that did not fit, then allocates its slot. Runs the
* compaction + GPU relayout atomically inside a single `onBeforeRender` so no inconsistent CPU/GPU layout
* is ever rendered. Returns the new slot offset, or null if even compaction cannot free enough contiguous
* space (the caller refuses the upgrade).
* @param fileId file to allocate after compaction
* @param count splats the file needs
* @returns the allocated offset, or null
*/
async _relayoutAndAllocateAsync(fileId, count) {
if (!this._residency || !this._workBuffer) {
return null;
}
// Even a perfect compaction cannot help if the total free space is below what is needed.
if (this._residency.freeSize < count) {
return null;
}
return await new Promise((resolve) => {
const attempt = () => {
// Bail out (no relayout) if the file was cancelled while we waited for the shader to be ready,
// so rapidly-changing targets don't trigger an expensive compaction for a file no longer needed.
if (this._disposed || !this._residency || !this._workBuffer || this._cancelledDecodes.has(fileId)) {
resolve(null);
return;
}
if (!this._workBuffer.isRelayoutReady()) {
this._scene.onBeforeRenderObservable.addOnce(attempt);
return;
}
this._performRelayout();
resolve(this._residency.allocate(fileId, count));
};
this._scene.onBeforeRenderObservable.addOnce(attempt);
});
}
/**
* Compacts the resident set and relocates the corresponding GPU textures and CPU positions to the new
* layout. Must run at a frame-safe point with the work buffer's relayout shader ready.
*/
_performRelayout() {
if (!this._residency || !this._workBuffer || !this._splatPositions) {
return;
}
const oldOffsets = this._relayoutOldOffsets;
oldOffsets.clear();
for (const block of this._residency.getResidentBlocks()) {
oldOffsets.set(block.file, block.offset);
}
const moves = this._residency.compact();
if (moves.length === 0) {
return;
}
const capacity = this._residency.capacity;
if (!this._relayoutSrcIndex || this._relayoutSrcIndex.length !== capacity) {
this._relayoutSrcIndex = new Float32Array(capacity);
}
const srcIndexByDst = this._relayoutSrcIndex;
srcIndexByDst.fill(-1);
const resident = this._residency.getResidentBlocks();
// Destination->source splat index map for the GPU relayout pass.
for (const block of resident) {
const oldOffset = oldOffsets.get(block.file);
for (let k = 0; k < block.count; k++) {
srcIndexByDst[block.offset + k] = oldOffset + k;
}
}
// GPU: relocate the decoded textures in place (same texture instances).
this._workBuffer.relayoutSync(srcIndexByDst);
// CPU positions: compaction only ever moves a block to a lower offset, so copying in place in ascending
// new-offset order is safe (a block's source is never overwritten by an earlier move). This avoids a
// full capacity*4 scratch buffer. Block offsets are region-local; in hosted mode `_splatPositions` is the
// compound-wide buffer, so shift both source and destination by the region base (`_positionBase`, 0 standalone).
const positions = this._splatPositions;
const base = this._positionBase;
resident.sort((a, b) => a.offset - b.offset);
for (const block of resident) {
const oldOffset = oldOffsets.get(block.file);
if (oldOffset !== block.offset) {
positions.copyWithin((base + block.offset) * 4, (base + oldOffset) * 4, (base + oldOffset + block.count) * 4);
}
}
// Update the environment offset (it may have moved), re-post to the sort worker, and refresh ranges.
if (this._environmentRange) {
const envOffset = this._residency.offset(EnvironmentFileId);
if (envOffset !== undefined) {
this._environmentRange.offset = envOffset;
}
}
this._sinkNotifyDataChanged();
this._refreshActiveRanges();
}
/**
* Drops a file evicted by the residency controller from the decoded set so it will be re-decoded on demand.
* The file had no remaining references, so no node was rendering or downloading it.
* @param fileId evicted file index
*/
_onFileEvicted(fileId) {
this._decodedFiles.delete(fileId);
}
/**
* Snaps a desired LOD level to the nearest level the node provides, while never selecting a level finer
* than {@link maxDetailLod} (i.e. with an index below the cap). Ties prefer the finer allowed level. If
* the node has no level at or coarser than the cap, its coarsest available level is used.
* @param node leaf node
* @param desired desired LOD level
* @returns the chosen available level
*/
_cappedLevelForNode(node, desired) {
const levels = node.availableLevels;
const floor = this._maxDetailLod;
let best = -1;
let bestDiff = Number.POSITIVE_INFINITY;
for (const level of levels) {
if (level < floor) {
continue;
}
const diff = Math.abs(level - desired);
if (diff < bestDiff) {
best = level;
bestDiff = diff;
}
}
// No level is coarse enough to satisfy the cap: fall back to the coarsest the node has.
return best < 0 ? node.baseLod : best;
}
/**
* Computes each node's {@link ISOGLODNode.targetLevel}: the distance-based optimal level snapped to an
* available level, capped so no node renders finer (more detailed) than {@link maxDetailLod}.
*/
_computeTargetLevels() {
for (const node of this._leafNodes) {
const desired = node.optimalLod ?? node.baseLod;
node.targetLevel = this._cappedLevelForNode(node, desired);
}
}
/**
* Applies each node's {@link ISOGLODNode.targetLevel}: switches a node to its target level when that
* level's file is already decoded, otherwise records a pending download request for the file and leaves
* the node on its current LOD (so nothing ever disappears). Nodes within their post-switch cooldown are
* left untouched to damp oscillation (and keep their existing pending request).
*
* Each node tracks the single file it currently needs but lacks ({@link ISOGLODNode.pendingFile}). When a
* node's target changes before that file finished downloading, the old file's reference is released; if no
* other node still needs it, its queued/in-flight download is cancelled (see {@link _releaseFileRef}).
* @returns true when at least one node changed LOD (callers should refresh the active ranges)
*/
_applyDesiredLods() {
let dirty = false;
for (const node of this._leafNodes) {
// Nodes in cooldown keep their current LOD and their existing pending request untouched.
if (node.lodCooldown && node.lodCooldown > 0) {
continue;
}
const desired = node.targetLevel ?? node.baseLod;
let newPending;
if (desired !== node.activeLod) {
const entry = node.lods[String(desired)];
if (entry) {
if (this._decodedFiles.has(entry.file)) {
this._switchActiveFile(node, entry.file);
node.activeLod = desired;
node.lodCooldown = this._lodCooldownFrames;
dirty = true;
}
else {
newPending = entry.file;
}
}
}
// Reconcile this node's pending-download reference against its (possibly changed) target.
if (node.pendingFile !== newPending) {
if (node.pendingFile !== undefined) {
this._releaseFileRef(node.pendingFile);
}
if (newPending !== undefined) {
this._acquirePendingFile(newPending);
}
node.pendingFile = newPending;
}
}
return dirty;
}
/**
* Moves a node's resident reference from its previous active file to the one it now renders, so the file
* count that keeps a block in the work buffer stays accurate (and cancels any pending eviction of the new
* file). The new file is already decoded.
* @param node leaf node switching its rendered file
* @param file the file the node now renders from
*/
_switchActiveFile(node, file) {
if (node.activeFile === file) {
return;
}
if (node.activeFile !== undefined) {
this._releaseFileRef(node.activeFile);
}
this._acquireFileRef(file);
node.activeFile = file;
}
/**
* Adds a reference to a file (active render or pending download), cancelling any scheduled eviction.
* @param fileId file index
*/
_acquireFileRef(fileId) {
const refs = (this._fileRefs.get(fileId) ?? 0) + 1;
this._fileRefs.set(fileId, refs);
if (refs === 1) {
// Referenced again before its eviction cooldown elapsed: keep it resident.
this._residency?.cancelEviction(fileId);
}
}
/**
* Records that a node needs a not-yet-decoded file, bumping its reference count and queueing the decode.
* @param fileId file index the node now targets
*/
_acquirePendingFile(fileId) {
this._acquireFileRef(fileId);
this._enqueueDecode(fileId);
}
/**
* Releases a node's reference to a file. When the last reference is dropped: a decoded file is scheduled
* for eviction (when streaming under a budget), and a still-downloading file has its queued decode dropped
* and any in-flight download cancelled.
* @param fileId file index the node no longer references
*/
_releaseFileRef(fileId) {
const refs = (this._fileRefs.get(fileId) ?? 0) - 1;
if (refs > 0) {
this._fileRefs.set(fileId, refs);
return;
}
this._fileRefs.delete(fileId);
if (this._decodedFiles.has(fileId)) {
// No node renders it anymore: schedule eviction (only when streaming under a budget).
if (this._evictionEnabled) {
this._residency?.scheduleEviction(fileId);
}
return;
}
// Still downloading/queued: drop the queued decode and cancel any in-flight download.
const queueIndex = this._decodeQueue.indexOf(fileId);
if (queueIndex !== -1) {
this._decodeQueue.splice(queueIndex, 1);
}
if (this._loadingFiles.has(fileId)) {
// Flag the in-flight decode to bail at its next checkpoint and cancel its image downloads.
this._cancelledDecodes.add(fileId);
this._downloadManager.cancelGroup(fileId);
}
}
/**
* Per-frame LOD streaming loop. Ticks cooldowns and pumps the decode queue every frame, and runs the
* cheap per-node frustum test every frame so the off-screen LOD bias tracks camera rotation. The LOD
* re-evaluation is throttled to at most every {@link _lodUpdateInterval} frames once the camera has
* translated far enough, but also runs immediately whenever a node enters/leaves the frustum (so its
* detail upgrades/downgrades promptly), a node whose cooldown just expired still needs to switch LOD,
* or a cap change forces it. Active ranges rebuild on any LOD change.
*
* The cooldown-expiry trigger lets a node reach its already-computed target level as soon as its
* cooldown clears, rather than waiting for the camera to move. This matters right from load: a
* node's base-layer decode is itself applied as a switch (from no active level to the base one), so
* it starts the same cooldown a later switch would — this trigger is what lets the node progress past
* that base level promptly once it expires, even at a fixed camera pose.
*/
_onLodFrame() {
if (this._disposed || !this._baseLayerReady) {
return;
}
let cooldownExpiredWithPendingSwitch = false;
for (const node of this._leafNodes) {
if (node.lodCooldown && node.lodCooldown > 0) {
node.lodCooldown--;
if (node.lodCooldown === 0 && node.targetLevel !== undefined && node.targetLevel !== node.activeLod) {
cooldownExpiredWithPendingSwitch = true;
}
}
}
// Tick eviction cooldowns: unreferenced files are freed once their cooldown elapses (budgeted streaming).
if (this._evictionEnabled) {
this._residency?.tick();
}
// In-flight/queued decodes still progress every frame.
this._pumpDecodeQueue();
// Per-node frustum test runs every frame (cheap) so the off-screen LOD bias tracks camera rotation,
// not just the translation that gates the throttled LOD re-evaluation below.
const frustumChanged = this._updateNodeFrustum();
let runLodEval = this._forceLodUpdate || frustumChanged || cooldownExpiredWithPendingSwitch;
if (!runLodEval && ++this._framesSinceLodUpdate >= this._lodUpdateInterval) {
const camera = this._scene.activeCamera;
const threshold = this._lodUpdateDistance;
if (!camera || Vector3.DistanceSquared(camera.globalPosition, this._lastLodCamPos) >= threshold * threshold) {
if (camera) {
this._lastLodCamPos.copyFrom(camera.globalPosition);
}
runLodEval = true;
}
}
if (runLodEval) {
this._forceLodUpdate = false;
this._framesSinceLodUpdate = 0;
this.evaluateOptimalLods(this._scene.activeCamera);
this._computeTargetLevels();
if (this._applyDesiredLods()) {
this._refreshActiveRanges();
}
}
}
/**
* Updates each leaf node's {@link ISOGLODNode.inFrustum} flag from a per-node frustum test against the
* active camera. When {@link frustumCulling} is disabled (or there is no camera) every node is marked
* in-frustum. Bounds are static (from the LOD tree), so flags are valid for all nodes regardless of
* decode state. Returns true when any node's in-frustum state changed (so the LOD bias must be re-applied).
* @returns whether any node's in-frustum state changed
*/
_updateNodeFrustum() {
const camera = this._scene.activeCamera;
let changed = false;
if (!this._frustumCulling || !camera) {
for (const node of this._leafNodes) {
if (node.inFrustum === false) {
node.inFrustum = true;
changed = true;
}
}
return changed;
}
// World-space frustum planes from the current view-projection, tested against each node's world AABB.
// force=false uses the renderId/sync fast-path (still recomputes when the transform actually changed),
// avoiding a full world-matrix recompute every frame for the per-node frustum test.
const world = this._getEffectiveWorldMatrix(false);
camera.getViewMatrix().multiplyToRef(camera.getProjectionMatrix(), this._cullViewProj);
Frustum.GetPlanesToRef(this._cullViewProj, this._frustumPlanes);
for (const node of this._leafNodes) {
node.cullBounds.update(world);
const inFrustum = node.cullBounds.isInFrustum(this._frustumPlanes);
if (inFrustum !== node.inFrustum) {
node.inFrustum = inFrustum;
changed = true;
}
}
return changed;
}
/**
* Reads the splat count from SOG metadata.
* @param data SOG metadata
* @returns the splat count
*/
static _GetSplatCount(data) {
return data.count ?? (Array.isArray(data.means.shape) ? data.means.shape[0] : 0);
}
/**
* Reads a SOG file's higher-order SH degree and coefficient count from its metadata, mirroring
* {@link ParseSogDatas}'s `coeffs`/`shDegree` derivation. Returns zeros when the file carries no `shN`.
* @param data parsed SOG root metadata
* @returns the SH degree and higher-order coefficient count (excludes the DC/SH0 term)
*/
static _GetShInfo(data) {
if (!data.shN) {
return { degree: 0, coeffs: 0 };
}
// Derive the SH degree from remote (untrusted) metadata, then validate/clamp it: the degree drives the SH
// render-target count and decode-pass count, so a bogus (huge / non-finite / negative) `bands` or `shape`
// must not be able to demand unbounded allocation. The draw path supports shTexture0..4, i.e. degree <= 4.
const maxDegree = 4;
let degree = 0;
const bands = data.shN.bands;
if (typeof bands === "number" && Number.isFinite(bands) && bands > 0) {
degree = Math.floor(bands);
}
else if (Array.isArray(data.shN.shape) && Number.isFinite(data.shN.shape[1]) && data.shN.shape[1] > 0) {
const shapeCoeffs = Math.floor(data.shN.shape[1] / 3);
degree = shapeCoeffs > 0 ? Math.round(Math.sqrt(shapeCoeffs + 1) - 1) : 0;
}
if (!(degree > 0)) {
return { degree: 0, coeffs: 0 };
}
if (degree > maxDegree) {
Logger.Warn(`GaussianSplattingStream: SH degree ${degree} exceeds the maximum supported (${maxDegree}); clamping.`);
degree = maxDegree;
}
return { degree, coeffs: (degree + 1) ** 2 - 1 };
}
/**
* Disposes all GPU source textures of a SOG pack (they are only needed for the one decode pass).
* @param pack the SOG texture pack
*/
static _DisposePack(pack) {
pack.meansTextureL.dispose();
pack.meansTextureU.dispose();
pack.scalesTexture.dispose();
pack.quatsTexture.dispose();
pack.sh0Texture.dispose();
pack.shCentroidsTexture?.dispose();
pack.shLabelsTexture?.dispose();
pack.codebookTexture?.dispose();
}
/**
* Expands the running splat-center bounds with a newly decoded file's centers and updates the
* mesh bounding info so the GS is correctly frustum-culled and pickable.
* @param positions stride-4 splat centers for the new file
* @param count number of splats
*/
_updateBounds(positions, count) {
const min = this._boundsMin;
const max = this._boundsMax;
for (let i = 0; i < count; i++) {
const x = positions[i * 4 + 0];
const y = positions[i * 4 + 1];
const z = positions[i * 4 + 2];
min.minimizeInPlaceFromFloats(x, y, z);
max.maximizeInPlaceFromFloats(x, y, z);
}
// Hosted: grow the reserved part's (and compound's) bounds. Standalone: set this mesh's bounds.
if (this._host) {
this._host.expandBounds(min, max);
}
else {
this.setBoundingInfo(new BoundingInfo(min, max));
}
}
/**
* Rebuilds the active interval set from the environment plus each node's currently-selected LOD entry,
* coalesces adjacent ranges, and pushes the result to the sort worker.
*/
_refreshActiveRanges() {
const ranges = [];
if (this._environmentRange) {
ranges.push({ offset: this._environmentRange.offset, count: this._environmentRange.count });
}
for (const node of this._leafNodes) {
if (node.activeLod === undefined) {
continue;
}
const entry = node.lods[String(node.activeLod)];
if (!entry) {
continue;
}
const base = this._residency?.offset(entry.file);
if (base === undefined) {
continue;
}
ranges.push({ offset: base + entry.offset, count: entry.count });
}
// Ranges are local to the stream's buffer; the sink (compound handle) offsets them by the region base.
this._sinkSetActiveRanges(GaussianSplattingStream._CoalesceRanges(ranges));
}
/**
* Sorts and merges adjacent/overlapping ranges to keep the interval list compact.
* @param ranges raw ranges
* @returns coalesced ranges
*/
static _CoalesceRanges(ranges) {
if (ranges.length <= 1) {
return ranges;
}
const sorted = ranges.slice().sort((a, b) => a.offset - b.offset);
const merged = [{ offset: sorted[0].offset, count: sorted[0].count }];
for (let i = 1; i < sorted.length; i++) {
const last = merged[merged.length - 1];
const range = sorted[i];
const lastEnd = last.offset + last.count;
if (range.offset <= lastEnd) {
const end = Math.max(lastEnd, range.offset + range.count);
last.count = end - last.offset;
}
else {
merged.push({ offset: range.offset, count: range.count });
}
}
return merged;
}
/**
* Unzips a `.sog` bundle into a name -> bytes map, loading fflate on demand.
* @param data zipped bytes
* @returns map of entry name to bytes
*/
async _unzipAsync(data) {
let fflateModule = this._streamOptions.fflate;
if (!fflateModule) {
if (typeof window.fflate === "undefined") {
await Tools.LoadScriptAsync(this._streamOptions.deflateURL ?? "https://unpkg.com/fflate/umd/index.js");
}
fflateModule = window.fflate;
}
const unzipped = fflateModule.unzipSync(data);
const files = new Map();
for (const [name, content] of Object.entries(unzipped)) {
files.set(name, content);
}
return files;
}
}
/**
* Represents one particle of a points cloud system.
*/
class CloudPoint {
/**
* Creates a Point Cloud object.
* Don't create particles manually, use instead the PCS internal tools like _addParticle()
* @param particleIndex (integer) is the particle index in the PCS pool. It's also the particle identifier.
* @param group (PointsGroup) is the group the particle belongs to
* @param groupId (integer) is the group identifier in the PCS.
* @param idxInGroup (integer) is the index of the particle in the current point group (ex: the 10th point of addPoints(30))
* @param pcs defines the PCS it is associated to
*/
constructor(particleIndex, group, groupId, idxInGroup, pcs) {
/**
* particle global index
*/
this.idx = 0;
/**
* The color of the particle
*/
this.color = new Color4(1.0, 1.0, 1.0, 1.0);
/**
* The world space position of the particle.
*/
this.position = Vector3.Zero();
/**
* The world space rotation of the particle. (Not use if rotationQuaternion is set)
*/
this.rotation = Vector3.Zero();
/**
* The uv of the particle.
*/
this.uv = new Vector2(0.0, 0.0);
/**
* The current speed of the particle.
*/
this.velocity = Vector3.Zero();
/**
* The pivot point in the particle local space.
*/
this.pivot = Vector3.Zero();
/**
* Must the particle be translated from its pivot point in its local space ?
* In this case, the pivot point is set at the origin of the particle local space and the particle is translated.
* Default : false
*/
this.translateFromPivot = false;
/**
* Index of this particle in the global "positions" array (Internal use)
* @internal
*/
this._pos = 0;
/**
* @internal Index of this particle in the global "indices" array (Internal use)
*/
this._ind = 0;
/**
* Group id of this particle
*/
this.groupId = 0;
/**
* Index of the particle in its group id (Internal use)
*/
this.idxInGroup = 0;
/**
* @internal Still set as invisible in order to skip useless computations (Internal use)
*/
this._stillInvisible = false;
/**
* @internal Last computed particle rotation matrix
*/
this._rotationMatrix = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
/**
* Parent particle Id, if any.
* Default null.
*/
this.parentId = null;
/**
* @internal Internal global position in the PCS.
*/
this._globalPosition = Vector3.Zero();
this.idx = particleIndex;
this._group = group;
this.groupId = groupId;
this.idxInGroup = idxInGroup;
this._pcs = pcs;
}
/**
* get point size
*/
get size() {
return this.size;
}
/**
* Set point size
*/
set size(scale) {
this.size = scale;
}
/**
* Legacy support, changed quaternion to rotationQuaternion
*/
get quaternion() {
return this.rotationQuaternion;
}
/**
* Legacy support, changed quaternion to rotationQuaternion
*/
set quaternion(q) {
this.rotationQuaternion = q;
}
/**
* Returns a boolean. True if the particle intersects a mesh, else false
* The intersection is computed on the particle position and Axis Aligned Bounding Box (AABB) or Sphere
* @param target is the object (point or mesh) what the intersection is computed against
* @param isSphere is boolean flag when false (default) bounding box of mesh is used, when true the bounding sphere is used
* @returns true if it intersects
*/
intersectsMesh(target, isSphere) {
if (!target.hasBoundingInfo) {
return false;
}
if (!this._pcs.mesh) {
throw new Error("Point Cloud System doesnt contain the Mesh");
}
if (isSphere) {
return target.getBoundingInfo().boundingSphere.intersectsPoint(this.position.add(this._pcs.mesh.position));
}
const bbox = target.getBoundingInfo().boundingBox;
const maxX = bbox.maximumWorld.x;
const minX = bbox.minimumWorld.x;
const maxY = bbox.maximumWorld.y;
const minY = bbox.minimumWorld.y;
const maxZ = bbox.maximumWorld.z;
const minZ = bbox.minimumWorld.z;
const x = this.position.x + this._pcs.mesh.position.x;
const y = this.position.y + this._pcs.mesh.position.y;
const z = this.position.z + this._pcs.mesh.position.z;
return minX <= x && x <= maxX && minY <= y && y <= maxY && minZ <= z && z <= maxZ;
}
/**
* get the rotation matrix of the particle
* @internal
*/
getRotationMatrix(m) {
let quaternion;
if (this.rotationQuaternion) {
quaternion = this.rotationQuaternion;
}
else {
quaternion = TmpVectors.Quaternion[0];
const rotation = this.rotation;
Quaternion.RotationYawPitchRollToRef(rotation.y, rotation.x, rotation.z, quaternion);
}
quaternion.toRotationMatrix(m);
}
}
/**
* Represents a group of points in a points cloud system
* * PCS internal tool, don't use it manually.
*/
class PointsGroup {
/**
* Get or set the groupId
* @deprecated Please use groupId instead
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
get groupID() {
return this.groupId;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
set groupID(groupID) {
this.groupId = groupID;
}
/**
* Creates a points group object. This is an internal reference to produce particles for the PCS.
* PCS internal tool, don't use it manually.
* @internal
*/
constructor(id, posFunction) {
this.groupId = id;
this._positionFunction = posFunction;
}
}
/** Defines the 4 color options */
var PointColor;
(function (PointColor) {
/** color value */
PointColor[PointColor["Color"] = 2] = "Color";
/** uv value */
PointColor[PointColor["UV"] = 1] = "UV";
/** random value */
PointColor[PointColor["Random"] = 0] = "Random";
/** stated value */
PointColor[PointColor["Stated"] = 3] = "Stated";
})(PointColor || (PointColor = {}));
/**
* The PointCloudSystem (PCS) is a single updatable mesh. The points corresponding to the vertices of this big mesh.
* As it is just a mesh, the PointCloudSystem has all the same properties as any other BJS mesh : not more, not less. It can be scaled, rotated, translated, enlighted, textured, moved, etc.
* The PointCloudSystem is also a particle system, with each point being a particle. It provides some methods to manage the particles.
* However it is behavior agnostic. This means it has no emitter, no particle physics, no particle recycler. You have to implement your own behavior.
*
* Full documentation here : TO BE ENTERED
*/
class PointsCloudSystem {
/**
* Gets the particle positions computed by the Point Cloud System
*/
get positions() {
return this._positions32;
}
/**
* Gets the particle colors computed by the Point Cloud System
*/
get colors() {
return this._colors32;
}
/**
* Gets the particle uvs computed by the Point Cloud System
*/
get uvs() {
return this._uvs32;
}
/**
* Creates a PCS (Points Cloud System) object
* @param name (String) is the PCS name, this will be the underlying mesh name
* @param pointSize (number) is the size for each point. Has no effect on a WebGPU engine.
* @param scene (Scene) is the scene in which the PCS is added
* @param options defines the options of the PCS e.g.
* * updatable (optional boolean, default true) : if the PCS must be updatable or immutable
*/
constructor(name, pointSize, scene, options) {
/**
* The PCS array of cloud point objects. Just access each particle as with any classic array.
* Example : var p = SPS.particles[i];
*/
this.particles = new Array();
/**
* The PCS total number of particles. Read only. Use PCS.counter instead if you need to set your own value.
*/
this.nbParticles = 0;
/**
* This a counter for your own usage. It's not set by any SPS functions.
*/
this.counter = 0;
/**
* This empty object is intended to store some PCS specific or temporary values in order to lower the Garbage Collector activity.
* Please read :
*/
this.vars = {};
this._promises = [];
this._positions = new Array();
this._indices = new Array();
this._normals = new Array();
this._colors = new Array();
this._uvs = new Array();
this._updatable = true;
this._isVisibilityBoxLocked = false;
this._alwaysVisible = false;
this._groups = new Array(); //start indices for each group of particles
this._groupCounter = 0;
this._computeParticleColor = true;
this._computeParticleTexture = true;
this._computeParticleRotation = true;
this._computeBoundingBox = false;
this._isReady = false;
this.name = name;
this._size = pointSize;
this._scene = scene || EngineStore.LastCreatedScene;
if (options && options.updatable !== undefined) {
this._updatable = options.updatable;
}
else {
this._updatable = true;
}
}
/**
* Builds the PCS underlying mesh. Returns a standard Mesh.
* If no points were added to the PCS, the returned mesh is just a single point.
* @param material The material to use to render the mesh. If not provided, will create a default one
* @returns a promise for the created mesh
*/
async buildMeshAsync(material) {
await Promise.all(this._promises);
this._isReady = true;
return await this._buildMeshAsync(material);
}
async _buildMeshAsync(material) {
if (this.nbParticles === 0) {
this.addPoints(1);
}
this._positions32 = new Float32Array(this._positions);
this._uvs32 = new Float32Array(this._uvs);
this._colors32 = new Float32Array(this._colors);
const vertexData = new VertexData();
vertexData.set(this._positions32, VertexBuffer.PositionKind);
if (this._uvs32.length > 0) {
vertexData.set(this._uvs32, VertexBuffer.UVKind);
}
let ec = 0; //emissive color value 0 for UVs, 1 for color
if (this._colors32.length > 0) {
ec = 1;
vertexData.set(this._colors32, VertexBuffer.ColorKind);
}
const mesh = new Mesh(this.name, this._scene);
vertexData.applyToMesh(mesh, this._updatable);
this.mesh = mesh;
// free memory
this._positions = null;
this._uvs = null;
this._colors = null;
if (!this._updatable) {
this.particles.length = 0;
}
let mat = material;
if (!mat) {
mat = new StandardMaterial("point cloud material", this._scene);
mat.emissiveColor = new Color3(ec, ec, ec);
mat.disableLighting = true;
mat.pointsCloud = true;
mat.pointSize = this._size;
}
mesh.material = mat;
return mesh;
}
// adds a new particle object in the particles array
_addParticle(idx, group, groupId, idxInGroup) {
const cp = new CloudPoint(idx, group, groupId, idxInGroup, this);
this.particles.push(cp);
return cp;
}
_randomUnitVector(particle) {
particle.position = new Vector3(Math.random(), Math.random(), Math.random());
particle.color = new Color4(1, 1, 1, 1);
}
_getColorIndicesForCoord(pointsGroup, x, y, width) {
const imageData = pointsGroup._groupImageData;
const color = y * (width * 4) + x * 4;
const colorIndices = [color, color + 1, color + 2, color + 3];
const redIndex = colorIndices[0];
const greenIndex = colorIndices[1];
const blueIndex = colorIndices[2];
const alphaIndex = colorIndices[3];
const redForCoord = imageData[redIndex];
const greenForCoord = imageData[greenIndex];
const blueForCoord = imageData[blueIndex];
const alphaForCoord = imageData[alphaIndex];
return new Color4(redForCoord / 255, greenForCoord / 255, blueForCoord / 255, alphaForCoord);
}
_setPointsColorOrUV(mesh, pointsGroup, isVolume, colorFromTexture, hasTexture, color, range, uvSetIndex) {
uvSetIndex = uvSetIndex ?? 0;
if (isVolume) {
mesh.updateFacetData();
}
const boundInfo = mesh.getBoundingInfo();
const diameter = 2 * boundInfo.boundingSphere.radius;
let meshPos = mesh.getVerticesData(VertexBuffer.PositionKind);
const meshInd = mesh.getIndices();
const meshUV = mesh.getVerticesData(VertexBuffer.UVKind + (uvSetIndex ? uvSetIndex + 1 : ""));
const meshCol = mesh.getVerticesData(VertexBuffer.ColorKind);
const place = Vector3.Zero();
mesh.computeWorldMatrix();
const meshMatrix = mesh.getWorldMatrix();
if (!meshMatrix.isIdentity()) {
meshPos = meshPos.slice(0);
for (let p = 0; p < meshPos.length / 3; p++) {
Vector3.TransformCoordinatesFromFloatsToRef(meshPos[3 * p], meshPos[3 * p + 1], meshPos[3 * p + 2], meshMatrix, place);
meshPos[3 * p] = place.x;
meshPos[3 * p + 1] = place.y;
meshPos[3 * p + 2] = place.z;
}
}
let idxPoints;
let id0;
let id1;
let id2;
let v0X;
let v0Y;
let v0Z;
let v1X;
let v1Y;
let v1Z;
let v2X;
let v2Y;
let v2Z;
const vertex0 = Vector3.Zero();
const vertex1 = Vector3.Zero();
const vertex2 = Vector3.Zero();
const vec0 = Vector3.Zero();
const vec1 = Vector3.Zero();
let uv0X;
let uv0Y;
let uv1X;
let uv1Y;
let uv2X;
let uv2Y;
const uv0 = Vector2.Zero();
const uv1 = Vector2.Zero();
const uv2 = Vector2.Zero();
const uvec0 = Vector2.Zero();
const uvec1 = Vector2.Zero();
let col0X;
let col0Y;
let col0Z;
let col0A;
let col1X;
let col1Y;
let col1Z;
let col1A;
let col2X;
let col2Y;
let col2Z;
let col2A;
const col0 = Vector4.Zero();
const col1 = Vector4.Zero();
const col2 = Vector4.Zero();
const colvec0 = Vector4.Zero();
const colvec1 = Vector4.Zero();
let lamda;
let mu;
range = range ? range : 0;
let facetPoint;
let uvPoint;
let colPoint = new Vector4(0, 0, 0, 1);
let norm;
let tang;
let biNorm;
let angle;
let facetPlaneVec;
let gap;
let distance;
const ray = new Ray(Vector3.Zero(), new Vector3(1, 0, 0));
let pickInfo;
let direction;
for (let index = 0; index < meshInd.length / 3; index++) {
id0 = meshInd[3 * index];
id1 = meshInd[3 * index + 1];
id2 = meshInd[3 * index + 2];
v0X = meshPos[3 * id0];
v0Y = meshPos[3 * id0 + 1];
v0Z = meshPos[3 * id0 + 2];
v1X = meshPos[3 * id1];
v1Y = meshPos[3 * id1 + 1];
v1Z = meshPos[3 * id1 + 2];
v2X = meshPos[3 * id2];
v2Y = meshPos[3 * id2 + 1];
v2Z = meshPos[3 * id2 + 2];
vertex0.set(v0X, v0Y, v0Z);
vertex1.set(v1X, v1Y, v1Z);
vertex2.set(v2X, v2Y, v2Z);
vertex1.subtractToRef(vertex0, vec0);
vertex2.subtractToRef(vertex1, vec1);
if (meshUV) {
uv0X = meshUV[2 * id0];
uv0Y = meshUV[2 * id0 + 1];
uv1X = meshUV[2 * id1];
uv1Y = meshUV[2 * id1 + 1];
uv2X = meshUV[2 * id2];
uv2Y = meshUV[2 * id2 + 1];
uv0.set(uv0X, uv0Y);
uv1.set(uv1X, uv1Y);
uv2.set(uv2X, uv2Y);
uv1.subtractToRef(uv0, uvec0);
uv2.subtractToRef(uv1, uvec1);
}
if (meshCol && colorFromTexture) {
col0X = meshCol[4 * id0];
col0Y = meshCol[4 * id0 + 1];
col0Z = meshCol[4 * id0 + 2];
col0A = meshCol[4 * id0 + 3];
col1X = meshCol[4 * id1];
col1Y = meshCol[4 * id1 + 1];
col1Z = meshCol[4 * id1 + 2];
col1A = meshCol[4 * id1 + 3];
col2X = meshCol[4 * id2];
col2Y = meshCol[4 * id2 + 1];
col2Z = meshCol[4 * id2 + 2];
col2A = meshCol[4 * id2 + 3];
col0.set(col0X, col0Y, col0Z, col0A);
col1.set(col1X, col1Y, col1Z, col1A);
col2.set(col2X, col2Y, col2Z, col2A);
col1.subtractToRef(col0, colvec0);
col2.subtractToRef(col1, colvec1);
}
let width;
let height;
let deltaS;
let deltaV;
let h;
let s;
let v;
let hsvCol;
const statedColor = new Color3(0, 0, 0);
const colPoint3 = new Color3(0, 0, 0);
let pointColors;
let particle;
for (let i = 0; i < pointsGroup._groupDensity[index]; i++) {
idxPoints = this.particles.length;
this._addParticle(idxPoints, pointsGroup, this._groupCounter, index + i);
particle = this.particles[idxPoints];
//form a point inside the facet v0, v1, v2;
lamda = Math.sqrt(RandomRange(0, 1));
mu = RandomRange(0, 1);
facetPoint = vertex0.add(vec0.scale(lamda)).add(vec1.scale(lamda * mu));
if (isVolume) {
norm = mesh.getFacetNormal(index).normalize().scale(-1);
tang = vec0.clone().normalize();
biNorm = Vector3.Cross(norm, tang);
angle = RandomRange(0, 2 * Math.PI);
facetPlaneVec = tang.scale(Math.cos(angle)).add(biNorm.scale(Math.sin(angle)));
angle = RandomRange(0.1, Math.PI / 2);
direction = facetPlaneVec.scale(Math.cos(angle)).add(norm.scale(Math.sin(angle)));
ray.origin = facetPoint.add(direction.scale(0.00001));
ray.direction = direction;
ray.length = diameter;
pickInfo = ray.intersectsMesh(mesh);
if (pickInfo.hit) {
distance = pickInfo.pickedPoint.subtract(facetPoint).length();
gap = RandomRange(0, 1) * distance;
facetPoint.addInPlace(direction.scale(gap));
}
}
particle.position = facetPoint.clone();
this._positions.push(particle.position.x, particle.position.y, particle.position.z);
if (colorFromTexture !== undefined) {
if (meshUV) {
uvPoint = uv0.add(uvec0.scale(lamda)).add(uvec1.scale(lamda * mu));
if (colorFromTexture) {
//Set particle color to texture color
if (hasTexture && pointsGroup._groupImageData !== null) {
width = pointsGroup._groupImgWidth;
height = pointsGroup._groupImgHeight;
pointColors = this._getColorIndicesForCoord(pointsGroup, Math.round(uvPoint.x * width), Math.round(uvPoint.y * height), width);
particle.color = pointColors;
this._colors.push(pointColors.r, pointColors.g, pointColors.b, pointColors.a);
}
else {
if (meshCol) {
//failure in texture and colors available
colPoint = col0.add(colvec0.scale(lamda)).add(colvec1.scale(lamda * mu));
particle.color = new Color4(colPoint.x, colPoint.y, colPoint.z, colPoint.w);
this._colors.push(colPoint.x, colPoint.y, colPoint.z, colPoint.w);
}
else {
colPoint = col0.set(Math.random(), Math.random(), Math.random(), 1);
particle.color = new Color4(colPoint.x, colPoint.y, colPoint.z, colPoint.w);
this._colors.push(colPoint.x, colPoint.y, colPoint.z, colPoint.w);
}
}
}
else {
//Set particle uv based on a mesh uv
particle.uv = uvPoint.clone();
this._uvs.push(particle.uv.x, particle.uv.y);
}
}
}
else {
if (color) {
statedColor.set(color.r, color.g, color.b);
deltaS = RandomRange(-range, range);
deltaV = RandomRange(-range, range);
hsvCol = statedColor.toHSV();
h = hsvCol.r;
s = hsvCol.g + deltaS;
v = hsvCol.b + deltaV;
if (s < 0) {
s = 0;
}
if (s > 1) {
s = 1;
}
if (v < 0) {
v = 0;
}
if (v > 1) {
v = 1;
}
Color3.HSVtoRGBToRef(h, s, v, colPoint3);
colPoint.set(colPoint3.r, colPoint3.g, colPoint3.b, 1);
}
else {
colPoint = col0.set(Math.random(), Math.random(), Math.random(), 1);
}
particle.color = new Color4(colPoint.x, colPoint.y, colPoint.z, colPoint.w);
this._colors.push(colPoint.x, colPoint.y, colPoint.z, colPoint.w);
}
}
}
}
// stores mesh texture in dynamic texture for color pixel retrieval
// when pointColor type is color for surface points
_colorFromTexture(mesh, pointsGroup, isVolume) {
if (mesh.material === null) {
Logger.Warn(mesh.name + "has no material.");
pointsGroup._groupImageData = null;
this._setPointsColorOrUV(mesh, pointsGroup, isVolume, true, false);
return;
}
const mat = mesh.material;
const textureList = mat.getActiveTextures();
if (textureList.length === 0) {
Logger.Warn(mesh.name + "has no usable texture.");
pointsGroup._groupImageData = null;
this._setPointsColorOrUV(mesh, pointsGroup, isVolume, true, false);
return;
}
const clone = mesh.clone();
clone.setEnabled(false);
this._promises.push(new Promise((resolve) => {
BaseTexture.WhenAllReady(textureList, () => {
let n = pointsGroup._textureNb;
if (n < 0) {
n = 0;
}
if (n > textureList.length - 1) {
n = textureList.length - 1;
}
const finalize = () => {
pointsGroup._groupImgWidth = textureList[n].getSize().width;
pointsGroup._groupImgHeight = textureList[n].getSize().height;
this._setPointsColorOrUV(clone, pointsGroup, isVolume, true, true, undefined, undefined, textureList[n].coordinatesIndex);
clone.dispose();
resolve();
};
pointsGroup._groupImageData = null;
const dataPromise = textureList[n].readPixels();
if (!dataPromise) {
finalize();
}
else {
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
dataPromise.then((data) => {
pointsGroup._groupImageData = data;
finalize();
});
}
});
}));
}
// calculates the point density per facet of a mesh for surface points
_calculateDensity(nbPoints, positions, indices) {
let id0;
let id1;
let id2;
let v0X;
let v0Y;
let v0Z;
let v1X;
let v1Y;
let v1Z;
let v2X;
let v2Y;
let v2Z;
const vertex0 = Vector3.Zero();
const vertex1 = Vector3.Zero();
const vertex2 = Vector3.Zero();
const vec0 = Vector3.Zero();
const vec1 = Vector3.Zero();
const normal = Vector3.Zero();
let area;
const cumulativeAreas = [];
let surfaceArea = 0;
const nbFacets = indices.length / 3;
//surface area
for (let index = 0; index < nbFacets; index++) {
id0 = indices[3 * index];
id1 = indices[3 * index + 1];
id2 = indices[3 * index + 2];
v0X = positions[3 * id0];
v0Y = positions[3 * id0 + 1];
v0Z = positions[3 * id0 + 2];
v1X = positions[3 * id1];
v1Y = positions[3 * id1 + 1];
v1Z = positions[3 * id1 + 2];
v2X = positions[3 * id2];
v2Y = positions[3 * id2 + 1];
v2Z = positions[3 * id2 + 2];
vertex0.set(v0X, v0Y, v0Z);
vertex1.set(v1X, v1Y, v1Z);
vertex2.set(v2X, v2Y, v2Z);
vertex1.subtractToRef(vertex0, vec0);
vertex2.subtractToRef(vertex1, vec1);
Vector3.CrossToRef(vec0, vec1, normal);
area = 0.5 * normal.length();
surfaceArea += area;
cumulativeAreas[index] = surfaceArea;
}
const density = new Array(nbFacets);
let remainingPoints = nbPoints;
for (let index = nbFacets - 1; index > 0; index--) {
const cumulativeArea = cumulativeAreas[index];
if (cumulativeArea === 0) {
// avoiding division by 0 upon degenerate triangles
density[index] = 0;
}
else {
const area = cumulativeArea - cumulativeAreas[index - 1];
const facetPointsWithFraction = (area / cumulativeArea) * remainingPoints;
const floored = Math.floor(facetPointsWithFraction);
const fraction = facetPointsWithFraction - floored;
const extraPoint = Number(Math.random() < fraction);
const facetPoints = floored + extraPoint;
density[index] = facetPoints;
remainingPoints -= facetPoints;
}
}
density[0] = remainingPoints;
return density;
}
/**
* Adds points to the PCS in random positions within a unit sphere
* @param nb (positive integer) the number of particles to be created from this model
* @param pointFunction is an optional javascript function to be called for each particle on PCS creation
* @returns the number of groups in the system
*/
addPoints(nb, pointFunction = this._randomUnitVector) {
const pointsGroup = new PointsGroup(this._groupCounter, pointFunction);
let cp;
// particles
let idx = this.nbParticles;
for (let i = 0; i < nb; i++) {
cp = this._addParticle(idx, pointsGroup, this._groupCounter, i);
if (pointsGroup && pointsGroup._positionFunction) {
pointsGroup._positionFunction(cp, idx, i);
}
this._positions.push(cp.position.x, cp.position.y, cp.position.z);
if (cp.color) {
this._colors.push(cp.color.r, cp.color.g, cp.color.b, cp.color.a);
}
if (cp.uv) {
this._uvs.push(cp.uv.x, cp.uv.y);
}
idx++;
}
this.nbParticles += nb;
this._groupCounter++;
return this._groupCounter;
}
/**
* Adds points to the PCS from the surface of the model shape
* @param mesh is any Mesh object that will be used as a surface model for the points
* @param nb (positive integer) the number of particles to be created from this model
* @param colorWith determines whether a point is colored using color (default), uv, random, stated or none (invisible)
* @param color (color4) to be used when colorWith is stated or color (number) when used to specify texture position
* @param range (number from 0 to 1) to determine the variation in shape and tone for a stated color
* @returns the number of groups in the system
*/
addSurfacePoints(mesh, nb, colorWith, color, range) {
let colored = colorWith ? colorWith : 0 /* PointColor.Random */;
if (isNaN(colored) || colored < 0 || colored > 3) {
colored = 0 /* PointColor.Random */;
}
const meshPos = mesh.getVerticesData(VertexBuffer.PositionKind);
const meshInd = mesh.getIndices();
this._groups.push(this._groupCounter);
const pointsGroup = new PointsGroup(this._groupCounter, null);
pointsGroup._groupDensity = this._calculateDensity(nb, meshPos, meshInd);
if (colored === 2 /* PointColor.Color */) {
pointsGroup._textureNb = color ? color : 0;
}
else {
color = color ? color : new Color4(1, 1, 1, 1);
}
switch (colored) {
case 2 /* PointColor.Color */:
this._colorFromTexture(mesh, pointsGroup, false);
break;
case 1 /* PointColor.UV */:
this._setPointsColorOrUV(mesh, pointsGroup, false, false, false);
break;
case 0 /* PointColor.Random */:
this._setPointsColorOrUV(mesh, pointsGroup, false);
break;
case 3 /* PointColor.Stated */:
this._setPointsColorOrUV(mesh, pointsGroup, false, undefined, undefined, color, range);
break;
}
this.nbParticles += nb;
this._groupCounter++;
return this._groupCounter - 1;
}
/**
* Adds points to the PCS inside the model shape
* @param mesh is any Mesh object that will be used as a surface model for the points
* @param nb (positive integer) the number of particles to be created from this model
* @param colorWith determines whether a point is colored using color (default), uv, random, stated or none (invisible)
* @param color (color4) to be used when colorWith is stated or color (number) when used to specify texture position
* @param range (number from 0 to 1) to determine the variation in shape and tone for a stated color
* @returns the number of groups in the system
*/
addVolumePoints(mesh, nb, colorWith, color, range) {
let colored = colorWith ? colorWith : 0 /* PointColor.Random */;
if (isNaN(colored) || colored < 0 || colored > 3) {
colored = 0 /* PointColor.Random */;
}
const meshPos = mesh.getVerticesData(VertexBuffer.PositionKind);
const meshInd = mesh.getIndices();
this._groups.push(this._groupCounter);
const pointsGroup = new PointsGroup(this._groupCounter, null);
pointsGroup._groupDensity = this._calculateDensity(nb, meshPos, meshInd);
if (colored === 2 /* PointColor.Color */) {
pointsGroup._textureNb = color ? color : 0;
}
else {
color = color ? color : new Color4(1, 1, 1, 1);
}
switch (colored) {
case 2 /* PointColor.Color */:
this._colorFromTexture(mesh, pointsGroup, true);
break;
case 1 /* PointColor.UV */:
this._setPointsColorOrUV(mesh, pointsGroup, true, false, false);
break;
case 0 /* PointColor.Random */:
this._setPointsColorOrUV(mesh, pointsGroup, true);
break;
case 3 /* PointColor.Stated */:
this._setPointsColorOrUV(mesh, pointsGroup, true, undefined, undefined, color, range);
break;
}
this.nbParticles += nb;
this._groupCounter++;
return this._groupCounter - 1;
}
/**
* Sets all the particles : this method actually really updates the mesh according to the particle positions, rotations, colors, textures, etc.
* This method calls `updateParticle()` for each particle of the SPS.
* For an animated SPS, it is usually called within the render loop.
* @param start The particle index in the particle array where to start to compute the particle property values _(default 0)_
* @param end The particle index in the particle array where to stop to compute the particle property values _(default nbParticle - 1)_
* @param update If the mesh must be finally updated on this call after all the particle computations _(default true)_
* @returns the PCS.
*/
setParticles(start = 0, end = this.nbParticles - 1, update = true) {
if (!this._updatable || !this._isReady) {
return this;
}
// custom beforeUpdate
this.beforeUpdateParticles(start, end, update);
const rotMatrix = TmpVectors.Matrix[0];
const mesh = this.mesh;
const colors32 = this._colors32;
const positions32 = this._positions32;
const uvs32 = this._uvs32;
const tempVectors = TmpVectors.Vector3;
const camAxisX = tempVectors[5].copyFromFloats(1.0, 0.0, 0.0);
const camAxisY = tempVectors[6].copyFromFloats(0.0, 1.0, 0.0);
const camAxisZ = tempVectors[7].copyFromFloats(0.0, 0.0, 1.0);
const minimum = tempVectors[8].setAll(Number.MAX_VALUE);
const maximum = tempVectors[9].setAll(-Number.MAX_VALUE);
Matrix.IdentityToRef(rotMatrix);
let idx; // current index of the particle
if (this.mesh?.isFacetDataEnabled) {
this._computeBoundingBox = true;
}
end = end >= this.nbParticles ? this.nbParticles - 1 : end;
if (this._computeBoundingBox) {
if (start != 0 || end != this.nbParticles - 1) {
// only some particles are updated, then use the current existing BBox basis. Note : it can only increase.
const boundingInfo = this.mesh?.getBoundingInfo();
if (boundingInfo) {
minimum.copyFrom(boundingInfo.minimum);
maximum.copyFrom(boundingInfo.maximum);
}
}
}
let pindex; //index in positions array
let cindex; //index in color array
let uindex; //index in uv array
// particle loop
for (let p = start; p <= end; p++) {
const particle = this.particles[p];
idx = particle.idx;
pindex = 3 * idx;
cindex = 4 * idx;
uindex = 2 * idx;
// call to custom user function to update the particle properties
this.updateParticle(particle);
const particleRotationMatrix = particle._rotationMatrix;
const particlePosition = particle.position;
const particleGlobalPosition = particle._globalPosition;
if (this._computeParticleRotation) {
particle.getRotationMatrix(rotMatrix);
}
const particleHasParent = particle.parentId !== null;
if (particleHasParent) {
const parent = this.particles[particle.parentId];
const parentRotationMatrix = parent._rotationMatrix;
const parentGlobalPosition = parent._globalPosition;
const rotatedY = particlePosition.x * parentRotationMatrix[1] + particlePosition.y * parentRotationMatrix[4] + particlePosition.z * parentRotationMatrix[7];
const rotatedX = particlePosition.x * parentRotationMatrix[0] + particlePosition.y * parentRotationMatrix[3] + particlePosition.z * parentRotationMatrix[6];
const rotatedZ = particlePosition.x * parentRotationMatrix[2] + particlePosition.y * parentRotationMatrix[5] + particlePosition.z * parentRotationMatrix[8];
particleGlobalPosition.x = parentGlobalPosition.x + rotatedX;
particleGlobalPosition.y = parentGlobalPosition.y + rotatedY;
particleGlobalPosition.z = parentGlobalPosition.z + rotatedZ;
if (this._computeParticleRotation) {
const rotMatrixValues = rotMatrix.m;
particleRotationMatrix[0] =
rotMatrixValues[0] * parentRotationMatrix[0] + rotMatrixValues[1] * parentRotationMatrix[3] + rotMatrixValues[2] * parentRotationMatrix[6];
particleRotationMatrix[1] =
rotMatrixValues[0] * parentRotationMatrix[1] + rotMatrixValues[1] * parentRotationMatrix[4] + rotMatrixValues[2] * parentRotationMatrix[7];
particleRotationMatrix[2] =
rotMatrixValues[0] * parentRotationMatrix[2] + rotMatrixValues[1] * parentRotationMatrix[5] + rotMatrixValues[2] * parentRotationMatrix[8];
particleRotationMatrix[3] =
rotMatrixValues[4] * parentRotationMatrix[0] + rotMatrixValues[5] * parentRotationMatrix[3] + rotMatrixValues[6] * parentRotationMatrix[6];
particleRotationMatrix[4] =
rotMatrixValues[4] * parentRotationMatrix[1] + rotMatrixValues[5] * parentRotationMatrix[4] + rotMatrixValues[6] * parentRotationMatrix[7];
particleRotationMatrix[5] =
rotMatrixValues[4] * parentRotationMatrix[2] + rotMatrixValues[5] * parentRotationMatrix[5] + rotMatrixValues[6] * parentRotationMatrix[8];
particleRotationMatrix[6] =
rotMatrixValues[8] * parentRotationMatrix[0] + rotMatrixValues[9] * parentRotationMatrix[3] + rotMatrixValues[10] * parentRotationMatrix[6];
particleRotationMatrix[7] =
rotMatrixValues[8] * parentRotationMatrix[1] + rotMatrixValues[9] * parentRotationMatrix[4] + rotMatrixValues[10] * parentRotationMatrix[7];
particleRotationMatrix[8] =
rotMatrixValues[8] * parentRotationMatrix[2] + rotMatrixValues[9] * parentRotationMatrix[5] + rotMatrixValues[10] * parentRotationMatrix[8];
}
}
else {
particleGlobalPosition.x = 0;
particleGlobalPosition.y = 0;
particleGlobalPosition.z = 0;
if (this._computeParticleRotation) {
const rotMatrixValues = rotMatrix.m;
particleRotationMatrix[0] = rotMatrixValues[0];
particleRotationMatrix[1] = rotMatrixValues[1];
particleRotationMatrix[2] = rotMatrixValues[2];
particleRotationMatrix[3] = rotMatrixValues[4];
particleRotationMatrix[4] = rotMatrixValues[5];
particleRotationMatrix[5] = rotMatrixValues[6];
particleRotationMatrix[6] = rotMatrixValues[8];
particleRotationMatrix[7] = rotMatrixValues[9];
particleRotationMatrix[8] = rotMatrixValues[10];
}
}
const pivotBackTranslation = tempVectors[11];
if (particle.translateFromPivot) {
pivotBackTranslation.setAll(0.0);
}
else {
pivotBackTranslation.copyFrom(particle.pivot);
}
// positions
const tmpVertex = tempVectors[0];
tmpVertex.copyFrom(particle.position);
const vertexX = tmpVertex.x - particle.pivot.x;
const vertexY = tmpVertex.y - particle.pivot.y;
const vertexZ = tmpVertex.z - particle.pivot.z;
let rotatedX = vertexX * particleRotationMatrix[0] + vertexY * particleRotationMatrix[3] + vertexZ * particleRotationMatrix[6];
let rotatedY = vertexX * particleRotationMatrix[1] + vertexY * particleRotationMatrix[4] + vertexZ * particleRotationMatrix[7];
let rotatedZ = vertexX * particleRotationMatrix[2] + vertexY * particleRotationMatrix[5] + vertexZ * particleRotationMatrix[8];
rotatedX += pivotBackTranslation.x;
rotatedY += pivotBackTranslation.y;
rotatedZ += pivotBackTranslation.z;
const px = (positions32[pindex] = particleGlobalPosition.x + camAxisX.x * rotatedX + camAxisY.x * rotatedY + camAxisZ.x * rotatedZ);
const py = (positions32[pindex + 1] = particleGlobalPosition.y + camAxisX.y * rotatedX + camAxisY.y * rotatedY + camAxisZ.y * rotatedZ);
const pz = (positions32[pindex + 2] = particleGlobalPosition.z + camAxisX.z * rotatedX + camAxisY.z * rotatedY + camAxisZ.z * rotatedZ);
if (this._computeBoundingBox) {
minimum.minimizeInPlaceFromFloats(px, py, pz);
maximum.maximizeInPlaceFromFloats(px, py, pz);
}
if (this._computeParticleColor && particle.color) {
const color = particle.color;
const colors32 = this._colors32;
colors32[cindex] = color.r;
colors32[cindex + 1] = color.g;
colors32[cindex + 2] = color.b;
colors32[cindex + 3] = color.a;
}
if (this._computeParticleTexture && particle.uv) {
const uv = particle.uv;
const uvs32 = this._uvs32;
uvs32[uindex] = uv.x;
uvs32[uindex + 1] = uv.y;
}
}
// if the VBO must be updated
if (mesh) {
if (update) {
if (this._computeParticleColor) {
mesh.updateVerticesData(VertexBuffer.ColorKind, colors32, false, false);
}
if (this._computeParticleTexture) {
mesh.updateVerticesData(VertexBuffer.UVKind, uvs32, false, false);
}
mesh.updateVerticesData(VertexBuffer.PositionKind, positions32, false, false);
}
if (this._computeBoundingBox) {
if (mesh.hasBoundingInfo) {
mesh.getBoundingInfo().reConstruct(minimum, maximum, mesh._worldMatrix);
}
else {
mesh.buildBoundingInfo(minimum, maximum, mesh._worldMatrix);
}
}
}
this.afterUpdateParticles(start, end, update);
return this;
}
/**
* Disposes the PCS.
*/
dispose() {
this.mesh?.dispose();
this.vars = null;
// drop references to internal big arrays for the GC
this._positions = null;
this._indices = null;
this._normals = null;
this._uvs = null;
this._colors = null;
this._indices32 = null;
this._positions32 = null;
this._uvs32 = null;
this._colors32 = null;
}
/**
* Visibility helper : Recomputes the visible size according to the mesh bounding box
* doc :
* @returns the PCS.
*/
refreshVisibleSize() {
if (!this._isVisibilityBoxLocked) {
this.mesh?.refreshBoundingInfo();
}
return this;
}
/**
* Visibility helper : Sets the size of a visibility box, this sets the underlying mesh bounding box.
* @param size the size (float) of the visibility box
* note : this doesn't lock the PCS mesh bounding box.
* doc :
*/
setVisibilityBox(size) {
if (!this.mesh) {
return;
}
const vis = size / 2;
this.mesh.buildBoundingInfo(new Vector3(-vis, -vis, -vis), new Vector3(vis, vis, vis));
}
/**
* Gets whether the PCS is always visible or not
* doc :
*/
get isAlwaysVisible() {
return this._alwaysVisible;
}
/**
* Sets the PCS as always visible or not
* doc :
*/
set isAlwaysVisible(val) {
if (!this.mesh) {
return;
}
this._alwaysVisible = val;
this.mesh.alwaysSelectAsActiveMesh = val;
}
/**
* Tells to `setParticles()` to compute the particle rotations or not
* Default value : false. The PCS is faster when it's set to false
* Note : particle rotations are only applied to parent particles
* Note : the particle rotations aren't stored values, so setting `computeParticleRotation` to false will prevents the particle to rotate
*/
set computeParticleRotation(val) {
this._computeParticleRotation = val;
}
/**
* Tells to `setParticles()` to compute the particle colors or not.
* Default value : true. The PCS is faster when it's set to false.
* Note : the particle colors are stored values, so setting `computeParticleColor` to false will keep yet the last colors set.
*/
set computeParticleColor(val) {
this._computeParticleColor = val;
}
set computeParticleTexture(val) {
this._computeParticleTexture = val;
}
/**
* Gets if `setParticles()` computes the particle colors or not.
* Default value : false. The PCS is faster when it's set to false.
* Note : the particle colors are stored values, so setting `computeParticleColor` to false will keep yet the last colors set.
*/
get computeParticleColor() {
return this._computeParticleColor;
}
/**
* Gets if `setParticles()` computes the particle textures or not.
* Default value : false. The PCS is faster when it's set to false.
* Note : the particle textures are stored values, so setting `computeParticleTexture` to false will keep yet the last colors set.
*/
get computeParticleTexture() {
return this._computeParticleTexture;
}
/**
* Tells to `setParticles()` to compute or not the mesh bounding box when computing the particle positions.
*/
set computeBoundingBox(val) {
this._computeBoundingBox = val;
}
/**
* Gets if `setParticles()` computes or not the mesh bounding box when computing the particle positions.
*/
get computeBoundingBox() {
return this._computeBoundingBox;
}
// =======================================================================
// Particle behavior logic
// these following methods may be overwritten by users to fit their needs
/**
* This function does nothing. It may be overwritten to set all the particle first values.
* The PCS doesn't call this function, you may have to call it by your own.
* doc :
*/
initParticles() { }
/**
* This function does nothing. It may be overwritten to recycle a particle
* The PCS doesn't call this function, you can to call it
* doc :
* @param particle The particle to recycle
* @returns the recycled particle
*/
recycleParticle(particle) {
return particle;
}
/**
* Updates a particle : this function should be overwritten by the user.
* It is called on each particle by `setParticles()`. This is the place to code each particle behavior.
* doc :
* @example : just set a particle position or velocity and recycle conditions
* @param particle The particle to update
* @returns the updated particle
*/
updateParticle(particle) {
return particle;
}
/**
* This will be called before any other treatment by `setParticles()` and will be passed three parameters.
* This does nothing and may be overwritten by the user.
* @param start the particle index in the particle array where to start to iterate, same than the value passed to setParticle()
* @param stop the particle index in the particle array where to stop to iterate, same than the value passed to setParticle()
* @param update the boolean update value actually passed to setParticles()
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
beforeUpdateParticles(start, stop, update) { }
/**
* This will be called by `setParticles()` after all the other treatments and just before the actual mesh update.
* This will be passed three parameters.
* This does nothing and may be overwritten by the user.
* @param start the particle index in the particle array where to start to iterate, same than the value passed to setParticle()
* @param stop the particle index in the particle array where to stop to iterate, same than the value passed to setParticle()
* @param update the boolean update value actually passed to setParticles()
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
afterUpdateParticles(start, stop, update) { }
}
/* eslint-disable @typescript-eslint/naming-convention */
/**
* This file is only for internal use only and should not be used in your code
*/
let UniqueResolveID = 0;
/**
* Load an asynchronous script (identified by an url) in a module way. When the url returns, the
* content of this file is added into a new script element, attached to the DOM (body element)
* @param scriptUrl defines the url of the script to load
* @param scriptId defines the id of the script element
* @returns a promise request object
* It is up to the caller to provide a script that will do the import and prepare a "returnedValue" variable
* @internal DO NOT USE outside of Babylon.js core
*/
async function _LoadScriptModuleAsync(scriptUrl, scriptId) {
return await new Promise((resolve, reject) => {
// Need a relay
let windowAsAny;
let windowString;
if (IsWindowObjectExist()) {
windowAsAny = window;
windowString = "window";
}
else if (typeof self !== "undefined") {
windowAsAny = self;
windowString = "self";
}
else {
reject(new Error("Cannot load script module outside of a window or a worker"));
return;
}
if (!windowAsAny._LoadScriptModuleResolve) {
windowAsAny._LoadScriptModuleResolve = {};
}
windowAsAny._LoadScriptModuleResolve[UniqueResolveID] = resolve;
scriptUrl += `
${windowString}._LoadScriptModuleResolve[${UniqueResolveID}](returnedValue);
${windowString}._LoadScriptModuleResolve[${UniqueResolveID}] = undefined;
`;
UniqueResolveID++;
Tools.LoadScript(scriptUrl, undefined, (message, exception) => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
reject(exception || new Error(message));
}, scriptId, true);
});
}
/* eslint-disable @typescript-eslint/promise-function-async */
const _SpzConversionBatchSize = 32768;
const _SH_C0 = 0.28209479177387814;
// Cached WASM module promise — initialized once, reused across all SPZ loads.
let _SpzModulePromise = null;
let _SpzModuleUrl = null;
/**
* Parses SPZ data and returns a promise resolving to an IParsedSplat object.
* @param data The ArrayBuffer containing SPZ data.
* @param scene The Babylon.js scene.
* @param _loadingOptions Options for loading Gaussian Splatting files.
* @returns A promise resolving to the parsed SPZ data.
*/
function ParseSpz(data, scene, _loadingOptions) {
const ubuf = new Uint8Array(data);
const ubufu32 = new Uint32Array(data.slice(0, 12)); // Only need ubufu32[0] to [2]
// debug infos
const splatCount = ubufu32[2];
const shDegree = ubuf[12];
const fractionalBits = ubuf[13];
const flags = ubuf[14];
const reserved = ubuf[15];
const version = ubufu32[1];
// check magic and version
if (reserved || ubufu32[0] != 0x5053474e || version < 2 || version > 4) {
// reserved must be 0
return new Promise((resolve) => {
resolve({ mode: 3 /* Mode.Reject */, data: new ArrayBuffer(0), hasVertexColors: false });
});
}
const rowOutputLength = 3 * 4 + 3 * 4 + 4 + 4; // 32
const buffer = new ArrayBuffer(rowOutputLength * splatCount);
const positionScale = 1.0 / (1 << fractionalBits);
const int32View = new Int32Array(1);
const uint8View = new Uint8Array(int32View.buffer);
const read24bComponent = function (u8, offset) {
uint8View[0] = u8[offset + 0];
uint8View[1] = u8[offset + 1];
uint8View[2] = u8[offset + 2];
uint8View[3] = u8[offset + 2] & 0x80 ? 0xff : 0x00;
return int32View[0] * positionScale;
};
let byteOffset = 16;
const position = new Float32Array(buffer);
const scale = new Float32Array(buffer);
const rgba = new Uint8ClampedArray(buffer);
const rot = new Uint8ClampedArray(buffer);
// positions
for (let i = 0; i < splatCount; i++) {
position[i * 8 + 0] = read24bComponent(ubuf, byteOffset + 0);
position[i * 8 + 1] = read24bComponent(ubuf, byteOffset + 3);
position[i * 8 + 2] = read24bComponent(ubuf, byteOffset + 6);
byteOffset += 9;
}
// colors
for (let i = 0; i < splatCount; i++) {
for (let component = 0; component < 3; component++) {
const byteValue = ubuf[byteOffset + splatCount + i * 3 + component];
// 0.15 is hard coded value from spz
// Scale factor for DC color components. To convert to RGB, we should multiply by 0.282, but it can
// be useful to represent base colors that are out of range if the higher spherical harmonics bands
// bring them back into range so we multiply by a smaller value.
const value = (byteValue - 127.5) / (0.15 * 255);
rgba[i * 32 + 24 + component] = Scalar.Clamp((0.5 + _SH_C0 * value) * 255, 0, 255);
}
rgba[i * 32 + 24 + 3] = ubuf[byteOffset + i];
}
byteOffset += splatCount * 4;
// scales
for (let i = 0; i < splatCount; i++) {
scale[i * 8 + 3 + 0] = Math.exp(ubuf[byteOffset + 0] / 16.0 - 10.0);
scale[i * 8 + 3 + 1] = Math.exp(ubuf[byteOffset + 1] / 16.0 - 10.0);
scale[i * 8 + 3 + 2] = Math.exp(ubuf[byteOffset + 2] / 16.0 - 10.0);
byteOffset += 3;
}
// convert quaternion
if (version >= 3) {
/*
In version 3, rotations are represented as the smallest three components of the normalized rotation quaternion, for optimal rotation accuracy.
The largest component can be derived from the others and is not stored. Its index is stored on 2 bits
and each of the smallest three components is encoded as a 10-bit signed integer.
*/
const sqrt12 = Math.SQRT1_2;
for (let i = 0; i < splatCount; i++) {
const r = [ubuf[byteOffset + 0], ubuf[byteOffset + 1], ubuf[byteOffset + 2], ubuf[byteOffset + 3]];
const comp = r[0] + (r[1] << 8) + (r[2] << 16) + (r[3] << 24);
const cmask = (1 << 9) - 1;
const rotation = [];
const iLargest = comp >>> 30;
let remaining = comp;
let sumSquares = 0;
for (let i = 3; i >= 0; --i) {
if (i !== iLargest) {
const mag = remaining & cmask;
const negbit = (remaining >>> 9) & 0x1;
remaining = remaining >>> 10;
rotation[i] = sqrt12 * (mag / cmask);
if (negbit === 1) {
rotation[i] = -rotation[i];
}
// accumulate the sum of squares
sumSquares += rotation[i] * rotation[i];
}
}
const square = 1 - sumSquares;
rotation[iLargest] = Math.sqrt(Math.max(square, 0));
const shuffle = [3, 0, 1, 2]; // shuffle to match the order of the quaternion components in the splat file
for (let j = 0; j < 4; j++) {
rot[i * 32 + 28 + j] = Math.round(127.5 + rotation[shuffle[j]] * 127.5);
}
byteOffset += 4;
}
}
else {
/*
In version 2, rotations are represented as the `(x, y, z)` components of the normalized rotation quaternion. The
`w` component can be derived from the others and is not stored. Each component is encoded as an
8-bit signed integer.
*/
for (let i = 0; i < splatCount; i++) {
const x = ubuf[byteOffset + 0];
const y = ubuf[byteOffset + 1];
const z = ubuf[byteOffset + 2];
const nx = x / 127.5 - 1;
const ny = y / 127.5 - 1;
const nz = z / 127.5 - 1;
rot[i * 32 + 28 + 1] = x;
rot[i * 32 + 28 + 2] = y;
rot[i * 32 + 28 + 3] = z;
const v = 1 - (nx * nx + ny * ny + nz * nz);
rot[i * 32 + 28 + 0] = 127.5 + Math.sqrt(v < 0 ? 0 : v) * 127.5;
byteOffset += 3;
}
}
// SH
if (shDegree) {
// shVectorCount is : 3 for degree 1, 8 for degree 2, 15 for degree 3, 24 for degree 4
// number of vec3 vectors needed per splat
const shVectorCount = (shDegree + 1) * (shDegree + 1) - 1; // minus 1 because sh0 is color
// number of scalar component values: 3 per vec3
const shComponentCount = shVectorCount * 3;
const textureCount = Math.ceil(shComponentCount / 16); // 4 components can be stored per texture, 4 sh per component
let shIndexRead = byteOffset;
const engine = scene.getEngine();
const width = engine.getCaps().maxTextureSize;
const height = Math.ceil(splatCount / width);
// sh is an array of uint8array that will be used to create sh textures
const sh = AllocateShBuffers(textureCount, height * width * 4 * 4);
for (let i = 0; i < splatCount; i++) {
for (let shIndexWrite = 0; shIndexWrite < shComponentCount; shIndexWrite++) {
const shValue = ubuf[shIndexRead++];
const textureIndex = Math.floor(shIndexWrite / 16);
const shArray = sh[textureIndex];
const byteIndexInTexture = shIndexWrite % 16; // [0..15]
const offsetPerSplat = i * 16; // 16 sh values per texture per splat.
shArray[byteIndexInTexture + offsetPerSplat] = shValue;
}
}
return new Promise((resolve) => {
resolve({ mode: 0 /* Mode.Splat */, data: buffer, hasVertexColors: false, sh: sh, shDegree: shDegree, trainedWithAntialiasing: !!flags });
});
}
return new Promise((resolve) => {
resolve({ mode: 0 /* Mode.Splat */, data: buffer, hasVertexColors: false, trainedWithAntialiasing: !!flags });
});
}
/**
* Returns the initialized spz WASM module loaded from the given URL, loading it on first call.
* @param url URL to the spz WASM ES module (its default export should be a factory function)
* @returns A promise resolving to the initialized spz WASM module
*/
async function GetSpzModule(url) {
if (_SpzModulePromise && _SpzModuleUrl === url) {
return await _SpzModulePromise;
}
const spzModulePromise = _LoadScriptModuleAsync(`import createSpzModule from '${url}';
const module = await createSpzModule();
const returnedValue = module;`);
_SpzModuleUrl = url;
_SpzModulePromise = spzModulePromise;
return await spzModulePromise;
}
/**
* Converts a GaussianCloud object (from the spz WASM module) into the packed 32-byte-per-splat
* ArrayBuffer and SH texture arrays expected by GaussianSplattingMeshBase.updateData.
*
* Packed layout per splat (32 bytes):
* [0-11] position xyz (float32 x3)
* [12-23] scale xyz (float32 x3)
* [24-27] color RGBA (uint8 x4, colors in [0,255], alpha in [0,255])
* [28-31] quaternion wxyz (uint8 x4, encoded as q * 127.5 + 127.5)
*
* SH coefficients from the cloud (Float32, range ~[-1,1]) are encoded to bytes
* using the SPZ convention (load-spz.cc unquantizeSH): byte = coeff * 128 + 128.
*
* @param cloud The GaussianCloud returned by spz.loadSpzFromBuffer
* @param scene The Babylon.js scene (used to query maxTextureSize for SH textures)
* @param useCoroutine If true, yields periodically to avoid blocking the main thread
* @returns A coroutine returning an IParsedSplat ready to be passed to updateData
*/
function* ConvertSpzToSplat(cloud, scene, useCoroutine = false) {
const splatCount = cloud.numPoints;
const rowOutputLength = 3 * 4 + 3 * 4 + 4 + 4; // 32 bytes
const buffer = new ArrayBuffer(rowOutputLength * splatCount);
const fBuffer = new Float32Array(buffer);
const uBuffer = new Uint8Array(buffer);
const positions = cloud.positions;
const scales = cloud.scales;
const colors = cloud.colors;
const alphas = cloud.alphas;
const rotations = cloud.rotations;
// Build SH texture arrays upfront so both main and SH data can be written in a single pass
let sh = null;
const shDegree = cloud.shDegree;
let cloudSh = null;
let shComponentCount = 0;
let chunkStarts = null;
let chunkEnds = null;
let shArrays = null;
if (shDegree > 0 && cloud.sh.length > 0) {
const shVectorCount = (shDegree + 1) * (shDegree + 1) - 1;
shComponentCount = shVectorCount * 3;
const textureCount = Math.ceil(shComponentCount / 16);
const engine = scene.getEngine();
const width = engine.getCaps().maxTextureSize;
const height = Math.ceil(splatCount / width);
sh = AllocateShBuffers(textureCount, height * width * 4 * 4);
// Precompute chunk start/end and hoist texture references out of the per-splat loop
chunkStarts = new Int32Array(textureCount);
chunkEnds = new Int32Array(textureCount);
for (let t = 0; t < textureCount; t++) {
chunkStarts[t] = t * 16;
chunkEnds[t] = Math.min((t + 1) * 16, shComponentCount);
}
shArrays = sh;
cloudSh = cloud.sh;
}
// Single pass: write packed splat data and SH textures together to halve iteration count
for (let i = 0; i < splatCount; i++) {
const fBase = i * 8;
const uBase = i * 32;
const p = i * 3;
const r = i * 4;
// Position (float32 x3, bytes 0-11)
fBuffer[fBase + 0] = positions[p + 0];
fBuffer[fBase + 1] = positions[p + 1];
fBuffer[fBase + 2] = positions[p + 2];
// Scale (float32 x3, bytes 12-23) — cloud scales are in log space, convert to linear
fBuffer[fBase + 3] = Math.exp(scales[p + 0]);
fBuffer[fBase + 4] = Math.exp(scales[p + 1]);
fBuffer[fBase + 5] = Math.exp(scales[p + 2]);
// Color RGB: cloud gives raw SH DC coefficients, convert to [0,255] display value
const c0 = (0.5 + _SH_C0 * colors[p + 0]) * 255;
const c1 = (0.5 + _SH_C0 * colors[p + 1]) * 255;
const c2 = (0.5 + _SH_C0 * colors[p + 2]) * 255;
uBuffer[uBase + 24] = c0 <= 0 ? 0 : c0 >= 255 ? 255 : (c0 + 0.5) | 0;
uBuffer[uBase + 25] = c1 <= 0 ? 0 : c1 >= 255 ? 255 : (c1 + 0.5) | 0;
uBuffer[uBase + 26] = c2 <= 0 ? 0 : c2 >= 255 ? 255 : (c2 + 0.5) | 0;
// Alpha: cloud gives raw logit opacity, apply sigmoid to get [0,255]
uBuffer[uBase + 27] = ((1.0 / (1.0 + Math.exp(-alphas[i]))) * 255 + 0.5) | 0;
// Rotation: cloud is xyzw, packed buffer expects wxyz
const rw = rotations[r + 3] * 127.5 + 127.5;
const rx = rotations[r + 0] * 127.5 + 127.5;
const ry = rotations[r + 1] * 127.5 + 127.5;
const rz = rotations[r + 2] * 127.5 + 127.5;
uBuffer[uBase + 28] = rw <= 0 ? 0 : rw >= 255 ? 255 : (rw + 0.5) | 0; // w
uBuffer[uBase + 29] = rx <= 0 ? 0 : rx >= 255 ? 255 : (rx + 0.5) | 0; // x
uBuffer[uBase + 30] = ry <= 0 ? 0 : ry >= 255 ? 255 : (ry + 0.5) | 0; // y
uBuffer[uBase + 31] = rz <= 0 ? 0 : rz >= 255 ? 255 : (rz + 0.5) | 0; // z
// SH: process all texture chunks for this splat in the same iteration
if (cloudSh && shArrays && chunkStarts && chunkEnds) {
const shSplatBase = i * shComponentCount;
const offsetPerSplat = i * 16;
for (let t = 0; t < shArrays.length; t++) {
const shT = shArrays[t];
const chunkStart = chunkStarts[t];
const chunkEnd = chunkEnds[t];
for (let j = chunkStart; j < chunkEnd; j++) {
const v = cloudSh[shSplatBase + j] * 128.0 + 128.0;
shT[offsetPerSplat + j - chunkStart] = v <= 0 ? 0 : v >= 255 ? 255 : (v + 0.5) | 0;
}
}
}
if (i % _SpzConversionBatchSize === 0 && useCoroutine) {
yield;
}
}
// Extract safe-orbit-camera extension if present
let safeOrbitCameraRadiusMin;
let safeOrbitCameraElevationMinMax;
if (cloud.extensions) {
for (const ext of cloud.extensions) {
const safeOrbitExt = ext;
if (safeOrbitExt.safeOrbitRadiusMin !== undefined) {
safeOrbitCameraRadiusMin = safeOrbitExt.safeOrbitRadiusMin;
safeOrbitCameraElevationMinMax = [safeOrbitExt.safeOrbitElevationMin, safeOrbitExt.safeOrbitElevationMax];
break;
}
}
}
return {
mode: 0 /* Mode.Splat */,
data: buffer,
hasVertexColors: false,
sh: sh !== null ? sh : undefined,
shDegree: shDegree > 0 ? shDegree : undefined,
trainedWithAntialiasing: !!cloud.antialiased,
safeOrbitCameraRadiusMin,
safeOrbitCameraElevationMinMax,
};
}
/**
* Async version of ConvertSpzToSplat that yields periodically to avoid blocking the main thread.
* @param cloud The GaussianCloud returned by spz.loadSpzFromBuffer
* @param scene The Babylon.js scene
* @returns A promise resolving to an IParsedSplat
*/
async function ConvertSpzToSplatAsync(cloud, scene) {
return await runCoroutineAsync(ConvertSpzToSplat(cloud, scene, true), createYieldingScheduler());
}
/* eslint-disable @typescript-eslint/promise-function-async*/
/* eslint-disable @typescript-eslint/naming-convention */
/**
* @experimental
* SPLAT file type loader.
* This is a babylon scene loader plugin.
*/
class SPLATFileLoader {
/**
* Creates loader for gaussian splatting files
* @param loadingOptions options for loading and parsing splat and PLY files.
*/
constructor(loadingOptions = {}) {
/**
* Defines the name of the plugin.
*/
this.name = SPLATFileLoaderMetadata.name;
this._assetContainer = null;
/**
* Defines the extensions the splat loader is able to load.
* force data to come in as an ArrayBuffer
*/
this.extensions = SPLATFileLoaderMetadata.extensions;
this._loadingOptions = { ...SPLATFileLoader._DefaultLoadingOptions, ...loadingOptions };
}
/** @internal */
createPlugin(options) {
return new SPLATFileLoader(options[SPLATFileLoaderMetadata.name]);
}
/**
* Imports from the loaded gaussian splatting data and adds them to the scene
* @param meshesNames a string or array of strings of the mesh names that should be loaded from the file
* @param scene the scene the meshes should be added to
* @param data the gaussian splatting data to load
* @param rootUrl root url to load from
* @param _onProgress callback called while file is loading
* @param _fileName Defines the name of the file to load
* @returns a promise containing the loaded meshes, particles, skeletons and animations
*/
async importMeshAsync(meshesNames, scene, data, rootUrl, _onProgress, _fileName) {
const lodStream = this._tryCreateLODStream(scene, data, rootUrl);
if (lodStream) {
return {
meshes: [lodStream],
particleSystems: [],
skeletons: [],
animationGroups: [],
transformNodes: [],
geometries: [],
lights: [],
spriteManagers: [],
};
}
// eslint-disable-next-line github/no-then
return await this._parseAsync(meshesNames, scene, data, rootUrl).then((meshes) => {
return {
meshes: meshes,
particleSystems: [],
skeletons: [],
animationGroups: [],
transformNodes: [],
geometries: [],
lights: [],
spriteManagers: [],
};
});
}
/**
* Detects a PlayCanvas-style `lod-meta.json` payload and, if found, creates a streaming mesh for it.
* @param scene hosting scene
* @param data loaded file data
* @param rootUrl root url the metadata's relative paths resolve against
* @returns the streaming mesh, or null when the data is not SOG LOD metadata
*/
_tryCreateLODStream(scene, data, rootUrl) {
if (typeof data !== "string") {
return null;
}
let parsed;
try {
parsed = JSON.parse(data);
}
catch {
return null;
}
if (!GaussianSplattingStream.IsLODMetadata(parsed)) {
return null;
}
const previousBlockEntityCollection = scene._blockEntityCollection;
scene._blockEntityCollection = !!this._assetContainer;
try {
const stream = new GaussianSplattingStream("GaussianSplattingStream", parsed, rootUrl, scene, {
deflateURL: this._loadingOptions.deflateURL,
fflate: this._loadingOptions.fflate,
});
stream._parentContainer = this._assetContainer;
return stream;
}
finally {
scene._blockEntityCollection = previousBlockEntityCollection;
}
}
static _BuildPointCloud(pointcloud, data) {
if (!data.byteLength) {
return false;
}
const uBuffer = new Uint8Array(data);
const fBuffer = new Float32Array(data);
// parsed array contains room for position(3floats), normal(3floats), color (4b), quantized quaternion (4b)
const rowLength = 3 * 4 + 3 * 4 + 4 + 4;
const vertexCount = uBuffer.length / rowLength;
const pointcloudfunc = function (particle, i) {
const x = fBuffer[8 * i + 0];
const y = fBuffer[8 * i + 1];
const z = fBuffer[8 * i + 2];
particle.position = new Vector3(x, y, z);
const r = uBuffer[rowLength * i + 24 + 0] / 255;
const g = uBuffer[rowLength * i + 24 + 1] / 255;
const b = uBuffer[rowLength * i + 24 + 2] / 255;
particle.color = new Color4(r, g, b, 1);
};
pointcloud.addPoints(vertexCount, pointcloudfunc);
return true;
}
static _BuildMesh(scene, parsedPLY) {
const mesh = new Mesh("PLYMesh", scene);
const uBuffer = new Uint8Array(parsedPLY.data);
const fBuffer = new Float32Array(parsedPLY.data);
const rowLength = 3 * 4 + 3 * 4 + 4 + 4;
const vertexCount = uBuffer.length / rowLength;
const positions = [];
const vertexData = new VertexData();
for (let i = 0; i < vertexCount; i++) {
const x = fBuffer[8 * i + 0];
const y = fBuffer[8 * i + 1];
const z = fBuffer[8 * i + 2];
positions.push(x, y, z);
}
if (parsedPLY.hasVertexColors) {
const colors = new Float32Array(vertexCount * 4);
for (let i = 0; i < vertexCount; i++) {
const r = uBuffer[rowLength * i + 24 + 0] / 255;
const g = uBuffer[rowLength * i + 24 + 1] / 255;
const b = uBuffer[rowLength * i + 24 + 2] / 255;
colors[i * 4 + 0] = r;
colors[i * 4 + 1] = g;
colors[i * 4 + 2] = b;
colors[i * 4 + 3] = 1;
}
vertexData.colors = colors;
}
vertexData.positions = positions;
vertexData.indices = parsedPLY.faces;
vertexData.applyToMesh(mesh);
return mesh;
}
// eslint-disable-next-line @typescript-eslint/promise-function-async, no-restricted-syntax, @typescript-eslint/naming-convention
async _unzipWithFFlateAsync(data) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
let fflate = this._loadingOptions.fflate;
// ensure fflate is loaded
if (!fflate) {
if (typeof window.fflate === "undefined") {
await Tools.LoadScriptAsync(this._loadingOptions.deflateURL ?? "https://unpkg.com/fflate/umd/index.js");
}
fflate = window.fflate;
}
const { unzipSync } = fflate;
const unzipped = unzipSync(data); // { [filename: string]: Uint8Array }
const files = new Map();
for (const [name, content] of Object.entries(unzipped)) {
files.set(name, content);
}
return files;
}
// eslint-disable-next-line @typescript-eslint/promise-function-async, no-restricted-syntax
_parseAsync(meshesNames, scene, data, rootUrl) {
const babylonMeshesArray = []; //The mesh for babylon
const makeGSFromParsedSOG = (parsedSOG) => {
scene._blockEntityCollection = !!this._assetContainer;
const gaussianSplatting = this._loadingOptions.gaussianSplattingMesh ??
new GaussianSplattingMesh("GaussianSplatting", null, scene, this._loadingOptions.keepInRam, this._loadingOptions.needsRotationScaleTextures);
gaussianSplatting._parentContainer = this._assetContainer;
babylonMeshesArray.push(gaussianSplatting);
if (parsedSOG.sogTextures) {
gaussianSplatting.setSogTextureData(parsedSOG.sogTextures);
}
else {
gaussianSplatting.updateData(parsedSOG.data, parsedSOG.sh, { flipY: false }, undefined, parsedSOG.shDegree);
}
gaussianSplatting.scaling.y *= -1;
gaussianSplatting.computeWorldMatrix(true);
// Expose any parsed safe-orbit limits (SOG does not auto-apply them to the camera).
gaussianSplatting.safeOrbitCameraLimits = SPLATFileLoader._ExtractSafeOrbitLimits(parsedSOG);
scene._blockEntityCollection = false;
};
const engine = scene.getEngine();
let useSogTextures = this._loadingOptions.useSogTextures;
if (useSogTextures && !engine.isWebGPU && engine.version < 2) {
Logger.Warn("SPLATFileLoader: useSogTextures requires WebGL2 or WebGPU. Falling back to CPU path.");
useSogTextures = false;
}
const sogParser = useSogTextures ? ParseSogMetaAsTextures : ParseSogMeta;
// check if data is json string
if (typeof data === "string") {
const dataSOG = JSON.parse(data);
if (dataSOG && dataSOG.means && dataSOG.scales && dataSOG.quats && dataSOG.sh0) {
return new Promise((resolve, reject) => {
sogParser(dataSOG, rootUrl, scene)
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
.then((parsedSOG) => {
makeGSFromParsedSOG(parsedSOG);
resolve(babylonMeshesArray);
})
// eslint-disable-next-line github/no-then
.catch((e) => {
reject(new Error("Failed to parse SOG data.", { cause: e }));
});
});
}
}
const u8 = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
// ZIP signature check for SOG
if (u8[0] === 0x50 && u8[1] === 0x4b) {
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
this._unzipWithFFlateAsync(u8).then((files) => {
sogParser(files, rootUrl, scene)
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
.then((parsedSOG) => {
makeGSFromParsedSOG(parsedSOG);
resolve(babylonMeshesArray);
}) // eslint-disable-next-line github/no-then
.catch((e) => {
reject(new Error("Failed to parse SOG zip data.", { cause: e }));
});
});
});
}
const handlePLY = (resolve) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
SPLATFileLoader._ConvertPLYToSplat(data).then(async (parsedPLY) => {
scene._blockEntityCollection = !!this._assetContainer;
switch (parsedPLY.mode) {
case 0 /* Mode.Splat */:
{
const gaussianSplatting = this._loadingOptions.gaussianSplattingMesh ??
new GaussianSplattingMesh("GaussianSplatting", null, scene, this._loadingOptions.keepInRam, this._loadingOptions.needsRotationScaleTextures);
gaussianSplatting._parentContainer = this._assetContainer;
babylonMeshesArray.push(gaussianSplatting);
gaussianSplatting.updateData(parsedPLY.data, parsedPLY.sh, { flipY: false }, undefined, parsedPLY.shDegree);
gaussianSplatting.scaling.y *= -1;
if (parsedPLY.chirality === "RightHanded") {
gaussianSplatting.scaling.y *= -1;
}
switch (parsedPLY.upAxis) {
case "X":
gaussianSplatting.rotation = new Vector3(0, 0, Math.PI / 2);
break;
case "Y":
gaussianSplatting.rotation = new Vector3(0, 0, Math.PI);
break;
case "Z":
gaussianSplatting.rotation = new Vector3(-Math.PI / 2, Math.PI, 0);
break;
}
gaussianSplatting.computeWorldMatrix(true);
gaussianSplatting.safeOrbitCameraLimits = SPLATFileLoader._ExtractSafeOrbitLimits(parsedPLY);
}
break;
case 1 /* Mode.PointCloud */:
{
const pointcloud = new PointsCloudSystem("PointCloud", 1, scene);
if (SPLATFileLoader._BuildPointCloud(pointcloud, parsedPLY.data)) {
// eslint-disable-next-line github/no-then
await pointcloud.buildMeshAsync().then((mesh) => {
babylonMeshesArray.push(mesh);
});
}
else {
pointcloud.dispose();
}
}
break;
case 2 /* Mode.Mesh */:
{
if (parsedPLY.faces) {
babylonMeshesArray.push(SPLATFileLoader._BuildMesh(scene, parsedPLY));
}
else {
throw new Error("PLY mesh doesn't contain face informations.");
}
}
break;
default:
throw new Error("Unsupported Splat mode");
}
scene._blockEntityCollection = false;
this.applyAutoCameraLimits(SPLATFileLoader._ExtractSafeOrbitLimits(parsedPLY), scene);
resolve(babylonMeshesArray);
});
};
// Check for gzip (before SPZ V4) and NGSP (SPZ V4+) magic bytes to detect SPZ format
const isGZipped = u8[0] === 0x1f && u8[1] === 0x8b;
const isNGSP = u8[0] === 0x4e && u8[1] === 0x47 && u8[2] === 0x53 && u8[3] === 0x50;
if (!isGZipped && !isNGSP) {
return new Promise((resolve) => {
handlePLY(resolve);
});
}
const applyParsedSPZ = (parsedSPZ, resolve) => {
scene._blockEntityCollection = !!this._assetContainer;
const gaussianSplatting = this._loadingOptions.gaussianSplattingMesh ??
new GaussianSplattingMesh("GaussianSplatting", null, scene, this._loadingOptions.keepInRam, this._loadingOptions.needsRotationScaleTextures);
if (parsedSPZ.trainedWithAntialiasing) {
const gsMaterial = gaussianSplatting.material;
gsMaterial.kernelSize = 0.1;
gsMaterial.compensation = true;
}
gaussianSplatting._parentContainer = this._assetContainer;
babylonMeshesArray.push(gaussianSplatting);
gaussianSplatting.updateData(parsedSPZ.data, parsedSPZ.sh, { flipY: false }, undefined, parsedSPZ.shDegree);
if (!this._loadingOptions.flipY) {
gaussianSplatting.scaling.y *= -1;
gaussianSplatting.computeWorldMatrix(true);
}
scene._blockEntityCollection = false;
const safeOrbitLimits = SPLATFileLoader._ExtractSafeOrbitLimits(parsedSPZ);
gaussianSplatting.safeOrbitCameraLimits = safeOrbitLimits;
this.applyAutoCameraLimits(safeOrbitLimits, scene);
resolve(babylonMeshesArray);
};
if (this._loadingOptions.spzLibraryUrl) {
// WASM path: load spz module from URL, pass raw gzip data directly
// eslint-disable-next-line github/no-then
return GetSpzModule(this._loadingOptions.spzLibraryUrl).then((spz) => {
const cloud = spz.loadSpzFromBuffer(new Uint8Array(data), { to: spz.CoordinateSystem.RUB });
// eslint-disable-next-line github/no-then
return ConvertSpzToSplatAsync(cloud, scene).then((parsedSPZ) => {
return new Promise((resolve) => {
applyParsedSPZ(parsedSPZ, resolve);
});
});
});
}
// NGSP (SPZ V4+) requires WASM — the native fallback only handles legacy gzip formats
if (isNGSP) {
return Promise.reject(new Error("SPZ V4+ files (NGSP format) are not supported by the native fallback loader. " +
"Please provide a valid 'spzLibraryUrl' in the loading options to use the WASM-based SPZ library, " +
"or ensure WebAssembly is available in your environment."));
}
// Manual path: decompress gzip, then parse with the built-in SPZ parser
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(data));
controller.close();
},
});
const decompressionStream = new DecompressionStream("gzip");
const decompressedStream = readableStream.pipeThrough(decompressionStream);
return new Promise((resolve) => {
new Response(decompressedStream)
.arrayBuffer()
// eslint-disable-next-line github/no-then
.then((buffer) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises, github/no-then
ParseSpz(buffer, scene, this._loadingOptions).then((parsedSPZ) => {
applyParsedSPZ(parsedSPZ, resolve);
});
})
// eslint-disable-next-line github/no-then
.catch(() => {
handlePLY(resolve);
});
});
}
/**
* Extracts the safe-orbit camera limits from parsed splat metadata, or null when the file
* carries none. Exposed on the loaded mesh (GaussianSplattingMeshBase.safeOrbitCameraLimits)
* so consumers can apply/track them regardless of the active camera type.
* @param meta parsed splat meta data
* @returns the parsed safe-orbit limits, or null
*/
static _ExtractSafeOrbitLimits(meta) {
if (meta.safeOrbitCameraRadiusMin === undefined && meta.safeOrbitCameraElevationMinMax === undefined) {
return null;
}
return {
radiusMin: meta.safeOrbitCameraRadiusMin,
elevationMinMax: meta.safeOrbitCameraElevationMinMax,
};
}
/**
* Applies safe-orbit camera limits parsed from the file metadata to the scene's active
* ArcRotateCamera. No-op when no limits are present, `disableAutoCameraLimits` is set, or the
* active camera is not an ArcRotateCamera — in which case consumers can still read the limits
* from the loaded mesh's `safeOrbitCameraLimits` and apply them themselves.
* @param limits parsed safe-orbit limits (see _ExtractSafeOrbitLimits)
* @param scene
*/
applyAutoCameraLimits(limits, scene) {
if (this._loadingOptions.disableAutoCameraLimits || !limits) {
return;
}
if (scene.activeCamera?.getClassName() === "ArcRotateCamera") {
const arcCam = scene.activeCamera;
if (limits.elevationMinMax) {
arcCam.lowerBetaLimit = Math.PI * 0.5 - limits.elevationMinMax[1];
arcCam.upperBetaLimit = Math.PI * 0.5 - limits.elevationMinMax[0];
}
if (limits.radiusMin) {
arcCam.lowerRadiusLimit = limits.radiusMin;
}
}
}
/**
* Load into an asset container.
* @param scene The scene to load into
* @param data The data to import
* @param rootUrl The root url for scene and resources
* @returns The loaded asset container
*/
// eslint-disable-next-line no-restricted-syntax
loadAssetContainerAsync(scene, data, rootUrl) {
const container = new AssetContainer(scene);
this._assetContainer = container;
return (this.importMeshAsync(null, scene, data, rootUrl)
// eslint-disable-next-line github/no-then
.then((result) => {
for (const mesh of result.meshes) {
container.meshes.push(mesh);
}
// mesh material will be null before 1st rendered frame.
this._assetContainer = null;
return container;
})
// eslint-disable-next-line github/no-then
.catch((ex) => {
this._assetContainer = null;
throw ex;
}));
}
/**
* Imports all objects from the loaded OBJ data and adds them to the scene
* @param scene the scene the objects should be added to
* @param data the OBJ data to load
* @param rootUrl root url to load from
* @returns a promise which completes when objects have been loaded to the scene
*/
// eslint-disable-next-line @typescript-eslint/promise-function-async, no-restricted-syntax
loadAsync(scene, data, rootUrl) {
//Get the 3D model
// eslint-disable-next-line github/no-then
return this.importMeshAsync(null, scene, data, rootUrl).then(() => {
// return void
});
}
/**
* Code from https://github.com/dylanebert/gsplat.js/blob/main/src/loaders/PLYLoader.ts Under MIT license
* Converts a .ply data array buffer to splat
* if data array buffer is not ply, returns the original buffer
* @param data the .ply data to load
* @returns the loaded splat buffer
*/
static _ConvertPLYToSplat(data) {
const ubuf = new Uint8Array(data);
const header = new TextDecoder().decode(ubuf.slice(0, 1024 * 10));
const headerEnd = "end_header\n";
const headerEndIndex = header.indexOf(headerEnd);
if (headerEndIndex < 0 || !header) {
// standard splat
return new Promise((resolve) => {
resolve({ mode: 0 /* Mode.Splat */, data: data, rawSplat: true });
});
}
const vertexCount = parseInt(/element vertex (\d+)\n/.exec(header)[1]);
const faceElement = /element face (\d+)\n/.exec(header);
let faceCount = 0;
if (faceElement) {
faceCount = parseInt(faceElement[1]);
}
const chunkElement = /element chunk (\d+)\n/.exec(header);
let chunkCount = 0;
if (chunkElement) {
chunkCount = parseInt(chunkElement[1]);
}
let rowVertexOffset = 0;
let rowChunkOffset = 0;
const offsets = {
double: 8,
int: 4,
uint: 4,
float: 4,
short: 2,
ushort: 2,
uint16: 2,
uchar: 1,
list: 0,
};
const ElementMode = {
Vertex: 0,
Chunk: 1,
SH: 2,
Float_Tuple: 3,
Float: 4,
Uchar: 5,
};
let chunkMode = ElementMode.Chunk;
const vertexProperties = [];
const filtered = header.slice(0, headerEndIndex).split("\n");
const metaData = {};
for (const prop of filtered) {
if (prop.startsWith("property ")) {
const [, type, name] = prop.split(" ");
if (chunkMode == ElementMode.Chunk) {
rowChunkOffset += offsets[type];
}
else if (chunkMode == ElementMode.Vertex) {
vertexProperties.push({ name, type, offset: rowVertexOffset });
rowVertexOffset += offsets[type];
}
else if (chunkMode == ElementMode.SH) {
vertexProperties.push({ name, type, offset: rowVertexOffset });
}
else if (chunkMode == ElementMode.Float_Tuple) {
const view = new DataView(data, rowChunkOffset, offsets.float * 2);
metaData.safeOrbitCameraElevationMinMax = [view.getFloat32(0, true), view.getFloat32(4, true)];
}
else if (chunkMode == ElementMode.Float) {
const view = new DataView(data, rowChunkOffset, offsets.float);
metaData.safeOrbitCameraRadiusMin = view.getFloat32(0, true);
}
else if (chunkMode == ElementMode.Uchar) {
const view = new DataView(data, rowChunkOffset, offsets.uchar);
if (name == "up_axis") {
metaData.upAxis = view.getUint8(0) == 0 ? "X" : view.getUint8(0) == 1 ? "Y" : "Z";
}
else if (name == "chirality") {
metaData.chirality = view.getUint8(0) == 0 ? "LeftHanded" : "RightHanded";
}
}
if (!offsets[type]) {
Logger.Warn(`Unsupported property type: ${type}.`);
}
}
else if (prop.startsWith("element ")) {
const [, type] = prop.split(" ");
if (type == "chunk") {
chunkMode = ElementMode.Chunk;
}
else if (type == "vertex") {
chunkMode = ElementMode.Vertex;
}
else if (type == "sh") {
chunkMode = ElementMode.SH;
}
else if (type == "safe_orbit_camera_elevation_min_max_radians") {
chunkMode = ElementMode.Float_Tuple;
}
else if (type == "safe_orbit_camera_radius_min") {
chunkMode = ElementMode.Float;
}
else if (type == "up_axis" || type == "chirality") {
chunkMode = ElementMode.Uchar;
}
}
}
const rowVertexLength = rowVertexOffset;
const rowChunkLength = rowChunkOffset;
// eslint-disable-next-line github/no-then
return GaussianSplattingMesh.ConvertPLYWithSHToSplatAsync(data).then(async (splatsData) => {
const dataView = new DataView(data, headerEndIndex + headerEnd.length);
let offset = rowChunkLength * chunkCount + rowVertexLength * vertexCount;
// faces
const faces = [];
if (faceCount) {
for (let i = 0; i < faceCount; i++) {
const faceVertexCount = dataView.getUint8(offset);
if (faceVertexCount != 3) {
continue; // only support triangles
}
offset += 1;
for (let j = 0; j < faceVertexCount; j++) {
const vertexIndex = dataView.getUint32(offset + (2 - j) * 4, true); // change face winding
faces.push(vertexIndex);
}
offset += 12;
}
}
// early exit for chunked/quantized ply
if (chunkCount) {
return await new Promise((resolve) => {
resolve({
mode: 0 /* Mode.Splat */,
data: splatsData.buffer,
sh: splatsData.sh,
shDegree: splatsData.shDegree,
faces: faces,
hasVertexColors: false,
compressed: true,
rawSplat: false,
});
});
}
// count available properties. if all necessary are present then it's a splat. Otherwise, it's a point cloud
// if faces are found, then it's a standard mesh
let propertyCount = 0;
let propertyColorCount = 0;
const splatProperties = ["x", "y", "z", "scale_0", "scale_1", "scale_2", "opacity", "rot_0", "rot_1", "rot_2", "rot_3"];
const splatColorProperties = ["red", "green", "blue", "f_dc_0", "f_dc_1", "f_dc_2"];
for (let propertyIndex = 0; propertyIndex < vertexProperties.length; propertyIndex++) {
const property = vertexProperties[propertyIndex];
if (splatProperties.includes(property.name)) {
propertyCount++;
}
if (splatColorProperties.includes(property.name)) {
propertyColorCount++;
}
}
const hasMandatoryProperties = propertyCount == splatProperties.length && propertyColorCount >= 3;
const currentMode = faceCount ? 2 /* Mode.Mesh */ : hasMandatoryProperties ? 0 /* Mode.Splat */ : 1 /* Mode.PointCloud */;
// parsed ready ready to be used as a splat
return await new Promise((resolve) => {
resolve({
...metaData,
mode: currentMode,
data: splatsData.buffer,
sh: splatsData.sh,
shDegree: splatsData.shDegree,
faces: faces,
hasVertexColors: !!propertyColorCount,
compressed: false,
rawSplat: false,
});
});
});
}
}
SPLATFileLoader._DefaultLoadingOptions = {
keepInRam: false,
flipY: false,
needsRotationScaleTextures: false,
spzLibraryUrl: typeof WebAssembly === "object" ? "https://unpkg.com/@adobe/spz@0.2.2/dist/spz.js" : undefined,
};
// Add this loader into the register plugin
let _Registered$1 = false;
/**
* Registers the SPLATFileLoader scene loader plugin.
* Safe to call multiple times; only the first call has an effect.
*/
function RegisterSPLATFileLoader() {
if (_Registered$1) {
return;
}
_Registered$1 = true;
RegisterSceneLoaderPlugin(new SPLATFileLoader());
}
/** This file must only contain pure code and pure imports */
let _Registered = false;
/**
* Register side effects for enginesExtensionsEngineDynamicTexture.
* Safe to call multiple times; only the first call has an effect.
*/
function RegisterEnginesExtensionsEngineDynamicTexture() {
if (_Registered) {
return;
}
_Registered = true;
ThinEngine.prototype.createDynamicTexture = function (width, height, generateMipMaps, samplingMode) {
const texture = new InternalTexture(this, 4 /* InternalTextureSource.Dynamic */);
texture.baseWidth = width;
texture.baseHeight = height;
if (generateMipMaps) {
width = this.needPOTTextures ? GetExponentOfTwo(width, this._caps.maxTextureSize) : width;
height = this.needPOTTextures ? GetExponentOfTwo(height, this._caps.maxTextureSize) : height;
}
// this.resetTextureCache();
texture.width = width;
texture.height = height;
texture.isReady = false;
texture.generateMipMaps = generateMipMaps;
texture.samplingMode = samplingMode;
this.updateTextureSamplingMode(samplingMode, texture);
this._internalTexturesCache.push(texture);
return texture;
};
ThinEngine.prototype.updateDynamicTexture = function (texture, source, invertY, premulAlpha = false, format, forceBindTexture = false,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
allowGPUOptimization = false) {
if (!texture) {
return;
}
const gl = this._gl;
const target = gl.TEXTURE_2D;
const wasPreviouslyBound = this._bindTextureDirectly(target, texture, true, forceBindTexture);
this._unpackFlipY(invertY === undefined ? texture.invertY : invertY);
if (premulAlpha) {
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1);
}
const textureType = this._getWebGLTextureType(texture.type);
const glformat = this._getInternalFormat(format ? format : texture.format);
const internalFormat = this._getRGBABufferInternalSizedFormat(texture.type, glformat);
gl.texImage2D(target, 0, internalFormat, glformat, textureType, source);
if (texture.generateMipMaps) {
gl.generateMipmap(target);
}
if (!wasPreviouslyBound) {
this._bindTextureDirectly(target, null);
}
if (premulAlpha) {
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
}
if (format) {
texture.format = format;
}
texture._dynamicTextureSource = source;
texture._premulAlpha = premulAlpha;
texture.invertY = invertY || false;
texture.isReady = true;
};
}
RegisterEnginesExtensionsEngineDynamicTexture();
/**
* Re-exports the pure implementation and applies the runtime registration side effect.
* Import "./splatFileLoader.pure" for tree-shakeable, side-effect-free usage.
*/
RegisterSPLATFileLoader();
export { RegisterSPLATFileLoader, SPLATFileLoader };
//# sourceMappingURL=splatFileLoader-Br6IIkR2.esm.js.map