@cesium/engine
Version:
CesiumJS is a JavaScript library for creating 3D globes and 2D maps in a web browser without a plugin.
1,431 lines (1,330 loc) • 73.2 kB
JavaScript
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,