}
* @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.
*
* 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.
*
* @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.
*
* If this object was destroyed, it should not be used; calling any function other than
* isDestroyed 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.
*
* 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;