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 must 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} promise Promise that resolves to packed splat texture data. * @returns {Promise} * @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} sortPromise Promise that resolves to sorted indexes. * @returns {Promise} * @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} sortPromise Promise that resolves to sorted indexes. * @returns {Promise} * @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;