Files
random_tools/node_modules/@cesium/engine/Source/Scene/GaussianSplatPrimitive.js
T
2026-08-11 09:53:42 -04:00

2148 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Frozen from "../Core/Frozen.js";
import Matrix4 from "../Core/Matrix4.js";
import Matrix3 from "../Core/Matrix3.js";
import ModelUtility from "./Model/ModelUtility.js";
import GaussianSplatSorter from "./GaussianSplatSorter.js";
import GaussianSplatTextureGenerator from "./GaussianSplatTextureGenerator.js";
import ComponentDatatype from "../Core/ComponentDatatype.js";
import PixelDatatype from "../Renderer/PixelDatatype.js";
import PixelFormat from "../Core/PixelFormat.js";
import Sampler from "../Renderer/Sampler.js";
import Texture from "../Renderer/Texture.js";
import GaussianSplatRenderResources from "./GaussianSplatRenderResources.js";
import BlendingState from "./BlendingState.js";
import Pass from "../Renderer/Pass.js";
import ShaderDestination from "../Renderer/ShaderDestination.js";
import GaussianSplatVS from "../Shaders/PrimitiveGaussianSplatVS.js";
import GaussianSplatFS from "../Shaders/PrimitiveGaussianSplatFS.js";
import PrimitiveType from "../Core/PrimitiveType.js";
import DrawCommand from "../Renderer/DrawCommand.js";
import Geometry from "../Core/Geometry.js";
import GeometryAttribute from "../Core/GeometryAttribute.js";
import VertexArray from "../Renderer/VertexArray.js";
import BufferUsage from "../Renderer/BufferUsage.js";
import RenderState from "../Renderer/RenderState.js";
import clone from "../Core/clone.js";
import defined from "../Core/defined.js";
import DeveloperError from "../Core/DeveloperError.js";
import VertexAttributeSemantic from "./VertexAttributeSemantic.js";
import AttributeType from "./AttributeType.js";
import ModelComponents from "./ModelComponents.js";
import Axis from "./Axis.js";
import Cartesian3 from "../Core/Cartesian3.js";
import Quaternion from "../Core/Quaternion.js";
import SplitDirection from "./SplitDirection.js";
import destroyObject from "../Core/destroyObject.js";
import ContextLimits from "../Renderer/ContextLimits.js";
import Transforms from "../Core/Transforms.js";
const scratchMatrix4A = new Matrix4();
const scratchMatrix4C = new Matrix4();
const scratchMatrix4D = new Matrix4();
const scratchMatrix3 = new Matrix3();
const scratchTransformQuat = new Quaternion();
const scratchTransformPosition = new Cartesian3();
const scratchTransformRotation = new Quaternion();
const scratchTransformScale = new Cartesian3();
const TRANSFORM_CACHE_EPSILON = 1e-12;
const RIGID_TRANSFORM_EPSILON = 1e-5;
const UNIT_SCALE_FAST_PATH_EPSILON = 1e-7;
/**
* Runtime state machine for steady-state re-sorting of an already committed snapshot.
*
* The transition points are in {@link GaussianSplatPrimitive#update}:
* - IDLE -> WAITING/SORTING when a new steady sort request is scheduled
* - WAITING -> SORTING when a sorter promise becomes available
* - SORTING -> SORTED when the active promise resolves with a valid result
* - SORTED -> IDLE after rebuilding the draw command with fresh indexes
* - Any state -> ERROR when the active sort promise rejects
*
* @private
*/
const GaussianSplatSortingState = {
// No steady sort request is in flight. update() may decide to start one.
IDLE: 0,
// A steady sort was requested, but sorter capacity was unavailable this frame.
WAITING: 1,
// A steady sort promise is in flight and results are pending.
SORTING: 2,
// A valid sorted index buffer is available and waiting to be committed to draw command.
SORTED: 3,
// The active sort request failed; update() throws the captured error.
ERROR: 4,
};
/**
* Snapshot lifecycle for rebuilding aggregated splat data.
*
* Transition order:
* BUILDING -> TEXTURE_PENDING -> TEXTURE_READY -> SORTING -> READY
*
* The transition points are split across two functions:
* - {@link GaussianSplatPrimitive#update} drives BUILDING/SORTING/READY
* - {@link GaussianSplatPrimitive.generateSplatTexture} drives TEXTURE_PENDING/TEXTURE_READY
*
* A snapshot is committed only when it reaches READY, so all GPU resources
* and sorted indexes swap atomically as one unit.
*
* @private
*/
const SnapshotState = {
// CPU aggregation is complete and this snapshot is ready to start async texture generation.
BUILDING: "BUILDING",
// Async texture generation/upload is in flight for this snapshot.
TEXTURE_PENDING: "TEXTURE_PENDING",
// Attribute textures are ready; snapshot can now request index sorting.
TEXTURE_READY: "TEXTURE_READY",
// Sort request is in flight for this snapshot generation.
SORTING: "SORTING",
// Sorted indexes were validated for this generation and may be committed.
READY: "READY",
};
/**
* Aggregated Gaussian splat snapshot data that is built asynchronously and
* atomically committed once all required resources are ready.
*
* @typedef {object} GaussianSplatPrimitive.Snapshot
* @property {number} generation Monotonic data generation token.
* @property {Float32Array} positions Packed splat positions (xyz).
* @property {Float32Array} rotations Packed splat rotations (xyzw).
* @property {Float32Array} scales Packed splat scales (xyz).
* @property {Uint8Array} colors Packed splat colors (rgba).
* @property {Uint32Array|undefined} shData Packed spherical harmonics data.
* @property {number} sphericalHarmonicsDegree Spherical harmonics degree.
* @property {number} shCoefficientCount Coefficients per splat.
* @property {number} numSplats Total splat count in this snapshot.
* @property {Uint32Array|undefined} indexes Sorted index buffer when READY.
* @property {Texture|undefined} gaussianSplatTexture Packed splat attribute texture.
* @property {Texture|undefined} sphericalHarmonicsTexture Packed SH texture.
* @property {number} lastTextureWidth Last splat texture width.
* @property {number} lastTextureHeight Last splat texture height.
* @property {string} state Current snapshot lifecycle state from {@link SnapshotState}.
* @private
*/
/**
* Packed spherical harmonics texture payload.
*
* @typedef {object} GaussianSplatPrimitive.SphericalHarmonicsTextureData
* @property {number} width Texture width in texels.
* @property {number} height Texture height in texels.
* @property {ArrayBufferView} data Packed unsigned integer texture data.
* @private
*/
/**
* Packed Gaussian splat attribute texture payload.
*
* @typedef {object} GaussianSplatPrimitive.AttributeTextureData
* @property {number} width Texture width in texels.
* @property {number} height Texture height in texels.
* @property {ArrayBufferView} data Packed unsigned integer texture data.
* @private
*/
// Two stable frames avoids rebuilding during brief selected-tile jitter.
const DEFAULT_STABLE_FRAMES = 2;
// If selection keeps changing, force a rebuild after ~0.5s at 60fps to guarantee progress.
// Lower values react faster but can thrash on noisy LOD transitions.
// Higher values reduce rebuild churn but keep stale snapshots visible longer.
const DEFAULT_MAX_SNAPSHOT_STALL_FRAMES = 30;
// Minimum delay between steady re-sort requests once the camera is moving.
const DEFAULT_SORT_MIN_FRAME_INTERVAL = 3;
// ~0.5 degree camera direction change threshold before triggering steady re-sort.
const DEFAULT_SORT_MIN_ANGLE_RADIANS = 0.008726646259971648;
// Minimum camera movement in world units before triggering steady re-sort.
const DEFAULT_SORT_MIN_POSITION_DELTA = 1.0;
/**
* Determines whether the camera has moved or rotated enough since the last
* steady sort to justify scheduling a new one.
*
* Returns {@code true} when any of the following hold:
* - No previous steady sort has been recorded yet.
* - The camera position has moved by at least {@link DEFAULT_SORT_MIN_POSITION_DELTA} world units.
* - The camera direction has changed by at least {@link DEFAULT_SORT_MIN_ANGLE_RADIANS} radians.
*
* A minimum frame interval ({@link DEFAULT_SORT_MIN_FRAME_INTERVAL}) is
* enforced to prevent re-sorting every single frame.
*
* @param {GaussianSplatPrimitive} primitive The splat primitive to check.
* @param {FrameState} frameState The current frame state.
* @returns {boolean} Whether a new steady sort should begin.
* @private
*/
function shouldStartSteadySort(primitive, frameState) {
const framesSinceLastSort =
primitive._lastSteadySortFrameNumber >= 0
? frameState.frameNumber - primitive._lastSteadySortFrameNumber
: Number.POSITIVE_INFINITY;
if (
primitive._lastSteadySortFrameNumber >= 0 &&
framesSinceLastSort < DEFAULT_SORT_MIN_FRAME_INTERVAL
) {
return false;
}
const camera = frameState.camera;
if (!defined(camera)) {
return false;
}
if (
!primitive._hasLastSteadySortCameraPosition ||
!primitive._hasLastSteadySortCameraDirection
) {
return true;
}
const positionDelta = Cartesian3.distance(
camera.positionWC,
primitive._lastSteadySortCameraPosition,
);
if (positionDelta >= DEFAULT_SORT_MIN_POSITION_DELTA) {
return true;
}
const angleDelta = Cartesian3.angleBetween(
camera.directionWC,
primitive._lastSteadySortCameraDirection,
);
return angleDelta >= DEFAULT_SORT_MIN_ANGLE_RADIANS;
}
/**
* Records the frame number and camera pose at the start of a steady sort so
* that {@link shouldStartSteadySort} can later compute deltas.
*
* @param {GaussianSplatPrimitive} primitive The splat primitive to update.
* @param {FrameState} frameState The current frame state.
* @private
*/
function markSteadySortStart(primitive, frameState) {
primitive._lastSteadySortFrameNumber = frameState.frameNumber;
const camera = frameState.camera;
if (!defined(camera)) {
return;
}
Cartesian3.clone(camera.positionWC, primitive._lastSteadySortCameraPosition);
primitive._hasLastSteadySortCameraPosition = true;
Cartesian3.clone(
camera.directionWC,
primitive._lastSteadySortCameraDirection,
);
primitive._hasLastSteadySortCameraDirection = true;
}
/**
* Checks whether the set of currently selected tiles differs from the set
* recorded on the primitive. This is used to detect LOD transitions that
* require a snapshot rebuild.
*
* @param {GaussianSplatPrimitive} primitive The splat primitive.
* @param {Cesium3DTile[]} selectedTiles The tiles selected this frame.
* @returns {boolean} {@code true} if the tile set has changed.
* @private
*/
function haveSelectedTilesChanged(primitive, selectedTiles) {
const prevSet = primitive._selectedTileSet;
if (!defined(prevSet) || prevSet.size !== selectedTiles.length) {
return true;
}
for (let i = 0; i < selectedTiles.length; i++) {
if (!prevSet.has(selectedTiles[i])) {
return true;
}
}
return false;
}
/**
* Returns whether the given sort result still matches the primitive's current
* sort request and data generation, i.e. it has not been superseded.
*
* @param {GaussianSplatPrimitive} primitive The splat primitive.
* @param {object} activeSort The sort result to validate.
* @returns {boolean} {@code true} if the sort result is still current.
* @private
*/
function isActiveSort(primitive, activeSort) {
return (
defined(activeSort) &&
activeSort.requestId === primitive._sortRequestId &&
activeSort.dataGeneration === primitive._splatDataGeneration
);
}
/**
* Destroys the GPU textures owned by a snapshot, if any, and clears the
* references so they are not used after destruction.
*
* @param {GaussianSplatPrimitive.Snapshot|undefined} snapshot The snapshot whose textures should be destroyed.
* @private
*/
function destroySnapshotTextures(snapshot) {
if (!defined(snapshot)) {
return;
}
if (defined(snapshot.gaussianSplatTexture)) {
snapshot.gaussianSplatTexture.destroy();
snapshot.gaussianSplatTexture = undefined;
}
if (defined(snapshot.sphericalHarmonicsTexture)) {
snapshot.sphericalHarmonicsTexture.destroy();
snapshot.sphericalHarmonicsTexture = undefined;
}
}
/**
* Schedules a GPU texture for deferred destruction. The texture is kept alive
* for one additional frame so that any in-flight draw commands that reference
* it can finish before the underlying GPU resource is released.
*
* @param {GaussianSplatPrimitive} primitive The owning primitive.
* @param {Texture|undefined} texture The texture to retire.
* @param {number} frameNumber The frame number at which the texture was retired.
* @private
*/
function retireTexture(primitive, texture, frameNumber) {
if (!defined(texture)) {
return;
}
const retired = primitive._retiredTextures;
retired.push({
texture: texture,
frameNumber: frameNumber,
});
}
/**
* Destroys any retired textures whose grace period (one frame) has elapsed.
* Called once per frame to reclaim GPU memory from textures that were replaced
* by a newer snapshot.
*
* @param {GaussianSplatPrimitive} primitive The owning primitive.
* @param {number} frameNumber The current frame number.
* @private
*/
function releaseRetiredTextures(primitive, frameNumber) {
const retired = primitive._retiredTextures;
if (!defined(retired) || retired.length === 0) {
return;
}
const next = [];
for (let i = 0; i < retired.length; i++) {
const entry = retired[i];
if (frameNumber - entry.frameNumber > 0) {
entry.texture.destroy();
} else {
next.push(entry);
}
}
primitive._retiredTextures = next;
}
function getSnapshotArrayBuffer(snapshot, key) {
const value = snapshot?.[key];
return defined(value) ? value.buffer : undefined;
}
function acquireAggregateScratchBuffer(
primitive,
key,
componentDatatype,
requiredLength,
) {
let pool = primitive._aggregateScratchBuffers[key];
if (!defined(pool)) {
pool = [];
primitive._aggregateScratchBuffers[key] = pool;
}
const activeBuffer = getSnapshotArrayBuffer(primitive._snapshot, key);
for (let i = 0; i < pool.length; i++) {
const candidate = pool[i];
if (
candidate.length >= requiredLength &&
candidate.buffer !== activeBuffer
) {
return candidate;
}
}
const created = ComponentDatatype.createTypedArray(
componentDatatype,
requiredLength,
);
pool.push(created);
return created;
}
function trimAggregateScratchBuffer(buffer, length) {
if (buffer.length === length) {
return buffer;
}
return buffer.subarray(0, length);
}
/**
* Atomically promotes a fully-built snapshot to be the active splat data for
* the primitive. This includes swapping attribute arrays, GPU textures, and
* sorted indexes, as well as retiring any previously active textures so they
* can be safely destroyed after the current frame finishes.
*
* The snapshot <b>must</b> be in the {@link SnapshotState.READY} state;
* otherwise a {@link DeveloperError} is thrown.
*
* @param {GaussianSplatPrimitive} primitive The owning primitive.
* @param {GaussianSplatPrimitive.Snapshot} snapshot The snapshot to commit.
* @param {FrameState} frameState The current frame state.
* @throws {DeveloperError} If the snapshot is not READY.
* @private
*/
function commitSnapshot(primitive, snapshot, frameState) {
if (!defined(snapshot.indexes) || snapshot.state !== SnapshotState.READY) {
throw new DeveloperError("Committing snapshot before it is READY.");
}
const frameNumber = frameState.frameNumber;
const currentSnapshot = primitive._snapshot;
const splatTexture = defined(currentSnapshot)
? currentSnapshot.gaussianSplatTexture
: primitive.gaussianSplatTexture;
if (defined(splatTexture) && splatTexture !== snapshot.gaussianSplatTexture) {
retireTexture(primitive, splatTexture, frameNumber);
}
const sphericalHarmonicsTexture = defined(currentSnapshot)
? currentSnapshot.sphericalHarmonicsTexture
: primitive.sphericalHarmonicsTexture;
if (
defined(sphericalHarmonicsTexture) &&
sphericalHarmonicsTexture !== snapshot.sphericalHarmonicsTexture
) {
retireTexture(primitive, sphericalHarmonicsTexture, frameNumber);
}
primitive._snapshot = snapshot;
primitive._positions = snapshot.positions;
primitive._rotations = snapshot.rotations;
primitive._scales = snapshot.scales;
primitive._colors = snapshot.colors;
primitive._shData = snapshot.shData;
primitive._sphericalHarmonicsDegree = snapshot.sphericalHarmonicsDegree;
primitive._numSplats = snapshot.numSplats;
primitive._indexes = snapshot.indexes;
primitive.gaussianSplatTexture = snapshot.gaussianSplatTexture;
primitive.sphericalHarmonicsTexture = snapshot.sphericalHarmonicsTexture;
primitive._lastTextureWidth = snapshot.lastTextureWidth;
primitive._lastTextureHeight = snapshot.lastTextureHeight;
// Commit row-addressing params alongside the texture; the shader must
// always see the mask/shift that matches the active texture layout.
primitive._splatRowMask = snapshot.splatRowMask;
primitive._splatRowShift = snapshot.splatRowShift;
// Above 1.0 when the previous snapshot hit the hard cap; used in
// _wrappedUpdate to inflate traversal SSE and reduce tile load.
primitive._splatBudgetSSEScale = snapshot.splatBudgetSSEScale ?? 1.0;
primitive._hasGaussianSplatTexture = defined(snapshot.gaussianSplatTexture);
primitive._needsGaussianSplatTexture = false;
primitive._gaussianSplatTexturePending = false;
primitive._vertexArray = undefined;
primitive._vertexArrayLen = -1;
primitive._drawCommand = undefined;
primitive._sorterPromise = undefined;
primitive._activeSort = undefined;
primitive._sorterState = GaussianSplatSortingState.IDLE;
primitive._dirty = false;
}
/**
* Finalizes async splat texture generation for a snapshot. The resolved data
* updates or recreates GPU textures, and the snapshot transitions to
* {@link SnapshotState.TEXTURE_READY} when complete.
*
* @param {GaussianSplatPrimitive} primitive The owning primitive.
* @param {FrameState} frameState The current frame state.
* @param {GaussianSplatPrimitive.Snapshot} snapshot Snapshot being populated.
* @param {Promise<GaussianSplatPrimitive.AttributeTextureData>} promise Promise that resolves to packed splat texture data.
* @returns {Promise<void>}
* @private
*/
async function processGeneratedSplatTextureData(
primitive,
frameState,
snapshot,
promise,
) {
try {
const splatTextureData = await promise;
const maxTex = ContextLimits.maximumTextureSize;
// Use maximumTextureSize as the texture width; splatsPerRow = maxTex / 2
// (each splat occupies 2 side-by-side texels). The WASM buffer layout is
// width-independent, so the raw data is reused as-is.
const optimalWidth = maxTex;
let optimalHeight = Math.ceil(snapshot.numSplats / (maxTex / 2));
const splatRowShift = Math.log2(maxTex / 2);
const splatRowMask = maxTex / 2 - 1;
// Hard cap: >maxTex*(maxTex/2) splats cannot fit in any valid texture.
if (optimalHeight > maxTex) {
const originalCount = snapshot.numSplats;
optimalHeight = maxTex;
const splatsPerRow = optimalWidth / 2;
snapshot.numSplats = maxTex * splatsPerRow;
// Truncate CPU attribute arrays to match numSplats.
snapshot.positions = snapshot.positions.subarray(
0,
snapshot.numSplats * 3,
);
snapshot.rotations = snapshot.rotations.subarray(
0,
snapshot.numSplats * 4,
);
snapshot.scales = snapshot.scales.subarray(0, snapshot.numSplats * 3);
snapshot.colors = snapshot.colors.subarray(0, snapshot.numSplats * 4);
// shData is allocated independently and must be truncated separately.
if (defined(snapshot.shData)) {
const shPerSplat = snapshot.shData.length / originalCount;
snapshot.shData = snapshot.shData.subarray(
0,
Math.floor(snapshot.numSplats * shPerSplat),
);
}
// Scale up SSE next frame so traversal selects fewer tiles.
snapshot.splatBudgetSSEScale = originalCount / snapshot.numSplats;
console.warn(
`[GaussianSplat][HARD CAP] ${originalCount} splats exceed the maximum texture capacity ` +
`(${maxTex}\u00d7${splatsPerRow} = ${snapshot.numSplats} splats at width=${optimalWidth}). ` +
`Rendering only the first ${snapshot.numSplats} splats to avoid a WebGL crash. ` +
`Increasing maximumScreenSpaceError by ${snapshot.splatBudgetSSEScale.toFixed(2)}x next frame.`,
);
} else {
// Within budget; clear any SSE inflation carried over from a previous cap.
snapshot.splatBudgetSSEScale = 1.0;
}
// Trim or zero-pad the raw WASM buffer to match the chosen dimensions.
const requiredLen = optimalWidth * optimalHeight * 4;
let effectiveData;
if (requiredLen <= splatTextureData.data.length) {
effectiveData = splatTextureData.data.subarray(0, requiredLen);
} else {
effectiveData = new Uint32Array(requiredLen);
effectiveData.set(splatTextureData.data);
}
const effectiveTextureData = {
width: optimalWidth,
height: optimalHeight,
data: effectiveData,
};
snapshot.splatRowMask = splatRowMask;
snapshot.splatRowShift = splatRowShift;
if (primitive._pendingSnapshot !== snapshot) {
snapshot.state = SnapshotState.BUILDING;
return;
}
if (!defined(snapshot.gaussianSplatTexture)) {
snapshot.gaussianSplatTexture = createGaussianSplatTexture(
frameState.context,
effectiveTextureData,
);
} else if (
snapshot.lastTextureHeight !== effectiveTextureData.height ||
snapshot.lastTextureWidth !== effectiveTextureData.width
) {
const oldTex = snapshot.gaussianSplatTexture;
snapshot.gaussianSplatTexture = createGaussianSplatTexture(
frameState.context,
effectiveTextureData,
);
oldTex.destroy();
} else {
snapshot.gaussianSplatTexture.copyFrom({
source: {
width: effectiveTextureData.width,
height: effectiveTextureData.height,
arrayBufferView: effectiveTextureData.data,
},
});
}
snapshot.lastTextureHeight = effectiveTextureData.height;
snapshot.lastTextureWidth = effectiveTextureData.width;
if (defined(snapshot.shData) && snapshot.sphericalHarmonicsDegree > 0) {
const oldTex = snapshot.sphericalHarmonicsTexture;
const width = ContextLimits.maximumTextureSize;
const dims = snapshot.shCoefficientCount / 3;
const splatsPerRow = Math.floor(width / dims);
const floatsPerRow = splatsPerRow * (dims * 2);
const shHeight = Math.ceil(snapshot.numSplats / splatsPerRow);
// SH texture width is already maxTex and cannot be widened further.
// When height would exceed the GPU limit, gracefully disable SH for this
// snapshot and fall back to base color rendering rather than crashing.
if (shHeight > width) {
console.warn(
`[GaussianSplat][SHTexture] ${snapshot.numSplats} splats require SH height ${shHeight} > maxTex ${width}. ` +
`Disabling spherical harmonics for this snapshot (color-only fallback).`,
);
snapshot.sphericalHarmonicsDegree = 0;
if (defined(oldTex)) {
oldTex.destroy();
}
snapshot.sphericalHarmonicsTexture = undefined;
} else {
const texBuf = new Uint32Array(width * shHeight * 2);
let dataIndex = 0;
for (let i = 0; dataIndex < snapshot.shData.length; i += width * 2) {
texBuf.set(
snapshot.shData.subarray(dataIndex, dataIndex + floatsPerRow),
i,
);
dataIndex += floatsPerRow;
}
snapshot.sphericalHarmonicsTexture = createSphericalHarmonicsTexture(
frameState.context,
{
data: texBuf,
width: width,
height: shHeight,
},
);
if (defined(oldTex)) {
oldTex.destroy();
}
}
}
snapshot.state = SnapshotState.TEXTURE_READY;
} catch (error) {
console.error("Error generating Gaussian splat texture:", error);
snapshot.state = SnapshotState.BUILDING;
}
}
/**
* Resolves an in-flight sort for a pending snapshot, validates that the result
* still matches the active generation/request, and commits the snapshot when
* valid.
*
* @param {GaussianSplatPrimitive} primitive The owning primitive.
* @param {FrameState} frameState The current frame state.
* @param {object|undefined} pendingSort Pending sort metadata.
* @param {Promise<Uint32Array>} sortPromise Promise that resolves to sorted indexes.
* @returns {Promise<void>}
* @private
*/
async function resolvePendingSnapshotSort(
primitive,
frameState,
pendingSort,
sortPromise,
) {
try {
const sortedData = await sortPromise;
if (
!defined(pendingSort) ||
pendingSort.snapshot !== primitive._pendingSnapshot
) {
return;
}
const expectedCount = pendingSort.expectedCount;
const currentCount = expectedCount;
const sortedLen = sortedData?.length;
if (expectedCount !== currentCount || sortedLen !== expectedCount) {
primitive._pendingSortPromise = undefined;
primitive._pendingSort = undefined;
if (pendingSort.snapshot.state === SnapshotState.SORTING) {
pendingSort.snapshot.state = SnapshotState.TEXTURE_READY;
}
return;
}
const pending = pendingSort.snapshot;
pending.indexes = sortedData;
pending.state = SnapshotState.READY;
primitive._pendingSortPromise = undefined;
primitive._pendingSort = undefined;
commitSnapshot(primitive, pending, frameState);
primitive._pendingSnapshot = undefined;
GaussianSplatPrimitive.buildGSplatDrawCommand(primitive, frameState);
} catch (err) {
if (
!defined(pendingSort) ||
pendingSort.snapshot !== primitive._pendingSnapshot
) {
return;
}
primitive._pendingSortPromise = undefined;
primitive._pendingSort = undefined;
if (pendingSort.snapshot.state === SnapshotState.SORTING) {
pendingSort.snapshot.state = SnapshotState.TEXTURE_READY;
}
primitive._sorterState = GaussianSplatSortingState.ERROR;
primitive._sorterError = err;
}
}
/**
* Resolves an in-flight steady-state sort for the current committed snapshot.
* Results are ignored when superseded; otherwise, they advance sorting state
* to {@link GaussianSplatSortingState.SORTED}.
*
* @param {GaussianSplatPrimitive} primitive The owning primitive.
* @param {object|undefined} activeSort Active sort metadata.
* @param {Promise<Uint32Array>} sortPromise Promise that resolves to sorted indexes.
* @returns {Promise<void>}
* @private
*/
async function resolveSteadySort(primitive, activeSort, sortPromise) {
try {
const sortedData = await sortPromise;
const isActive = isActiveSort(primitive, activeSort);
const expectedCount = activeSort?.expectedCount;
const currentCount = expectedCount;
const sortedLen = sortedData?.length;
const isMismatch =
expectedCount !== currentCount || sortedLen !== expectedCount;
if (!isActive || isMismatch) {
if (isActive) {
primitive._sorterPromise = undefined;
primitive._sorterState = GaussianSplatSortingState.IDLE;
}
return;
}
primitive._indexes = sortedData;
primitive._sorterState = GaussianSplatSortingState.SORTED;
} catch (err) {
if (!isActiveSort(primitive, activeSort)) {
return;
}
primitive._sorterState = GaussianSplatSortingState.ERROR;
primitive._sorterError = err;
}
}
/**
* Creates a GPU texture that stores packed spherical harmonics coefficient
* data for all splats. The texture uses a two-channel unsigned-integer format
* ({@link PixelFormat.RG_INTEGER}) and nearest-neighbor sampling.
*
* @param {Context} context The WebGL context.
* @param {GaussianSplatPrimitive.SphericalHarmonicsTextureData} shData Packed SH texture payload.
* @returns {Texture} The created texture.
* @private
*/
function createSphericalHarmonicsTexture(context, shData) {
const texture = new Texture({
context: context,
source: {
width: shData.width,
height: shData.height,
arrayBufferView: shData.data,
},
preMultiplyAlpha: false,
skipColorSpaceConversion: true,
pixelFormat: PixelFormat.RG_INTEGER,
pixelDatatype: PixelDatatype.UNSIGNED_INT,
flipY: false,
sampler: Sampler.NEAREST,
});
return texture;
}
/**
* Creates a GPU texture that stores the packed Gaussian splat attributes
* (positions, scales, rotations, colors). The texture uses an RGBA
* unsigned-integer format ({@link PixelFormat.RGBA_INTEGER}) and
* nearest-neighbor sampling.
*
* @param {Context} context The WebGL context.
* @param {GaussianSplatPrimitive.AttributeTextureData} splatTextureData Packed splat texture payload.
* @returns {Texture} The created texture.
* @private
*/
function createGaussianSplatTexture(context, splatTextureData) {
return new Texture({
context: context,
source: {
width: splatTextureData.width,
height: splatTextureData.height,
arrayBufferView: splatTextureData.data,
},
preMultiplyAlpha: false,
skipColorSpaceConversion: true,
pixelFormat: PixelFormat.RGBA_INTEGER,
pixelDatatype: PixelDatatype.UNSIGNED_INT,
flipY: false,
sampler: Sampler.NEAREST,
});
}
/** A primitive that renders Gaussian splats.
* <p>
* This primitive is used to render Gaussian splats in a 3D Tileset.
* It is designed to work with the KHR_gaussian_splatting and KHR_gaussian_splatting_compression_spz_2 extensions.
* </p>
* @alias GaussianSplatPrimitive
* @constructor
* @param {object} options An object with the following properties:
* @param {Cesium3DTileset} options.tileset The tileset that this primitive belongs to.
* @param {boolean} [options.debugShowBoundingVolume=false] Whether to show the bounding volume of the primitive for debugging purposes.
* @private
*/
function GaussianSplatPrimitive(options) {
options = options ?? Frozen.EMPTY_OBJECT;
/**
* The positions of the Gaussian splats in the primitive.
* @type {undefined|Float32Array}
* @private
*/
this._positions = undefined;
/**
* The rotations of the Gaussian splats in the primitive.
* @type {undefined|Float32Array}
* @private
*/
this._rotations = undefined;
/**
* The scales of the Gaussian splats in the primitive.
* @type {undefined|Float32Array}
* @private
*/
this._scales = undefined;
/**
* The colors of the Gaussian splats in the primitive.
* @type {undefined|Uint8Array}
* @private
*/
this._colors = undefined;
/**
* The indexes of the Gaussian splats in the primitive.
* Used to index into the splat attribute texture in the vertex shader.
* @type {undefined|Uint32Array}
* @private
*/
this._indexes = undefined;
/**
* The number of splats in the primitive.
* This is the total number of splats across all selected tiles.
* @type {number}
* @private
*/
this._numSplats = 0;
/**
* Indicates whether or not the primitive needs a Gaussian splat texture.
* This is set to true when the primitive is first created or when the splat attributes change.
* @type {boolean}
* @private
*/
this._needsGaussianSplatTexture = true;
this._snapshot = undefined;
this._pendingSnapshot = undefined;
this._retiredTextures = [];
this._aggregateScratchBuffers = {
positions: [],
scales: [],
rotations: [],
colors: [],
};
/**
* Scratch buffer re-used across frames for aggregating packed spherical
* harmonics data so that a fresh typed-array allocation is avoided on
* every tile-selection change.
* @type {undefined|Uint32Array}
* @private
*/
this._scratchAggregateShBuffer = undefined;
this._selectedTilesStableFrames = 0;
this._needsSnapshotRebuild = false;
this._snapshotRebuildStallFrames = 0;
/**
* The previous view matrix used to determine if the primitive needs to be updated.
* This is used to avoid unnecessary updates when the view matrix hasn't changed.
* @type {Matrix4}
* @private
*/
this._prevViewMatrix = new Matrix4();
/**
* Indicates whether or not to show the bounding volume of the primitive for debugging purposes.
* This is used to visualize the bounding volume of the primitive in the scene.
* @type {boolean}
* @private
*/
this._debugShowBoundingVolume = options.debugShowBoundingVolume ?? false;
/**
* The texture used to store the Gaussian splat attributes.
* This texture is created from the splat attributes (positions, scales, rotations, colors)
* and is used in the vertex shader to render the splats.
* @type {undefined|Texture}
* @private
* @see {@link GaussianSplatTextureGenerator}
*/
this.gaussianSplatTexture = undefined;
/**
* The texture used to store the spherical harmonics coefficients for the Gaussian splats.
* @type {undefined|Texture}
* @private
*/
this.sphericalHarmonicsTexture = undefined;
/**
* The last width of the Gaussian splat texture.
* This is used to track changes in the texture size and update the primitive accordingly.
* @type {number}
* @private
*/
this._lastTextureWidth = 0;
/**
* The last height of the Gaussian splat texture.
* This is used to track changes in the texture size and update the primitive accordingly.
* @type {number}
* @private
*/
this._lastTextureHeight = 0;
/**
* The vertex array used to render the Gaussian splats.
* This vertex array contains the attributes needed to render the splats, such as positions and indexes.
* @type {undefined|VertexArray}
* @private
*/
this._vertexArray = undefined;
/**
* The length of the vertex array, used to track changes in the number of splats.
* This is used to determine if the vertex array needs to be rebuilt.
* @type {number}
* @private
*/
this._vertexArrayLen = -1;
this._splitDirection = SplitDirection.NONE;
/**
* The dirty flag forces the primitive to render this frame.
* @type {boolean}
* @private
*/
this._dirty = false;
this._tileset = options.tileset;
this._baseTilesetUpdate = this._tileset.update;
this._tileset.update = this._wrappedUpdate.bind(this);
this._tileset.tileLoad.addEventListener(this.onTileLoad, this);
this._tileset.tileVisible.addEventListener(this.onTileVisible, this);
/**
* Tracks current count of selected tiles.
* This is used to determine if the primitive needs to be rebuilt.
* @type {number}
* @private
*/
this.selectedTileLength = 0;
this._selectedTileSet = new Set();
/**
* Indicates whether or not the primitive is ready for use.
* @type {boolean}
* @private
*/
this._ready = false;
/**
* Indicates whether or not the primitive has a Gaussian splat texture.
* @type {boolean}
* @private
*/
this._hasGaussianSplatTexture = false;
/**
* Indicates whether or not the primitive is currently generating a Gaussian splat texture.
* @type {boolean}
* @private
*/
this._gaussianSplatTexturePending = false;
/**
* The draw command used to render the Gaussian splats.
* @type {undefined|DrawCommand}
* @private
*/
this._drawCommand = undefined;
this._drawCommandModelMatrix = new Matrix4();
/**
* The root transform of the tileset.
* This is used to transform the splats into world space.
* @type {undefined|Matrix4}
* @private
*/
this._rootTransform = undefined;
/**
* The axis correction matrix to transform the splats from Y-up to Z-up.
* @type {Matrix4}
* @private
*/
this._axisCorrectionMatrix = ModelUtility.getAxisCorrectionMatrix(
Axis.Y,
Axis.X,
new Matrix4(),
);
/**
* Cached inverse rotation for SH evaluation, updated each snapshot rebuild.
* Converts a world-space view direction to the original GLB Y-up model space
* so that spherical harmonic coefficients are evaluated in the correct frame.
* @type {Matrix3}
* @private
*/
this._shInverseRotation = new Matrix3();
/**
* Indicates whether or not the primitive has been destroyed.
* @type {boolean}
* @private
*/
this._isDestroyed = false;
/**
* The state of the Gaussian splat sorting process.
* This is used to track the progress of the sorting operation.
* @type {GaussianSplatSortingState}
* @private
*/
this._sorterState = GaussianSplatSortingState.IDLE;
/**
* A promise that resolves when the Gaussian splat sorting operation is complete.
* This is used to track the progress of the sorting operation.
* @type {undefined|Promise}
* @private
*/
this._sorterPromise = undefined;
this._splatDataGeneration = 0;
this._sortRequestId = 0;
this._activeSort = undefined;
this._pendingSortPromise = undefined;
this._pendingSort = undefined;
this._lastSteadySortFrameNumber = -1;
this._lastSteadySortCameraPosition = new Cartesian3();
this._hasLastSteadySortCameraPosition = false;
this._lastSteadySortCameraDirection = new Cartesian3();
this._hasLastSteadySortCameraDirection = false;
/**
* An error that occurred during the Gaussian splat sorting operation.
* Thrown when state is ERROR.
* @type {undefined|Error}
* @private
*/
this._sorterError = undefined;
// Splat texture row-addressing params; forwarded to the shader as uniforms.
// The texture width is always maximumTextureSize (varies by GPU), so these
// are computed per-snapshot and initialized here as safe placeholders.
this._splatRowMask = 0; // overwritten on first snapshot commit
this._splatRowShift = 0; // overwritten on first snapshot commit
/**
* Multiplier applied to maximumScreenSpaceError during tile traversal when
* the previous snapshot exceeded the splat texture budget. A value above 1.0
* biases traversal toward coarser LODs, lowering the total splat count.
* Resets to 1.0 once the splat count is within budget.
* @type {number}
* @private
*/
this._splatBudgetSSEScale = 1.0;
}
Object.defineProperties(GaussianSplatPrimitive.prototype, {
/**
* Indicates whether the primitive is ready for use.
* @memberof GaussianSplatPrimitive.prototype
* @type {boolean}
* @readonly
*/
ready: {
get: function () {
return this._ready;
},
},
/**
* Indicates whether the primitive has completed loading and sorting.
* @memberof GaussianSplatPrimitive.prototype
* @type {boolean}
* @private
* @readonly
*/
isStable: {
get: function () {
return (
!this._dirty &&
(!defined(this._pendingSnapshot) ||
this._pendingSnapshot.state === SnapshotState.READY)
);
},
},
/**
* The {@link SplitDirection} to apply to this point.
* @memberof GaussianSplatPrimitive.prototype
* @type {SplitDirection}
* @default {@link SplitDirection.NONE}
*/
splitDirection: {
get: function () {
return this._splitDirection;
},
set: function (value) {
if (this._splitDirection !== value) {
this._splitDirection = value;
this._dirty = true;
}
},
},
});
/**
* Replaces the tileset's own update function so this primitive is updated
* immediately after the tileset traversal, within the same frame. When
* _splatBudgetSSEScale is above 1.0, maximumScreenSpaceError is inflated
* for the duration of the traversal to reduce the number of tiles selected.
* @param {FrameState} frameState
* @private
*/
GaussianSplatPrimitive.prototype._wrappedUpdate = function (frameState) {
const tileset = this._tileset;
if (this._splatBudgetSSEScale !== 1.0) {
// Inflate SSE for this traversal only; the original value is restored
// immediately so the user-visible tileset property is never permanently changed.
const originalSSE = tileset.maximumScreenSpaceError;
tileset.maximumScreenSpaceError *= this._splatBudgetSSEScale;
this._baseTilesetUpdate.call(tileset, frameState);
tileset.maximumScreenSpaceError = originalSSE;
} else {
this._baseTilesetUpdate.call(tileset, frameState);
}
this.update(frameState);
};
/**
* Destroys the primitive and releases its resources in a deterministic manner.
* @private
*/
GaussianSplatPrimitive.prototype.destroy = function () {
this._positions = undefined;
this._rotations = undefined;
this._scales = undefined;
this._colors = undefined;
this._indexes = undefined;
destroySnapshotTextures(this._pendingSnapshot);
destroySnapshotTextures(this._snapshot);
if (defined(this._retiredTextures)) {
for (let i = 0; i < this._retiredTextures.length; i++) {
this._retiredTextures[i].texture.destroy();
}
}
this._retiredTextures = [];
this._pendingSnapshot = undefined;
this._snapshot = undefined;
this._aggregateScratchBuffers = undefined;
this.gaussianSplatTexture = undefined;
this.sphericalHarmonicsTexture = undefined;
const drawCommand = this._drawCommand;
if (defined(drawCommand)) {
drawCommand.shaderProgram =
drawCommand.shaderProgram && drawCommand.shaderProgram.destroy();
}
if (defined(this._vertexArray)) {
this._vertexArray.destroy();
this._vertexArray = undefined;
}
this._tileset.update = this._baseTilesetUpdate.bind(this._tileset);
return destroyObject(this);
};
/**
* Returns true if this object was destroyed; otherwise, false.
* <br /><br />
* If this object was destroyed, it should not be used; calling any function other than
* <code>isDestroyed</code> will result in a {@link DeveloperError} exception.
* @returns {boolean} Returns true if the primitive has been destroyed, otherwise false.
* @private
*/
GaussianSplatPrimitive.prototype.isDestroyed = function () {
return this._isDestroyed;
};
/**
* Event callback for when a tile is loaded.
* This method is called when a tile is loaded and the primitive needs to be updated.
* It sets the dirty flag to true, indicating that the primitive needs to be rebuilt.
* @param {Cesium3DTile} tile
* @private
*/
GaussianSplatPrimitive.prototype.onTileLoad = function (tile) {
this._dirty = true;
};
/**
* Callback for visible tiles.
* @param {Cesium3DTile} tile
* @private
*/
GaussianSplatPrimitive.prototype.onTileVisible = function (tile) {};
/**
* Transforms the tile's splat primitive attributes into world space.
* <br /><br />
* This method applies the computed transform of the tile and the tileset's bounding sphere
* to the splat primitive's position, rotation, and scale attributes.
* It modifies the attributes in place, transforming them from local space to world space.
*
* @param {Cesium3DTile} tile
* @private
*/
GaussianSplatPrimitive.transformTile = function (tile) {
const computedTransform = tile.computedTransform;
const gltfPrimitive = tile.content.gltfPrimitive;
const gaussianSplatPrimitive = tile.tileset.gaussianSplatPrimitive;
if (gaussianSplatPrimitive._rootTransform === undefined) {
gaussianSplatPrimitive._rootTransform = Transforms.eastNorthUpToFixedFrame(
tile.tileset.boundingSphere.center,
);
}
const rootTransform = gaussianSplatPrimitive._rootTransform;
const computedModelMatrix = Matrix4.multiplyTransformation(
computedTransform,
gaussianSplatPrimitive._axisCorrectionMatrix,
scratchMatrix4A,
);
Matrix4.multiplyTransformation(
computedModelMatrix,
tile.content.worldTransform,
computedModelMatrix,
);
// toLocal is inverse(rootTransform) only. tileset.modelMatrix is already
// factored into computedModelMatrix via tile.computedTransform, so its effect
// is baked directly into the splat values rather than split into the draw
// command's modelMatrix. This keeps czm_view * modelMatrix numerically small,
// avoiding float32 precision loss at ECEF-scale translations.
const toLocal = Matrix4.inverse(rootTransform, scratchMatrix4C);
const transform = Matrix4.multiplyTransformation(
toLocal,
computedModelMatrix,
scratchMatrix4A,
);
const cachedTransform = tile.content._lastSplatTransform;
if (
tile.content._transformed &&
defined(cachedTransform) &&
Matrix4.equalsEpsilon(transform, cachedTransform, TRANSFORM_CACHE_EPSILON)
) {
return;
}
const positions = tile.content.positions;
const rotations = tile.content.rotations;
const scales = tile.content.scales;
// Extract the rotation quaternion from transform once, before the per-splat
// loop. The columns of transform's upper-left 3x3 have magnitude ≈ 1 (rigid
// body placement), so normalizing is numerically stable. We cannot decompose
// the per-splat combined matrix (transform × TRS_i) instead, because each
// splat's scale can be very small, causing catastrophic cancellation when
// dividing to recover a pure rotation matrix.
const col0Len = Math.sqrt(
transform[0] * transform[0] +
transform[1] * transform[1] +
transform[2] * transform[2],
);
const col1Len = Math.sqrt(
transform[4] * transform[4] +
transform[5] * transform[5] +
transform[6] * transform[6],
);
const col2Len = Math.sqrt(
transform[8] * transform[8] +
transform[9] * transform[9] +
transform[10] * transform[10],
);
scratchMatrix3[0] = transform[0] / col0Len;
scratchMatrix3[1] = transform[1] / col0Len;
scratchMatrix3[2] = transform[2] / col0Len;
scratchMatrix3[3] = transform[4] / col1Len;
scratchMatrix3[4] = transform[5] / col1Len;
scratchMatrix3[5] = transform[6] / col1Len;
scratchMatrix3[6] = transform[8] / col2Len;
scratchMatrix3[7] = transform[9] / col2Len;
scratchMatrix3[8] = transform[10] / col2Len;
const dot01 =
scratchMatrix3[0] * scratchMatrix3[3] +
scratchMatrix3[1] * scratchMatrix3[4] +
scratchMatrix3[2] * scratchMatrix3[5];
const dot02 =
scratchMatrix3[0] * scratchMatrix3[6] +
scratchMatrix3[1] * scratchMatrix3[7] +
scratchMatrix3[2] * scratchMatrix3[8];
const dot12 =
scratchMatrix3[3] * scratchMatrix3[6] +
scratchMatrix3[4] * scratchMatrix3[7] +
scratchMatrix3[5] * scratchMatrix3[8];
const hasUnitScale =
Math.abs(col0Len - 1.0) <= UNIT_SCALE_FAST_PATH_EPSILON &&
Math.abs(col1Len - 1.0) <= UNIT_SCALE_FAST_PATH_EPSILON &&
Math.abs(col2Len - 1.0) <= UNIT_SCALE_FAST_PATH_EPSILON;
const isOrthogonal =
Math.abs(dot01) <= RIGID_TRANSFORM_EPSILON &&
Math.abs(dot02) <= RIGID_TRANSFORM_EPSILON &&
Math.abs(dot12) <= RIGID_TRANSFORM_EPSILON;
const determinant =
scratchMatrix3[0] *
(scratchMatrix3[4] * scratchMatrix3[8] -
scratchMatrix3[5] * scratchMatrix3[7]) -
scratchMatrix3[3] *
(scratchMatrix3[1] * scratchMatrix3[8] -
scratchMatrix3[2] * scratchMatrix3[7]) +
scratchMatrix3[6] *
(scratchMatrix3[1] * scratchMatrix3[5] -
scratchMatrix3[2] * scratchMatrix3[4]);
const useFastPath =
hasUnitScale &&
isOrthogonal &&
Math.abs(determinant - 1.0) <= RIGID_TRANSFORM_EPSILON;
Quaternion.fromRotationMatrix(scratchMatrix3, scratchTransformQuat);
Quaternion.normalize(scratchTransformQuat, scratchTransformQuat);
const attributePositions = ModelUtility.getAttributeBySemantic(
gltfPrimitive,
VertexAttributeSemantic.POSITION,
).typedArray;
const attributeRotations = ModelUtility.getAttributeBySemantic(
gltfPrimitive,
VertexAttributeSemantic.ROTATION,
).typedArray;
const attributeScales = ModelUtility.getAttributeBySemantic(
gltfPrimitive,
VertexAttributeSemantic.SCALE,
).typedArray;
const position = scratchTransformPosition;
const rotation = scratchTransformRotation;
const scale = scratchTransformScale;
for (let i = 0; i < attributePositions.length / 3; ++i) {
position.x = attributePositions[i * 3];
position.y = attributePositions[i * 3 + 1];
position.z = attributePositions[i * 3 + 2];
rotation.x = attributeRotations[i * 4];
rotation.y = attributeRotations[i * 4 + 1];
rotation.z = attributeRotations[i * 4 + 2];
rotation.w = attributeRotations[i * 4 + 3];
scale.x = attributeScales[i * 3];
scale.y = attributeScales[i * 3 + 1];
scale.z = attributeScales[i * 3 + 2];
if (useFastPath) {
Matrix4.multiplyByPoint(transform, position, position);
Quaternion.multiply(scratchTransformQuat, rotation, rotation);
Quaternion.normalize(rotation, rotation);
} else {
Matrix4.fromTranslationQuaternionRotationScale(
position,
rotation,
scale,
scratchMatrix4D,
);
Matrix4.multiplyTransformation(
transform,
scratchMatrix4D,
scratchMatrix4D,
);
Matrix4.getTranslation(scratchMatrix4D, position);
Matrix4.getScale(scratchMatrix4D, scale);
// rotation still holds the original splat quaternion from attributeRotations.
// Apply the transform's rotation by left-multiplying the transform quaternion.
Quaternion.multiply(scratchTransformQuat, rotation, rotation);
Quaternion.normalize(rotation, rotation);
}
positions[i * 3] = position.x;
positions[i * 3 + 1] = position.y;
positions[i * 3 + 2] = position.z;
rotations[i * 4] = rotation.x;
rotations[i * 4 + 1] = rotation.y;
rotations[i * 4 + 2] = rotation.z;
rotations[i * 4 + 3] = rotation.w;
scales[i * 3] = scale.x;
scales[i * 3 + 1] = scale.y;
scales[i * 3 + 2] = scale.z;
}
tile.content._lastSplatTransform = Matrix4.clone(
transform,
tile.content._lastSplatTransform,
);
tile.content._transformed = true;
};
/**
* Generates the Gaussian splat texture for the primitive.
* This method creates a texture from the splat attributes (positions, scales, rotations, colors)
* and updates the primitive's state accordingly.
*
* @see {@link GaussianSplatTextureGenerator}
*
* @param {GaussianSplatPrimitive} primitive The owning primitive.
* @param {FrameState} frameState The current frame state.
* @param {GaussianSplatPrimitive.Snapshot} snapshot Snapshot being populated.
* @private
*/
GaussianSplatPrimitive.generateSplatTexture = function (
primitive,
frameState,
snapshot,
) {
if (!defined(snapshot) || snapshot.state !== SnapshotState.BUILDING) {
return;
}
snapshot.state = SnapshotState.TEXTURE_PENDING;
const promise = GaussianSplatTextureGenerator.generateFromAttributes({
attributes: {
positions: new Float32Array(snapshot.positions),
scales: new Float32Array(snapshot.scales),
rotations: new Float32Array(snapshot.rotations),
colors: new Uint8Array(snapshot.colors),
},
count: snapshot.numSplats,
});
if (!defined(promise)) {
snapshot.state = SnapshotState.BUILDING;
return;
}
void processGeneratedSplatTextureData(
primitive,
frameState,
snapshot,
promise,
);
};
/**
* Builds the draw command for the Gaussian splat primitive.
* This method sets up the shader program, render state, and vertex array for rendering the Gaussian splats.
* It also configures the attributes and uniforms required for rendering.
*
* @param {GaussianSplatPrimitive} primitive
* @param {FrameState} frameState
*
* @private
*/
GaussianSplatPrimitive.buildGSplatDrawCommand = function (
primitive,
frameState,
) {
const tileset = primitive._tileset;
const renderResources = new GaussianSplatRenderResources(primitive);
const { shaderBuilder } = renderResources;
const renderStateOptions = renderResources.renderStateOptions;
renderStateOptions.cull.enabled = false;
renderStateOptions.depthMask = false;
renderStateOptions.depthTest.enabled = true;
renderStateOptions.blending = BlendingState.PRE_MULTIPLIED_ALPHA_BLEND;
renderResources.alphaOptions.pass = Pass.GAUSSIAN_SPLATS;
shaderBuilder.addAttribute("vec2", "a_screenQuadPosition");
shaderBuilder.addAttribute("float", "a_splatIndex");
shaderBuilder.addVarying("vec4", "v_splatColor");
shaderBuilder.addVarying("vec2", "v_vertPos");
shaderBuilder.addUniform(
"float",
"u_splitDirection",
ShaderDestination.VERTEX,
);
shaderBuilder.addVarying("float", "v_splitDirection");
shaderBuilder.addUniform(
"highp usampler2D",
"u_splatAttributeTexture",
ShaderDestination.VERTEX,
);
shaderBuilder.addUniform(
"float",
"u_sphericalHarmonicsDegree",
ShaderDestination.VERTEX,
);
shaderBuilder.addUniform("float", "u_splatScale", ShaderDestination.VERTEX);
shaderBuilder.addUniform(
"vec3",
"u_cameraPositionWC",
ShaderDestination.VERTEX,
);
shaderBuilder.addUniform(
"mat3",
"u_inverseModelRotation",
ShaderDestination.VERTEX,
);
const uniformMap = renderResources.uniformMap;
// Row-addressing uniforms: read from primitive each draw so they stay in sync
// with the texture width chosen for the current snapshot.
shaderBuilder.addUniform("int", "u_splatRowMask", ShaderDestination.VERTEX);
shaderBuilder.addUniform("int", "u_splatRowShift", ShaderDestination.VERTEX);
const textureCache = primitive.gaussianSplatTexture;
uniformMap.u_splatAttributeTexture = function () {
return textureCache;
};
uniformMap.u_splatRowMask = function () {
return primitive._splatRowMask;
};
uniformMap.u_splatRowShift = function () {
return primitive._splatRowShift;
};
if (primitive._sphericalHarmonicsDegree > 0) {
shaderBuilder.addDefine(
"HAS_SPHERICAL_HARMONICS",
"1",
ShaderDestination.VERTEX,
);
shaderBuilder.addUniform(
"highp usampler2D",
"u_sphericalHarmonicsTexture",
ShaderDestination.VERTEX,
);
uniformMap.u_sphericalHarmonicsTexture = function () {
return primitive.sphericalHarmonicsTexture;
};
}
uniformMap.u_sphericalHarmonicsDegree = function () {
return primitive._sphericalHarmonicsDegree;
};
uniformMap.u_cameraPositionWC = function () {
return Cartesian3.clone(frameState.camera.positionWC);
};
uniformMap.u_inverseModelRotation = function () {
// SH coefficients are encoded in the GLB Y-up training space. To evaluate
// them the world-space view direction must be rotated by
// inverse(computedTransform × axisCorrectionMatrix × worldTransform).
// This matrix is pre-computed each snapshot rebuild and stored on the
// primitive so the uniform closure just returns the cached value.
return primitive._shInverseRotation;
};
uniformMap.u_splitDirection = function () {
return primitive.splitDirection;
};
const instanceCount = defined(primitive._indexes)
? primitive._indexes.length
: primitive._numSplats;
renderResources.instanceCount = instanceCount;
renderResources.count = 4;
renderResources.primitiveType = PrimitiveType.TRIANGLE_STRIP;
shaderBuilder.addVertexLines(GaussianSplatVS);
shaderBuilder.addFragmentLines(GaussianSplatFS);
const shaderProgram = shaderBuilder.buildShaderProgram(frameState.context);
let renderState = clone(
RenderState.fromCache(renderResources.renderStateOptions),
true,
);
renderState.cull.face = ModelUtility.getCullFace(
tileset.modelMatrix,
PrimitiveType.TRIANGLE_STRIP,
);
renderState = RenderState.fromCache(renderState);
const splatQuadAttrLocations = {
screenQuadPosition: 0,
splatIndex: 2,
};
const idxAttr = new ModelComponents.Attribute();
idxAttr.name = "_SPLAT_INDEXES";
idxAttr.typedArray = primitive._indexes;
idxAttr.componentDatatype = ComponentDatatype.UNSIGNED_INT;
idxAttr.type = AttributeType.SCALAR;
idxAttr.normalized = false;
idxAttr.count = renderResources.instanceCount;
idxAttr.constant = 0;
idxAttr.instanceDivisor = 1;
const needsRebuild =
!defined(primitive._vertexArray) ||
primitive._indexes.length > primitive._vertexArrayLen;
if (needsRebuild) {
const geometry = new Geometry({
attributes: {
screenQuadPosition: new GeometryAttribute({
componentDatatype: ComponentDatatype.FLOAT,
componentsPerAttribute: 2,
values: [-1, -1, 1, -1, 1, 1, -1, 1],
name: "_SCREEN_QUAD_POS",
variableName: "screenQuadPosition",
}),
splatIndex: { ...idxAttr, variableName: "splatIndex" },
},
primitiveType: PrimitiveType.TRIANGLE_STRIP,
});
primitive._vertexArray = VertexArray.fromGeometry({
context: frameState.context,
geometry: geometry,
attributeLocations: splatQuadAttrLocations,
bufferUsage: BufferUsage.DYNAMIC_DRAW,
interleave: false,
});
} else {
primitive._vertexArray
.getAttribute(1)
.vertexBuffer.copyFromArrayView(primitive._indexes);
}
primitive._vertexArrayLen = primitive._indexes.length;
// The draw command uses rootTransform as its modelMatrix. tileset.modelMatrix
// is baked into the splat positions by transformTile and must not appear here
// as well. This keeps czm_view * modelMatrix numerically small (ENU frame),
// avoiding float32 precision loss from ECEF-scale translations.
const modelMatrix = Matrix4.clone(
primitive._rootTransform,
primitive._drawCommandModelMatrix,
);
const vertexArrayCache = primitive._vertexArray;
const command = new DrawCommand({
boundingVolume: tileset.boundingSphere,
modelMatrix: modelMatrix,
uniformMap: uniformMap,
renderState: renderState,
vertexArray: vertexArrayCache,
shaderProgram: shaderProgram,
cull: renderStateOptions.cull.enabled,
pass: Pass.GAUSSIAN_SPLATS,
count: renderResources.count,
owner: primitive,
instanceCount: renderResources.instanceCount,
primitiveType: PrimitiveType.TRIANGLE_STRIP,
debugShowBoundingVolume: tileset.debugShowBoundingVolume,
castShadows: false,
receiveShadows: false,
});
primitive._drawCommand = command;
};
/**
* Updates the Gaussian splat primitive for the current frame.
* This method checks if the primitive needs to be updated based on the current frame state,
* and if so, it processes the selected tiles, aggregates their attributes,
* and generates the Gaussian splat texture if necessary.
* It also handles the sorting of splat indexes and builds the draw command for rendering.
*
* @param {FrameState} frameState
* @private
*/
GaussianSplatPrimitive.prototype.update = function (frameState) {
const tileset = this._tileset;
releaseRetiredTextures(this, frameState.frameNumber);
if (!tileset.show) {
return;
}
if (this._drawCommand) {
frameState.commandList.push(this._drawCommand);
}
if (tileset._modelMatrixChanged) {
this._dirty = true;
return;
}
const hasRootTransform = defined(this._rootTransform);
if (frameState.passes.pick === true) {
return;
}
if (this.splitDirection !== tileset.splitDirection) {
this.splitDirection = tileset.splitDirection;
}
const camera = frameState.camera;
if (!defined(camera)) {
return;
}
if (this._sorterState === GaussianSplatSortingState.IDLE) {
const selectedTilesChanged =
tileset._selectedTiles.length !== 0 &&
haveSelectedTilesChanged(this, tileset._selectedTiles);
if (tileset._selectedTiles.length === 0) {
this._selectedTilesStableFrames = 0;
this._needsSnapshotRebuild = false;
this._snapshotRebuildStallFrames = 0;
} else if (selectedTilesChanged) {
this._selectedTilesStableFrames = 0;
} else {
this._selectedTilesStableFrames++;
}
if (selectedTilesChanged || this._dirty) {
this._needsSnapshotRebuild = true;
}
const isStable = this._selectedTilesStableFrames >= DEFAULT_STABLE_FRAMES;
const isBootstrap =
!defined(this._snapshot) &&
!defined(this._pendingSnapshot) &&
!defined(this._drawCommand);
// This prevents an indefinite wait if selected tiles never settle completely.
// In practice, this is the upper bound on "wait-for-stability" before forcing
// a rebuild to avoid visible starvation.
if (this._needsSnapshotRebuild && tileset._selectedTiles.length !== 0) {
this._snapshotRebuildStallFrames++;
} else {
this._snapshotRebuildStallFrames = 0;
}
const allowRebuild =
isStable ||
isBootstrap ||
this._snapshotRebuildStallFrames >= DEFAULT_MAX_SNAPSHOT_STALL_FRAMES;
const hasPendingWork =
this._dirty ||
this._needsSnapshotRebuild ||
selectedTilesChanged ||
defined(this._pendingSnapshot) ||
defined(this._pendingSortPromise) ||
!defined(this._drawCommand);
if (
!hasPendingWork &&
Matrix4.equals(camera.viewMatrix, this._prevViewMatrix)
) {
return;
}
if (
tileset._selectedTiles.length !== 0 &&
this._needsSnapshotRebuild &&
allowRebuild
) {
this._splatDataGeneration++;
this._activeSort = undefined;
this._sorterPromise = undefined;
this._sorterState = GaussianSplatSortingState.IDLE;
this._pendingSortPromise = undefined;
this._pendingSort = undefined;
if (defined(this._pendingSnapshot)) {
destroySnapshotTextures(this._pendingSnapshot);
}
const tiles = tileset._selectedTiles;
// Rebuild the ENU origin from the current tileset world center so that
// baked splat positions remain in a numerically small (meter-scale) local
// frame, regardless of the current tileset.modelMatrix value.
this._rootTransform = Transforms.eastNorthUpToFixedFrame(
tileset.boundingSphere.center,
);
// Compute the SH inverse rotation from the first tile's coordinate frame.
// SH coefficients are encoded in the GLB Y-up training space. To evaluate
// them correctly the view direction must be transformed by
// inverse(computedTransform × axisCorrectionMatrix × worldTransform).
// All tiles in a typical GS tileset share the same root coordinate frame,
// so using the first tile is sufficient.
{
const ft = tiles[0];
const shFwd = Matrix4.multiplyTransformation(
ft.computedTransform,
this._axisCorrectionMatrix,
scratchMatrix4C,
);
Matrix4.multiplyTransformation(
shFwd,
ft.content.worldTransform ?? Matrix4.IDENTITY,
shFwd,
);
Matrix4.getRotation(
Matrix4.inverse(shFwd, shFwd),
this._shInverseRotation,
);
}
for (const tile of tiles) {
GaussianSplatPrimitive.transformTile(tile);
}
const totalElements = tiles.reduce(
(total, tile) => total + tile.content.pointsLength,
0,
);
const aggregateAttributeValues = (
key,
componentDatatype,
getAttributeCallback,
numberOfComponents,
) => {
let aggregate;
let offset = 0;
let requiredLength = 0;
for (const tile of tiles) {
const attribute = getAttributeCallback(tile.content);
const componentsPerAttribute = defined(numberOfComponents)
? numberOfComponents
: AttributeType.getNumberOfComponents(attribute.type);
const buffer = defined(attribute.typedArray)
? attribute.typedArray
: attribute;
requiredLength += buffer.length;
if (!defined(aggregate)) {
aggregate = acquireAggregateScratchBuffer(
this,
key,
componentDatatype,
totalElements * componentsPerAttribute,
);
}
}
if (!defined(aggregate)) {
return ComponentDatatype.createTypedArray(componentDatatype, 0);
}
for (const tile of tiles) {
const content = tile.content;
const attribute = getAttributeCallback(content);
const buffer = defined(attribute.typedArray)
? attribute.typedArray
: attribute;
aggregate.set(buffer, offset);
offset += buffer.length;
}
return trimAggregateScratchBuffer(aggregate, requiredLength);
};
const aggregateShData = () => {
// Determine the SH degree from the first tile with SH data so we can
// pre-allocate the aggregate buffer once, outside the tile loop.
let coefs = 0;
for (const tile of tiles) {
if (tile.content.sphericalHarmonicsDegree > 0) {
switch (tile.content.sphericalHarmonicsDegree) {
case 1:
coefs = 9;
break;
case 2:
coefs = 24;
break;
case 3:
coefs = 45;
break;
}
break;
}
}
if (coefs === 0) {
return undefined;
}
const requiredLength = totalElements * (coefs * (2 / 3));
// Re-use the class-level scratch buffer when it is already large
// enough, avoiding a fresh allocation (and eventual GC) every frame.
if (
!defined(this._scratchAggregateShBuffer) ||
this._scratchAggregateShBuffer.length < requiredLength
) {
this._scratchAggregateShBuffer = new Uint32Array(requiredLength);
}
const aggregate = this._scratchAggregateShBuffer;
let offset = 0;
for (const tile of tiles) {
const tileShData = tile.content.packedSphericalHarmonicsData;
if (tile.content.sphericalHarmonicsDegree > 0) {
aggregate.set(tileShData, offset);
offset += tileShData.length;
}
}
// Return a correctly-sized view so downstream consumers see the
// exact element count they expect.
if (offset < aggregate.length) {
return aggregate.subarray(0, offset);
}
return aggregate;
};
const positions = aggregateAttributeValues(
"positions",
ComponentDatatype.FLOAT,
(content) => content.positions,
3,
);
const scales = aggregateAttributeValues(
"scales",
ComponentDatatype.FLOAT,
(content) => content.scales,
3,
);
const rotations = aggregateAttributeValues(
"rotations",
ComponentDatatype.FLOAT,
(content) => content.rotations,
4,
);
const colors = aggregateAttributeValues(
"colors",
ComponentDatatype.UNSIGNED_BYTE,
(content) =>
ModelUtility.getAttributeBySemantic(
content.gltfPrimitive,
VertexAttributeSemantic.COLOR,
),
);
const sphericalHarmonicsDegree =
tiles[0].content.sphericalHarmonicsDegree;
const shCoefficientCount =
sphericalHarmonicsDegree > 0
? tiles[0].content.sphericalHarmonicsCoefficientCount
: 0;
const shData = aggregateShData();
this._pendingSnapshot = {
generation: this._splatDataGeneration,
positions: positions,
rotations: rotations,
scales: scales,
colors: colors,
shData: shData,
sphericalHarmonicsDegree: sphericalHarmonicsDegree,
shCoefficientCount: shCoefficientCount,
numSplats: totalElements,
indexes: undefined,
gaussianSplatTexture: undefined,
sphericalHarmonicsTexture: undefined,
lastTextureWidth: 0,
lastTextureHeight: 0,
splatRowMask: 0, // set by processGeneratedSplatTextureData
splatRowShift: 0, // set by processGeneratedSplatTextureData
state: SnapshotState.BUILDING,
};
this.selectedTileLength = tileset._selectedTiles.length;
this._selectedTileSet = new Set(tileset._selectedTiles);
this._dirty = false;
this._needsSnapshotRebuild = false;
this._snapshotRebuildStallFrames = 0;
}
if (defined(this._pendingSnapshot)) {
const pending = this._pendingSnapshot;
if (pending.state === SnapshotState.BUILDING) {
GaussianSplatPrimitive.generateSplatTexture(this, frameState, pending);
return;
}
if (pending.state === SnapshotState.TEXTURE_PENDING) {
return;
}
if (
pending.state === SnapshotState.TEXTURE_READY &&
!defined(pending.gaussianSplatTexture)
) {
return;
}
if (!hasRootTransform) {
return;
}
Matrix4.clone(camera.viewMatrix, this._prevViewMatrix);
Matrix4.multiply(camera.viewMatrix, this._rootTransform, scratchMatrix4A);
if (
pending.state === SnapshotState.TEXTURE_READY &&
!defined(this._pendingSortPromise)
) {
const requestId = ++this._sortRequestId;
const dataGeneration = this._splatDataGeneration;
this._pendingSort = {
requestId: requestId,
dataGeneration: dataGeneration,
expectedCount: pending.numSplats,
snapshot: pending,
};
const sortPromise = GaussianSplatSorter.radixSortIndexes({
primitive: {
positions: new Float32Array(pending.positions),
modelView: Float32Array.from(scratchMatrix4A),
count: pending.numSplats,
},
sortType: "Index",
});
if (!defined(sortPromise)) {
this._pendingSortPromise = undefined;
this._pendingSort = undefined;
pending.state = SnapshotState.TEXTURE_READY;
return;
}
this._pendingSortPromise = sortPromise;
pending.state = SnapshotState.SORTING;
const pendingSort = this._pendingSort;
void resolvePendingSnapshotSort(
this,
frameState,
pendingSort,
sortPromise,
);
return;
}
if (!defined(this._pendingSortPromise)) {
if (pending.state === SnapshotState.SORTING) {
pending.state = SnapshotState.TEXTURE_READY;
}
return;
}
return;
}
if (this._numSplats === 0) {
return;
}
if (!hasRootTransform) {
return;
}
Matrix4.clone(camera.viewMatrix, this._prevViewMatrix);
Matrix4.multiply(camera.viewMatrix, this._rootTransform, scratchMatrix4A);
if (!defined(this._sorterPromise)) {
if (!shouldStartSteadySort(this, frameState)) {
return;
}
const requestId = ++this._sortRequestId;
const dataGeneration = this._splatDataGeneration;
const expectedCount = this._numSplats;
this._activeSort = {
requestId: requestId,
dataGeneration: dataGeneration,
expectedCount: expectedCount,
};
const rawPromise = GaussianSplatSorter.radixSortIndexes({
primitive: {
positions: new Float32Array(this._positions),
modelView: Float32Array.from(scratchMatrix4A),
count: this._numSplats,
},
sortType: "Index",
});
this._sorterPromise = rawPromise;
if (defined(rawPromise)) {
markSteadySortStart(this, frameState);
const activeSort = this._activeSort;
this._sorterState = GaussianSplatSortingState.SORTING;
void resolveSteadySort(this, activeSort, rawPromise);
return;
}
}
if (!defined(this._sorterPromise)) {
this._sorterState = GaussianSplatSortingState.WAITING;
return;
}
this._sorterState = GaussianSplatSortingState.SORTING;
return;
} else if (this._sorterState === GaussianSplatSortingState.WAITING) {
if (!defined(this._sorterPromise)) {
const requestId = ++this._sortRequestId;
const dataGeneration = this._splatDataGeneration;
const expectedCount = this._numSplats;
this._activeSort = {
requestId: requestId,
dataGeneration: dataGeneration,
expectedCount: expectedCount,
};
const rawPromise = GaussianSplatSorter.radixSortIndexes({
primitive: {
positions: new Float32Array(this._positions),
modelView: Float32Array.from(scratchMatrix4A),
count: this._numSplats,
},
sortType: "Index",
});
this._sorterPromise = rawPromise;
if (defined(rawPromise)) {
markSteadySortStart(this, frameState);
const activeSort = this._activeSort;
this._sorterState = GaussianSplatSortingState.SORTING;
void resolveSteadySort(this, activeSort, rawPromise);
return;
}
}
if (!defined(this._sorterPromise)) {
this._sorterState = GaussianSplatSortingState.WAITING;
return;
}
this._sorterState = GaussianSplatSortingState.SORTING;
return;
} else if (this._sorterState === GaussianSplatSortingState.SORTING) {
return; //still sorting, wait for next frame
} else if (this._sorterState === GaussianSplatSortingState.SORTED) {
//update the draw command if sorted
GaussianSplatPrimitive.buildGSplatDrawCommand(this, frameState);
this._sorterState = GaussianSplatSortingState.IDLE; //reset state for next frame
this._dirty = false;
this._sorterPromise = undefined; //reset promise for next frame
this._activeSort = undefined;
} else if (this._sorterState === GaussianSplatSortingState.ERROR) {
throw this._sorterError;
}
this._dirty = false;
};
export default GaussianSplatPrimitive;