playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
1,020 lines • 61.3 kB
JavaScript
import { Compute } from '../../platform/graphics/compute.js';
import { Shader } from '../../platform/graphics/shader.js';
import { StorageBuffer } from '../../platform/graphics/storage-buffer.js';
import { BindGroupFormat, BindStorageBufferFormat, BindUniformBufferFormat } from '../../platform/graphics/bind-group-format.js';
import { UniformBufferFormat, UniformFormat } from '../../platform/graphics/uniform-buffer-format.js';
import { BUFFERUSAGE_COPY_DST, BUFFERUSAGE_COPY_SRC, SHADERSTAGE_COMPUTE, SHADERLANGUAGE_WGSL, BUFFERUSAGE_INDIRECT, UNIFORMTYPE_UINT, UNIFORMTYPE_FLOAT, UNIFORMTYPE_MAT4, PIXELFORMAT_RGBA16U } from '../../platform/graphics/constants.js';
import { GSPLAT_FORWARD, PROJECTION_ORTHOGRAPHIC, FOG_NONE, GSPLAT_DEBUG_HEATMAP } from '../constants.js';
import { Debug } from '../../core/debug.js';
import { Color } from '../../core/math/color.js';
import { Mat4 } from '../../core/math/mat4.js';
import { GSplatRenderer } from './gsplat-renderer.js';
import { FramePassGSplatComputeLocal } from './frame-pass-gsplat-compute-local.js';
import { computeGsplatLocalDispatchPrepSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-dispatch-prep.js';
import { computeGsplatLocalDispatchPrepLargeSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-dispatch-prep-large.js';
import { computeGsplatLocalTileCountSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-tile-count.js';
import { computeGsplatLocalTileCountLargeSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-tile-count-large.js';
import { computeGsplatLocalPlaceEntriesSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-place-entries.js';
import { computeGsplatLocalPlaceEntriesLargeSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-place-entries-large.js';
import { computeGsplatLocalTileSortSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-tile-sort.js';
import { computeGsplatLocalClassifySource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-classify.js';
import { computeGsplatLocalBucketSortSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-bucket-sort.js';
import { computeGsplatLocalChunkSortSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-chunk-sort.js';
import { computeGsplatLocalCopySource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-copy.js';
import { computeGsplatLocalBitonicSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-local-bitonic.js';
import { computeGsplatCommonSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-common.js';
import { computeGsplatTileIntersectSource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-tile-intersect.js';
import { GSplatTileComposite } from './gsplat-tile-composite.js';
import { GSplatLocalDispatchSet } from './gsplat-local-dispatch-set.js';
import computeSplatSource from '../shader-lib/wgsl/chunks/gsplat/vert/gsplatComputeSplat.js';
/**
* @import { GraphNode } from '../graph-node.js'
* @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js'
* @import { Layer } from '../layer.js'
* @import { GSplatWorkBuffer } from './gsplat-work-buffer.js'
* @import { GSplatFormat } from '../gsplat/gsplat-format.js'
* @import { Texture } from '../../platform/graphics/texture.js'
* @import { MeshInstance } from '../mesh-instance.js'
*/ // ---- Tunable knobs (memory vs. quality / robustness trade-offs) ----
// Floor for _tileEntryMultiplier (minimum tile entries per splat). Controls
// tile-entry buffer capacity on the first few frames before GPU readback has
// converged. Raising reduces cold-start tile clamping (missing tiles on scene
// load / teleport) at a flat cost of numSplats * (value) * 8 bytes.
const INITIAL_TILE_ENTRY_MULTIPLIER = 2.5;
// Headroom factor applied to measured entry demand. Cushions steady-state
// frame-to-frame spikes (camera motion, splats crossing tile boundaries) so
// they don't exceed capacity and cause clamping.
const ENTRY_HEADROOM_MULTIPLIER = 2.0;
// Consecutive low-usage readbacks before the multiplier is allowed to shrink.
const SHRINK_THRESHOLD = 200;
// Initial capacity (in splat IDs) of the large-splat buffer, which holds IDs of
// splats whose screen AABB covers more than 64 tiles and are deferred to the
// cooperative tile-count / place-entries passes. Buffer is grow-only: it expands
// on demand via readback but never shrinks, so demand exceeding this initial size
// causes large splats to be dropped for the first few frames (missing coverage on
// close-up views) until readback catches up. Fixed cost is (value) * 4 bytes.
const INITIAL_LARGE_SPLAT_CAPACITY = 16384;
// ---- Algorithmic invariants (must match shader code, do not change casually) ----
const TILE_SIZE = 16;
const MAX_TILES = 65535; // tile index must fit in 16 bits for pair packing (tileIdx << 16 | localOffset)
const CACHE_STRIDE = 7;
const MAX_CHUNKS_PER_TILE = 8;
// ---- Module-scope scratch (reusable, never exported) ----
const _viewProjMat = new Mat4();
const _viewProjData = new Float32Array(16);
const _viewData = new Float32Array(16);
const _fogColorLinear = new Color();
const _fogColorArray = new Float32Array(3);
/**
* Renders splats using a tiled compute pipeline with per-tile binning and local sorting.
* Receives a compacted splat ID list from {@link GSplatIntervalCompaction}, projects each
* splat into a projection cache, bins them into screen-space tiles via a fused
* count+pair-write pass, classifies tiles by size, sorts each tile by depth (with bucket
* pre-sort for large tiles), then rasterizes front-to-back. Pipeline:
*
* 1. Tile count: project each visible splat, write projection cache (screen pos, conic,
* color, depth). Iterate overlapping tiles twice: first to count intersections and
* build a 6x5 bitmask, then after a workgroup prefix sum + single global atomicAdd,
* to perform capped atomicAdd on per-tile counters and write (tileIdx, localOffset)
* pairs into a contiguous pair buffer. Large splats (AABB > 64 tiles) are deferred
* to a cooperative pass — their IDs are appended to a largeSplatIds buffer.
* 1b. Large tile count: one workgroup (256 threads) per deferred large splat reads
* projCache, recomputes the AABB, and cooperatively iterates tiles. Same pair-buffer
* and tileSplatCounts writes as pass 1. Sets the high bit of splatPairCount to flag
* these splats for the cooperative place-entries pass.
* 2. Prefix sum: exclusive prefix sum over per-tile counts produces offsets + total.
* 3. Place entries: each thread reads its (tileIdx, localOffset) pairs from the pair
* buffer and writes its splat index into tileEntries at deterministic positions
* (prefix-summed offset + localOffset). No atomics, no projCache reads. Skips large
* splats (high bit of splatPairCount).
* 3b. Large place entries: cooperative pass — one workgroup (256 threads) per large
* splat, same dispatch as 1b. Reads pairs and writes tileEntries in parallel.
* 3.5. Classify: scan tiles, build small/large/rasterize tile lists, assign compact
* overflow scratch offsets for large tiles, write indirect args.
* 4b. Bucket pre-sort: logarithmic-depth bucket histogram + scatter for large tiles
* (>4096 entries), using overflow scratch in the unified tileEntries buffer, packs
* whole buckets into <=4096 chunks (indirect dispatch).
* 4b.5. Copy chunk sort indirect dispatch args (separate pass for inter-pass barrier).
* 4a. Small tile sort: bitonic sort for tiles with 1..4096 entries (indirect dispatch).
* 4c. Chunk sort: bitonic sort on each chunk from bucket pre-sort (indirect dispatch).
* 5. Rasterize: one workgroup per non-empty tile reads its sorted entry range, loads
* from the projection cache via shared memory, and blends front-to-back with
* early-out (indirect dispatch).
*
* The tileEntries buffer is unified: main tile entry lists occupy [0, totalEntries),
* overflow scratch for bucket sort occupies [totalEntries, totalEntries + overflowUsed).
* Buffer capacity adapts dynamically via async GPU readback of actual usage.
*
* Supports both color and pick dispatch via two {@link GSplatLocalDispatchSet} instances.
* Splat/entry-dependent buffers (projCache, tileEntries) are shared between dispatch sets
* with a submitVersion guard to prevent resizing within the same command encoder.
*
* @ignore
*/ class GSplatComputeLocalRenderer extends GSplatRenderer {
/**
* Sets the data source for format and texture access, decoupling this renderer from
* the work buffer. The source object must provide:
*
* - `format` — a {@link GSplatFormat} describing the texture streams and shader read code.
* - `getTexture(name)` — a function returning a {@link Texture} for a given stream name.
*
* @param {object} source - The data source.
*/ setDataSource(source) {
this._dataSource = source;
}
destroy() {
this._unregisterFramePass();
if (this.renderMode) {
if (this.renderMode & GSPLAT_FORWARD) {
this.layer.removeMeshInstances([
this.tileComposite.meshInstance
], true);
}
}
this._mainSet.destroy();
this._pickSet?.destroy();
this._placeEntriesShader.destroy();
this._placeEntriesBindGroupFormat.destroy();
this._placeEntryPrepShader.destroy();
this._placeEntryPrepBindGroupFormat.destroy();
this._placeEntryPrepDispatchBuffer?.destroy();
this._largeSplatShader.destroy();
this._largeSplatBindGroupFormat.destroy();
this._largeSplatPrepShader.destroy();
this._largeSplatPrepBindGroupFormat.destroy();
this._largeSplatDispatchBuffer?.destroy();
this._largePlaceEntriesShader.destroy();
this._largePlaceEntriesBindGroupFormat.destroy();
this._classifyShader.destroy();
this._classifyBindGroupFormat.destroy();
this._sortShader.destroy();
this._sortBindGroupFormat.destroy();
this._bucketSortShader.destroy();
this._bucketSortBindGroupFormat.destroy();
this._copyShader.destroy();
this._copyBindGroupFormat.destroy();
this._chunkSortShader.destroy();
this._chunkSortBindGroupFormat.destroy();
this._projCacheBuffer?.destroy();
this._depthBuffer?.destroy();
this._tileEntriesBuffer?.destroy();
this._pairBuffer?.destroy();
this._countersBuffer?.destroy();
this._splatPairStartBuffer?.destroy();
this._splatPairCountBuffer?.destroy();
this._largeSplatIdsBuffer?.destroy();
this.tileComposite.destroy();
super.destroy();
}
get material() {
return this.tileComposite.material;
}
setRenderMode(renderMode) {
const oldRenderMode = this.renderMode ?? 0;
const wasForward = (oldRenderMode & GSPLAT_FORWARD) !== 0;
const isForward = (renderMode & GSPLAT_FORWARD) !== 0;
if (!wasForward && isForward) {
this.layer.addMeshInstances([
this.tileComposite.meshInstance
], true);
this._registerFramePass();
}
if (wasForward && !isForward) {
this.layer.removeMeshInstances([
this.tileComposite.meshInstance
], true);
this._unregisterFramePass();
}
super.setRenderMode(renderMode);
}
frameUpdate(gsplat, exposure, fogParams) {
if (this._needsFramePassRegister) {
this._registerFramePass();
}
this._minPixelSize = gsplat.minPixelSize;
this._minContribution = gsplat.minContribution;
this._alphaClip = gsplat.alphaClip;
this._exposure = exposure ?? 1.0;
this._fisheye = gsplat.fisheye;
this._fogParams = fogParams ?? null;
this._debugMode = gsplat.debug;
const formatHash = this._dataSource.format.hash;
if (formatHash !== this._formatHash) {
this._formatHash = formatHash;
this._invalidateCountCompute();
}
}
/**
* @param {StorageBuffer} compactedSplatIds - Dense buffer of visible splat IDs.
* @param {StorageBuffer} sortElementCountBuffer - Single-u32 buffer with visible count.
* @param {number} textureSize - The work buffer texture size.
* @param {number} numSplats - Upper bound on visible splats.
*/ setCompactedData(compactedSplatIds, sortElementCountBuffer, textureSize, numSplats) {
this._compactedSplatIds = compactedSplatIds;
this._sortElementCountBuffer = sortElementCountBuffer;
this._textureSize = textureSize;
this._numSplats = numSplats;
}
/** @private */ _registerFramePass() {
const camera = this.cameraNode.camera?.camera;
if (camera) {
// Schedule the compute splat pass before this camera's scene rendering.
if (!camera.beforePasses.includes(this.framePass)) {
camera.beforePasses.push(this.framePass);
}
this._needsFramePassRegister = false;
} else {
this._needsFramePassRegister = true;
}
}
/** @private */ _unregisterFramePass() {
this._needsFramePassRegister = false;
const camera = this.cameraNode.camera?.camera;
if (camera) {
const idx = camera.beforePasses.indexOf(this.framePass);
if (idx !== -1) {
camera.beforePasses.splice(idx, 1);
}
}
}
resizeOutputTexture(width, height) {
this._mainSet.resizeOutputTextures(width, height);
}
/**
* Ensure shared splat/entry buffers are large enough. Only resizes when
* device.submitVersion has changed (preventing mid-encoder buffer destruction).
*
* @param {number} numSplats - Upper bound on visible splats.
* @private
*/ _ensureSharedBuffers(numSplats) {
const device = this.device;
const canResize = device.submitVersion !== this._lastBufferSubmitVersion;
// Consume readback for shrink logic
const readbackValue = this._lastReadbackEntryCount;
if (readbackValue !== -1) {
this._lastReadbackEntryCount = -1;
const currentCapacity = this._allocatedEntryCapacity;
if (currentCapacity > 0 && readbackValue < currentCapacity / 2) {
this._shrinkFrameCount++;
} else {
this._shrinkFrameCount = 0;
}
if (this._shrinkFrameCount >= SHRINK_THRESHOLD && numSplats > 0) {
const target = Math.max(INITIAL_TILE_ENTRY_MULTIPLIER, readbackValue / numSplats * ENTRY_HEADROOM_MULTIPLIER);
this._tileEntryMultiplier = Math.max(target, this._tileEntryMultiplier * 0.9);
this._shrinkFrameCount = 0;
}
}
if (!canResize) return;
// Splat capacity (projCache + depthBuffer + per-splat pair metadata)
if (numSplats > this._allocatedSplatCapacity) {
this._projCacheBuffer?.destroy();
this._depthBuffer?.destroy();
this._splatPairStartBuffer?.destroy();
this._splatPairCountBuffer?.destroy();
this._allocatedSplatCapacity = numSplats;
this._projCacheBuffer = new StorageBuffer(device, numSplats * CACHE_STRIDE * 4);
this._depthBuffer = new StorageBuffer(device, numSplats * 4);
this._splatPairStartBuffer = new StorageBuffer(device, numSplats * 4);
this._splatPairCountBuffer = new StorageBuffer(device, numSplats * 4);
}
// Packed atomic counters: [0] = global pair counter, [1] = large splat count.
if (!this._countersBuffer) {
this._countersBuffer = new StorageBuffer(device, 8, BUFFERUSAGE_COPY_DST | BUFFERUSAGE_COPY_SRC);
}
// Large-splat ID buffer (grow-only)
if (this._largeSplatIdsCapacity > this._allocatedLargeSplatCapacity) {
this._largeSplatIdsBuffer?.destroy();
this._allocatedLargeSplatCapacity = this._largeSplatIdsCapacity;
this._largeSplatIdsBuffer = new StorageBuffer(device, this._largeSplatIdsCapacity * 4);
}
// Entry capacity (tileEntries + pairBuffer — both sized identically)
const requiredEntryCapacity = Math.ceil(numSplats * this._tileEntryMultiplier);
const needsGrow = requiredEntryCapacity > this._allocatedEntryCapacity;
const needsShrink = this._allocatedEntryCapacity > 0 && requiredEntryCapacity * 2 < this._allocatedEntryCapacity;
if (needsGrow || needsShrink) {
this._tileEntriesBuffer?.destroy();
this._pairBuffer?.destroy();
this._allocatedEntryCapacity = requiredEntryCapacity;
this._tileEntriesBuffer = new StorageBuffer(device, requiredEntryCapacity * 4, BUFFERUSAGE_COPY_DST);
this._pairBuffer = new StorageBuffer(device, requiredEntryCapacity * 4);
}
this._lastBufferSubmitVersion = device.submitVersion;
}
/**
* Main color dispatch.
*/ dispatch() {
const set = this._mainSet;
const outputTex = set.outputTexture;
if (!outputTex) return;
const width = outputTex.width;
const height = outputTex.height;
this._dispatchPipeline(set, width, height, false);
this.tileComposite.update(this._lastDrawSlot, outputTex, set._rasterizeTileListBuffer, this._lastNumTilesX, width, height);
}
/**
* Pick dispatch: runs the compute pick pipeline and returns the configured pick mesh instance.
*
* @param {object} cam - The camera.
* @param {number} width - Pick target width.
* @param {number} height - Pick target height.
* @returns {MeshInstance|null} The pick mesh instance ready for the picker's render list.
*/ dispatchPick(cam, width, height) {
if (!this._pickSet) {
this._pickSet = this._createDispatchSet(true);
}
const set = this._pickSet;
set.resizeOutputTextures(width, height);
this._dispatchPipeline(set, width, height, true);
return this.tileComposite.prepareForPicking(this._lastDrawSlot, set.pickIdTexture, set.pickDepthTexture, set._rasterizeTileListBuffer, this._lastNumTilesX, width, height);
}
/**
* Unified dispatch pipeline used by both color and pick paths.
*
* @param {GSplatLocalDispatchSet} set - The dispatch set to use.
* @param {number} width - Render target width.
* @param {number} height - Render target height.
* @param {boolean} pickMode - Whether this is a pick dispatch.
* @private
*/ _dispatchPipeline(set, width, height, pickMode) {
const numSplats = this._numSplats;
if (!this._compactedSplatIds || !this._sortElementCountBuffer || numSplats === 0) return;
const device = this.device;
let numTilesX = Math.ceil(width / TILE_SIZE);
let numTilesY = Math.ceil(height / TILE_SIZE);
if (numTilesX * numTilesY > MAX_TILES) {
Debug.warnOnce('GSplatComputeLocalRenderer: render target exceeds maximum supported tile count (65535). Tile coverage will be clamped, causing rendering artifacts at screen edges.');
const scale = Math.sqrt(MAX_TILES / (numTilesX * numTilesY));
numTilesX = Math.max(1, Math.floor(numTilesX * scale));
numTilesY = Math.max(1, Math.floor(numTilesY * scale));
}
const numTiles = numTilesX * numTilesY;
this._ensureSharedBuffers(numSplats);
set.ensureTileBuffers(numTiles);
const maxEntries = this._allocatedEntryCapacity;
const ds = this._dataSource;
const camera = this.cameraNode.camera;
const cam = camera.camera;
const view = cam.viewMatrix;
const proj = cam.projectionMatrix;
_viewProjMat.mul2(proj, view);
_viewProjData.set(_viewProjMat.data);
_viewData.set(view.data);
const focal = width * proj.data[0];
const alphaClip = pickMode ? this._alphaClip : 1.0 / 255.0;
// Ensure fisheyeProj is up-to-date (culling may not have run this frame)
this.fisheyeProj.update(this._fisheye, camera.fov, proj);
const fisheyeEnabled = this.fisheyeProj.enabled;
const createCountShader = (pick, fisheye)=>this._createCountShaderAndFormat(pick, fisheye);
const countCompute = set.getCountCompute(fisheyeEnabled, createCountShader);
// Prep dispatch: compute indirect dispatch dimensions from the visible count.
// Writes two dispatch slots: slot 0 for tile-count, slot 1 for place-entries
// (both 1 splat/thread, 256-wide workgroups).
this._placeEntryPrepCompute.setParameter('sortElementCount', this._sortElementCountBuffer);
this._placeEntryPrepCompute.setParameter('dispatchArgs', this._placeEntryPrepDispatchBuffer);
this._placeEntryPrepCompute.setupDispatch(1, 1, 1);
device.computeDispatch([
this._placeEntryPrepCompute
], 'GSplatLocalDispatchPrep');
// --- Pass 1: Per-tile count + projection cache + pair buffer writes ---
set._tileSplatCountsBuffer.clear();
this._countersBuffer.clear();
countCompute.setParameter('compactedSplatIds', this._compactedSplatIds);
countCompute.setParameter('sortElementCount', this._sortElementCountBuffer);
countCompute.setParameter('projCache', this._projCacheBuffer);
countCompute.setParameter('tileSplatCounts', set._tileSplatCountsBuffer);
countCompute.setParameter('pairBuffer', this._pairBuffer);
countCompute.setParameter('countersBuffer', this._countersBuffer);
countCompute.setParameter('splatPairStart', this._splatPairStartBuffer);
countCompute.setParameter('splatPairCount', this._splatPairCountBuffer);
countCompute.setParameter('largeSplatIds', this._largeSplatIdsBuffer);
countCompute.setParameter('depthBuffer', this._depthBuffer);
for (const stream of ds.format.streams){
countCompute.setParameter(stream.name, ds.getTexture(stream.name));
}
for (const stream of ds.format.extraStreams){
countCompute.setParameter(stream.name, ds.getTexture(stream.name));
}
countCompute.setParameter('splatTextureSize', this._textureSize);
countCompute.setParameter('numTilesX', numTilesX);
countCompute.setParameter('numTilesY', numTilesY);
countCompute.setParameter('viewProj', _viewProjData);
countCompute.setParameter('viewMatrix', _viewData);
countCompute.setParameter('focal', focal);
countCompute.setParameter('viewportWidth', width);
countCompute.setParameter('viewportHeight', height);
countCompute.setParameter('nearClip', cam.nearClip);
countCompute.setParameter('farClip', cam.farClip);
countCompute.setParameter('minPixelSize', this._minPixelSize * 0.5);
countCompute.setParameter('isOrtho', cam.projection === PROJECTION_ORTHOGRAPHIC ? 1 : 0);
countCompute.setParameter('exposure', this._exposure);
countCompute.setParameter('alphaClip', alphaClip);
countCompute.setParameter('minContribution', this._minContribution);
if (fisheyeEnabled) {
const fp = this.fisheyeProj;
countCompute.setParameter('fisheye_k', fp.k);
countCompute.setParameter('fisheye_inv_k', fp.invK);
countCompute.setParameter('fisheye_projMat00', fp.projMat00);
countCompute.setParameter('fisheye_projMat11', fp.projMat11);
}
countCompute.setupIndirectDispatch(0, this._placeEntryPrepDispatchBuffer);
device.computeDispatch([
countCompute
], pickMode ? 'GSplatPickTileCount' : 'GSplatLocalTileCount');
// --- Pass 1b: Large-splat prep + cooperative tile count ---
// Compute indirect dispatch dimensions for the large-splat pass (one workgroup
// per large splat), then dispatch. Writes to the same tileSplatCounts, pairBuffer,
// and splatPairStart/Count as the main count pass.
this._largeSplatPrepCompute.setParameter('countersBuffer', this._countersBuffer);
this._largeSplatPrepCompute.setParameter('dispatchArgs', this._largeSplatDispatchBuffer);
this._largeSplatPrepCompute.setParameter('largeSplatIds', this._largeSplatIdsBuffer);
this._largeSplatPrepCompute.setupDispatch(1, 1, 1);
device.computeDispatch([
this._largeSplatPrepCompute
], pickMode ? 'GSplatPickLargeSplatPrep' : 'GSplatLocalLargeSplatPrep');
set.largeSplatCompute.setParameter('projCache', this._projCacheBuffer);
set.largeSplatCompute.setParameter('tileSplatCounts', set._tileSplatCountsBuffer);
set.largeSplatCompute.setParameter('pairBuffer', this._pairBuffer);
set.largeSplatCompute.setParameter('countersBuffer', this._countersBuffer);
set.largeSplatCompute.setParameter('splatPairStart', this._splatPairStartBuffer);
set.largeSplatCompute.setParameter('splatPairCount', this._splatPairCountBuffer);
set.largeSplatCompute.setParameter('largeSplatIds', this._largeSplatIdsBuffer);
set.largeSplatCompute.setParameter('numTilesX', numTilesX);
set.largeSplatCompute.setParameter('numTilesY', numTilesY);
set.largeSplatCompute.setParameter('viewportWidth', width);
set.largeSplatCompute.setParameter('viewportHeight', height);
set.largeSplatCompute.setParameter('alphaClip', alphaClip);
set.largeSplatCompute.setupIndirectDispatch(0, this._largeSplatDispatchBuffer);
device.computeDispatch([
set.largeSplatCompute
], pickMode ? 'GSplatPickLargeTileCount' : 'GSplatLocalLargeTileCount');
// --- Pass 2: Prefix sum ---
set.prefixSumKernel.resize(set._tileSplatCountsBuffer, numTiles + 1);
set.prefixSumKernel.dispatch(device);
// --- Pass 3: Place entries (scatter-free) ---
// Reads (tileIdx, localOffset) pairs written by the count pass and places splat
// indices into tileEntries at positions determined by the prefix-summed tile offsets.
// No atomics, no projCache reads, no intersection recomputation.
set.placeEntriesCompute.setParameter('pairBuffer', this._pairBuffer);
set.placeEntriesCompute.setParameter('splatPairStart', this._splatPairStartBuffer);
set.placeEntriesCompute.setParameter('splatPairCount', this._splatPairCountBuffer);
set.placeEntriesCompute.setParameter('tileSplatCounts', set._tileSplatCountsBuffer);
set.placeEntriesCompute.setParameter('tileEntries', this._tileEntriesBuffer);
set.placeEntriesCompute.setParameter('sortElementCount', this._sortElementCountBuffer);
// Slot 1 of the prep buffer holds the full (1 splat/thread) dispatch dimensions.
set.placeEntriesCompute.setupIndirectDispatch(1, this._placeEntryPrepDispatchBuffer);
device.computeDispatch([
set.placeEntriesCompute
], pickMode ? 'GSplatPickPlaceEntries' : 'GSplatLocalPlaceEntries');
// --- Pass 3b: Cooperative place entries for large splats ---
// Reuses the same indirect dispatch as LargeTileCount (one workgroup per large splat).
set.largePlaceEntriesCompute.setParameter('pairBuffer', this._pairBuffer);
set.largePlaceEntriesCompute.setParameter('splatPairStart', this._splatPairStartBuffer);
set.largePlaceEntriesCompute.setParameter('splatPairCount', this._splatPairCountBuffer);
set.largePlaceEntriesCompute.setParameter('tileSplatCounts', set._tileSplatCountsBuffer);
set.largePlaceEntriesCompute.setParameter('tileEntries', this._tileEntriesBuffer);
set.largePlaceEntriesCompute.setParameter('largeSplatIds', this._largeSplatIdsBuffer);
set.largePlaceEntriesCompute.setParameter('countersBuffer', this._countersBuffer);
set.largePlaceEntriesCompute.setupIndirectDispatch(0, this._largeSplatDispatchBuffer);
device.computeDispatch([
set.largePlaceEntriesCompute
], pickMode ? 'GSplatPickLargePlaceEntries' : 'GSplatLocalLargePlaceEntries');
// --- Pass 3.5: Classify ---
set._tileListCountsBuffer.clear();
set._totalChunksBuffer.clear();
set._chunkSortIndirectBuffer.clear();
const indirectSlot = device.getIndirectDispatchSlot(3);
const drawSlot = device.getIndirectDrawSlot(1);
set.classifyCompute.setParameter('tileSplatCounts', set._tileSplatCountsBuffer);
set.classifyCompute.setParameter('smallTileList', set._smallTileListBuffer);
set.classifyCompute.setParameter('largeTileList', set._largeTileListBuffer);
set.classifyCompute.setParameter('rasterizeTileList', set._rasterizeTileListBuffer);
set.classifyCompute.setParameter('tileListCounts', set._tileListCountsBuffer);
set.classifyCompute.setParameter('indirectDispatchArgs', device.indirectDispatchBuffer);
set.classifyCompute.setParameter('largeTileOverflowBases', set._largeTileOverflowBasesBuffer);
set.classifyCompute.setParameter('indirectDrawArgs', device.indirectDrawBuffer);
set.classifyCompute.setParameter('numTiles', numTiles);
set.classifyCompute.setParameter('dispatchSlotOffset', indirectSlot * 3);
set.classifyCompute.setParameter('bufferCapacity', maxEntries);
set.classifyCompute.setParameter('maxWorkgroupsPerDim', device.limits.maxComputeWorkgroupsPerDimension || 65535);
set.classifyCompute.setParameter('drawSlot', drawSlot);
set.classifyCompute.setupDispatch(1, 1, 1);
device.computeDispatch([
set.classifyCompute
], pickMode ? 'GSplatPickClassify' : 'GSplatLocalClassify');
// --- Pass 4b: Bucket pre-sort ---
set.bucketSortCompute.setParameter('tileEntries', this._tileEntriesBuffer);
set.bucketSortCompute.setParameter('largeTileOverflowBases', set._largeTileOverflowBasesBuffer);
set.bucketSortCompute.setParameter('tileSplatCounts', set._tileSplatCountsBuffer);
set.bucketSortCompute.setParameter('depthBuffer', this._depthBuffer);
set.bucketSortCompute.setParameter('largeTileList', set._largeTileListBuffer);
set.bucketSortCompute.setParameter('chunkRanges', set._chunkRangesBuffer);
set.bucketSortCompute.setParameter('totalChunks', set._totalChunksBuffer);
set.bucketSortCompute.setParameter('tileListCounts', set._tileListCountsBuffer);
set.bucketSortCompute.setParameter('bufferCapacity', maxEntries);
set.bucketSortCompute.setParameter('maxChunks', numTiles * MAX_CHUNKS_PER_TILE);
set.bucketSortCompute.setupIndirectDispatch(indirectSlot + 1);
device.computeDispatch([
set.bucketSortCompute
], pickMode ? 'GSplatPickBucketSort' : 'GSplatLocalBucketSort');
// --- Pass 4b.5: Copy chunk sort indirect ---
set.copyCompute.setParameter('totalChunks', set._totalChunksBuffer);
set.copyCompute.setParameter('chunkSortIndirect', set._chunkSortIndirectBuffer);
set.copyCompute.setParameter('maxChunks', numTiles * MAX_CHUNKS_PER_TILE);
set.copyCompute.setParameter('maxWorkgroupsPerDim', device.limits.maxComputeWorkgroupsPerDimension || 65535);
set.copyCompute.setupDispatch(1, 1, 1);
device.computeDispatch([
set.copyCompute
], pickMode ? 'GSplatPickCopy' : 'GSplatLocalCopy');
// --- Pass 4a: Small tile sort ---
set.sortCompute.setParameter('tileEntries', this._tileEntriesBuffer);
set.sortCompute.setParameter('tileSplatCounts', set._tileSplatCountsBuffer);
set.sortCompute.setParameter('depthBuffer', this._depthBuffer);
set.sortCompute.setParameter('smallTileList', set._smallTileListBuffer);
set.sortCompute.setParameter('tileListCounts', set._tileListCountsBuffer);
set.sortCompute.setupIndirectDispatch(indirectSlot);
device.computeDispatch([
set.sortCompute
], pickMode ? 'GSplatPickTileSort' : 'GSplatLocalTileSort');
// --- Pass 4c: Chunk sort ---
set.chunkSortCompute.setParameter('tileEntries', this._tileEntriesBuffer);
set.chunkSortCompute.setParameter('depthBuffer', this._depthBuffer);
set.chunkSortCompute.setParameter('chunkRanges', set._chunkRangesBuffer);
set.chunkSortCompute.setParameter('totalChunks', set._totalChunksBuffer);
set.chunkSortCompute.setParameter('maxChunks', numTiles * MAX_CHUNKS_PER_TILE);
set.chunkSortCompute.setupIndirectDispatch(0, set._chunkSortIndirectBuffer);
device.computeDispatch([
set.chunkSortCompute
], pickMode ? 'GSplatPickChunkSort' : 'GSplatLocalChunkSort');
// --- Pass 5: Rasterize ---
// Select the shader variant based on pick mode and depth availability. Depth testing
// against the scene linear depth texture is only used in color mode when the depth
// prepass has run (indicated by sceneDepthMapLinear) and the texture is bound.
const hasLinearDepth = cam.shaderParams.sceneDepthMapLinear;
const sceneDepthMap = hasLinearDepth ? device.scope.resolve('uSceneDepthMap').value : null;
const useDepth = !pickMode && sceneDepthMap;
const fogParams = this._fogParams;
const fogType = fogParams && fogParams.type !== FOG_NONE ? fogParams.type : 'none';
const heatmap = !pickMode && this._debugMode === GSPLAT_DEBUG_HEATMAP;
const rasterizeCompute = set.getRasterizeCompute(pickMode, useDepth, fogType, heatmap);
rasterizeCompute.setParameter('screenWidth', width);
rasterizeCompute.setParameter('screenHeight', height);
rasterizeCompute.setParameter('numTilesX', numTilesX);
rasterizeCompute.setParameter('nearClip', cam.nearClip);
rasterizeCompute.setParameter('farClip', cam.farClip);
rasterizeCompute.setParameter('alphaClip', alphaClip);
if (fogType !== 'none') {
_fogColorLinear.linear(fogParams.color);
_fogColorArray[0] = _fogColorLinear.r;
_fogColorArray[1] = _fogColorLinear.g;
_fogColorArray[2] = _fogColorLinear.b;
rasterizeCompute.setParameter('fog_color', _fogColorArray);
rasterizeCompute.setParameter('fog_start', fogParams.start);
rasterizeCompute.setParameter('fog_end', fogParams.end);
rasterizeCompute.setParameter('fog_density', fogParams.density);
}
rasterizeCompute.setParameter('tileEntries', this._tileEntriesBuffer);
rasterizeCompute.setParameter('tileSplatCounts', set._tileSplatCountsBuffer);
rasterizeCompute.setParameter('projCache', this._projCacheBuffer);
rasterizeCompute.setParameter('rasterizeTileList', set._rasterizeTileListBuffer);
rasterizeCompute.setParameter('tileListCounts', set._tileListCountsBuffer);
rasterizeCompute.setParameter('depthBuffer', this._depthBuffer);
if (pickMode) {
rasterizeCompute.setParameter('pickIdTexture', set.pickIdTexture);
rasterizeCompute.setParameter('pickDepthTexture', set.pickDepthTexture);
} else {
rasterizeCompute.setParameter('outputTexture', set.outputTexture);
if (useDepth) {
rasterizeCompute.setParameter('sceneDepthMap', sceneDepthMap);
}
}
rasterizeCompute.setupIndirectDispatch(indirectSlot + 2);
device.computeDispatch([
rasterizeCompute
], pickMode ? 'GSplatPickRasterize' : 'GSplatLocalRasterize');
this._lastDrawSlot = drawSlot;
this._lastNumTilesX = numTilesX;
this._scheduleReadback(numSplats, numTiles, set);
}
/**
* @param {number} numSplats - Splat count at dispatch time.
* @param {number} numTiles - Tile count at dispatch time.
* @param {GSplatLocalDispatchSet} set - The dispatch set to readback from.
* @private
*/ _scheduleReadback(numSplats, numTiles, set) {
if (numSplats === 0) return;
const capturedNumSplats = numSplats;
const readback1 = set._tileSplatCountsBuffer.read(numTiles * 4, 4);
const readback2 = set._tileListCountsBuffer.read(3 * 4, 4);
const readback3 = this._countersBuffer.read(4, 4);
Promise.all([
readback1,
readback2,
readback3
]).then(([r1, r2, r3])=>{
const totalEntries = new Uint32Array(r1.buffer, r1.byteOffset, 1)[0];
const totalOverflow = new Uint32Array(r2.buffer, r2.byteOffset, 1)[0];
const needed = totalEntries + totalOverflow;
if (capturedNumSplats > 0) {
this._tileEntryMultiplier = Math.max(this._tileEntryMultiplier, needed / capturedNumSplats * ENTRY_HEADROOM_MULTIPLIER);
this._tileEntryMultiplier = Math.max(this._tileEntryMultiplier, INITIAL_TILE_ENTRY_MULTIPLIER);
}
this._lastReadbackEntryCount = needed;
// Grow the large-splat ID buffer if demand exceeded capacity (grow-only)
const largeSplatDemand = new Uint32Array(r3.buffer, r3.byteOffset, 1)[0];
if (largeSplatDemand > this._largeSplatIdsCapacity) {
this._largeSplatIdsCapacity = Math.ceil(largeSplatDemand * 1.2);
}
}).catch(()=>{
// readback failed, ignore
});
}
/**
* Invalidates all cached count resources on both dispatch sets. Called when the
* work buffer format changes, making all compiled count shaders invalid.
*
* @private
*/ _invalidateCountCompute() {
this._mainSet.destroyCountResources();
this._pickSet?.destroyCountResources();
}
// ---- Shader / BindGroupFormat creation ----
/** @private */ _createCommonIncludes() {
const cincludes = new Map();
cincludes.set('gsplatCommonCS', computeGsplatCommonSource);
cincludes.set('gsplatTileIntersectCS', computeGsplatTileIntersectSource);
return cincludes;
}
/** @private */ _createBitonicIncludes() {
const cincludes = new Map();
cincludes.set('gsplatLocalBitonicCS', computeGsplatLocalBitonicSource);
return cincludes;
}
/**
* Creates all shared shaders and bind group formats (called once in constructor).
*
* @private
*/ _createSharedShaders() {
const device = this.device;
// Count shader/format are created lazily by each dispatch set's getCountCompute()
// --- PlaceEntries (replaces the old scatter pass) ---
this._placeEntriesBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('pairBuffer', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('splatPairStart', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('splatPairCount', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('tileSplatCounts', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('tileEntries', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('sortElementCount', SHADERSTAGE_COMPUTE, true)
]);
this._placeEntriesShader = new Shader(device, {
name: 'GSplatLocalPlaceEntries',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalPlaceEntriesSource,
computeBindGroupFormat: this._placeEntriesBindGroupFormat
});
// --- PlaceEntryPrep: tiny 1-thread shader that reads the visible count and writes
// dispatch args for place-entries into a local buffer. This avoids relying on the
// device-level indirect dispatch buffer written earlier in the frame. ---
{
const maxDim = device.limits.maxComputeWorkgroupsPerDimension || 65535;
const prepDefines = new Map();
prepDefines.set('{MAX_DIM}', maxDim.toString());
prepDefines.set('{SPLATS_PER_WG}', '256');
prepDefines.set('{SPLATS_PER_WG_MINUS_1}', '255');
this._placeEntryPrepBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('sortElementCount', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('dispatchArgs', SHADERSTAGE_COMPUTE)
]);
this._placeEntryPrepShader = new Shader(device, {
name: 'GSplatLocalPlaceEntryPrep',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalDispatchPrepSource,
cdefines: prepDefines,
computeBindGroupFormat: this._placeEntryPrepBindGroupFormat
});
this._placeEntryPrepDispatchBuffer = new StorageBuffer(device, 6 * 4, BUFFERUSAGE_INDIRECT);
this._placeEntryPrepCompute = new Compute(device, this._placeEntryPrepShader);
}
// --- Large-splat cooperative tile count ---
{
const cincludes = this._createCommonIncludes();
const ubf = new UniformBufferFormat(device, [
new UniformFormat('numTilesX', UNIFORMTYPE_UINT),
new UniformFormat('numTilesY', UNIFORMTYPE_UINT),
new UniformFormat('viewportWidth', UNIFORMTYPE_FLOAT),
new UniformFormat('viewportHeight', UNIFORMTYPE_FLOAT),
new UniformFormat('alphaClip', UNIFORMTYPE_FLOAT)
]);
this._largeSplatBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('projCache', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('tileSplatCounts', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('pairBuffer', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('countersBuffer', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('splatPairStart', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('splatPairCount', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('largeSplatIds', SHADERSTAGE_COMPUTE, true),
new BindUniformBufferFormat('uniforms', SHADERSTAGE_COMPUTE)
]);
this._largeSplatShader = new Shader(device, {
name: 'GSplatLocalTileCountLarge',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalTileCountLargeSource,
cincludes,
computeBindGroupFormat: this._largeSplatBindGroupFormat,
computeUniformBufferFormats: {
uniforms: ubf
}
});
}
// --- Large-splat prep: reads countersBuffer[1] (large splat count) and computes indirect dispatch args ---
{
const maxDim = device.limits.maxComputeWorkgroupsPerDimension || 65535;
const largePrepDefines = new Map();
largePrepDefines.set('{MAX_DIM}', maxDim.toString());
this._largeSplatPrepBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('countersBuffer', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('dispatchArgs', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('largeSplatIds', SHADERSTAGE_COMPUTE, true)
]);
this._largeSplatPrepShader = new Shader(device, {
name: 'GSplatLocalLargeSplatPrep',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalDispatchPrepLargeSource,
cdefines: largePrepDefines,
computeBindGroupFormat: this._largeSplatPrepBindGroupFormat
});
this._largeSplatDispatchBuffer = new StorageBuffer(device, 3 * 4, BUFFERUSAGE_INDIRECT);
this._largeSplatPrepCompute = new Compute(device, this._largeSplatPrepShader);
}
// --- Large-splat cooperative place entries ---
this._largePlaceEntriesBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('pairBuffer', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('splatPairStart', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('splatPairCount', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('tileSplatCounts', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('tileEntries', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('largeSplatIds', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('countersBuffer', SHADERSTAGE_COMPUTE, true)
]);
this._largePlaceEntriesShader = new Shader(device, {
name: 'GSplatLocalPlaceEntriesLarge',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalPlaceEntriesLargeSource,
computeBindGroupFormat: this._largePlaceEntriesBindGroupFormat
});
// --- Classify ---
{
const ubf = new UniformBufferFormat(device, [
new UniformFormat('numTiles', UNIFORMTYPE_UINT),
new UniformFormat('dispatchSlotOffset', UNIFORMTYPE_UINT),
new UniformFormat('bufferCapacity', UNIFORMTYPE_UINT),
new UniformFormat('maxWorkgroupsPerDim', UNIFORMTYPE_UINT),
new UniformFormat('drawSlot', UNIFORMTYPE_UINT)
]);
this._classifyBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('tileSplatCounts', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('smallTileList', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('largeTileList', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('rasterizeTileList', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('tileListCounts', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('indirectDispatchArgs', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('largeTileOverflowBases', SHADERSTAGE_COMPUTE),
new BindUniformBufferFormat('uniforms', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('indirectDrawArgs', SHADERSTAGE_COMPUTE)
]);
this._classifyShader = new Shader(device, {
name: 'GSplatLocalClassify',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalClassifySource,
computeBindGroupFormat: this._classifyBindGroupFormat,
computeUniformBufferFormats: {
uniforms: ubf
}
});
}
// --- Sort ---
this._sortBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('tileEntries', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('tileSplatCounts', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('depthBuffer', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('smallTileList', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('tileListCounts', SHADERSTAGE_COMPUTE, true)
]);
this._sortShader = new Shader(device, {
name: 'GSplatLocalTileSort',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalTileSortSource,
cincludes: this._createBitonicIncludes(),
computeBindGroupFormat: this._sortBindGroupFormat
});
// --- BucketSort ---
{
const ubf = new UniformBufferFormat(device, [
new UniformFormat('bufferCapacity', UNIFORMTYPE_UINT),
new UniformFormat('maxChunks', UNIFORMTYPE_UINT)
]);
this._bucketSortBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('tileEntries', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('largeTileOverflowBases', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('tileSplatCounts', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('depthBuffer', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('largeTileList', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('chunkRanges', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('totalChunks', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('tileListCounts', SHADERSTAGE_COMPUTE, true),
new BindUniformBufferFormat('uniforms', SHADERSTAGE_COMPUTE)
]);
this._bucketSortShader = new Shader(device, {
name: 'GSplatLocalBucketSort',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalBucketSortSource,
computeBindGroupFormat: this._bucketSortBindGroupFormat,
computeUniformBufferFormats: {
uniforms: ubf
}
});
}
// --- Copy ---
{
const ubf = new UniformBufferFormat(device, [
new UniformFormat('maxChunks', UNIFORMTYPE_UINT),
new UniformFormat('maxWorkgroupsPerDim', UNIFORMTYPE_UINT)
]);
this._copyBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('totalChunks', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('chunkSortIndirect', SHADERSTAGE_COMPUTE),
new BindUniformBufferFormat('uniforms', SHADERSTAGE_COMPUTE)
]);
this._copyShader = new Shader(device, {
name: 'GSplatLocalCopy',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalCopySource,
computeBindGroupFormat: this._copyBindGroupFormat,
computeUniformBufferFormats: {
uniforms: ubf
}
});
}
// --- ChunkSort ---
{
const ubf = new UniformBufferFormat(device, [
new UniformFormat('maxChunks', UNIFORMTYPE_UINT)
]);
this._chunkSortBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat('tileEntries', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('depthBuffer', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('chunkRanges', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('totalChunks', SHADERSTAGE_COMPUTE, true),
new BindUniformBufferFormat('uniforms', SHADERSTAGE_COMPUTE)
]);
this._chunkSortShader = new Shader(device, {
name: 'GSplatLocalChunkSort',
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalChunkSortSource,
cincludes: this._createBitonicIncludes(),
computeBindGroupFormat: this._chunkSortBindGroupFormat,
computeUniformBufferFormats: {
uniforms: ubf
}
});
}
}
/**
* Creates the count shader + shared bind group format.
*
* @param {boolean} pickMode - Whether to create the pick variant.
* @param {boolean} fisheyeEnabled - Whether to include the GSPLAT_FISHEYE define and fisheye uniforms.
* @returns {{ shader: Shader, bindGroupFormat: BindGroupFormat }} The shader and format.
* @private
*/ _createCountShaderAndFormat(pickMode, fisheyeEnabled) {
const device = this.device;
const uniforms = [
new UniformFormat('splatTextureSize', UNIFORMTYPE_UINT),
new UniformFormat('numTilesX', UNIFORMTYPE_UINT),
new UniformFormat('numTilesY', UNIFORMTYPE_UINT),
new UniformFormat('viewProj', UNIFORMTYPE_MAT4),
new UniformFormat('viewMatrix', UNIFORMTYPE_MAT4),
new UniformFormat('focal', UNIFORMTYPE_FLOAT),
new UniformFormat('viewportWidth', UNIFORMTYPE_FLOAT),
new UniformFormat('viewportHeight', UNIFORMTYPE_FLOAT),
new UniformFormat('nearClip', UNIFORMTYPE_FLOAT),
new UniformFormat('farClip', UNIFORMTYPE_FLOAT),
new UniformFormat('minPixelSize', UNIFORMTYPE_FLOAT),
new UniformFormat('isOrtho', UNIFORMTYPE_UINT),
new UniformFormat('exposure', UNIFORMTYPE_FLOAT),
new UniformFormat('alphaClip', UNIFORMTYPE_FLOAT),
new UniformFormat('minContribution', UNIFORMTYPE_FLOAT)
];
if (fisheyeEnabled) {
uniforms.push(new UniformFormat('fisheye_k', UNIFORMTYPE_FLOAT), new UniformFormat('fisheye_inv_k', UNIFORMTYPE_FLOAT), new UniformFormat('fisheye_projMat00', UNIFORMTYPE_FLOAT), new UniformFormat('fisheye_projMat11', UNIFORMTYPE_FLOAT));
}
const uniformBufferFormat = new UniformBufferFormat(device, uniforms);
const wbFormat = this._dataSource.format;
const fixedBindings = [
new BindStorageBufferFormat('compactedSplatIds', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('sortElementCount', SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat('projCache', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('tileSplatCounts', SHADERSTAGE_COMPUTE),
new BindUniformBufferFormat('uniforms', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('pairBuffer', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('countersBuffer', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('splatPairStart', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('splatPairCount', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('largeSplatIds', SHADERSTAGE_COMPUTE),
new BindStorageBufferFormat('depthBuffer', SHADERSTAGE_COMPUTE)
];
const bindGroupFormat = new BindGroupFormat(device, [
...fixedBindings,
...wbFormat.getComputeBindFormats()
]);
const cincludes = this._createCommonIncludes();
cincludes.set('gsplatComputeSplatCS', computeSplatSource);
cincludes.set('gsplatFormatDeclCS', wbFormat.getComputeInputDeclarations(fixedBindings.length));
cincludes.set('gsplatFormatReadCS', wbFormat.getReadCode());
const cdefines = new Map();
if (fisheyeEnabled) {
cdefines.set('GSPLAT_FISHEYE', '');
}
if (pickMode) {
cdefines.set('PICK_MODE', '');
}
const colorStream = wbFormat.getStream('dataColor');
if (colorStream && colorStream.format !== PIXELFORMAT_RGBA16U) {
cdefines.set('GSPLAT_COLOR_FLOAT', '');
}
const suffix = fisheyeEnabled ? 'Fisheye' : '';
const shader = new Shader(device, {
name: pickMode ? `GSplatLocalTileCountPick${suffix}` : `GSplatLocalTileCount${suffix}`,
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatLocalTileCountSource,
cincludes,
cdefines,
computeBindGroupFormat: bindGroupFormat,
computeUniformBufferFormats: {
uniforms: uniformBufferFormat
}
});
return {
shader,
bindGroupFormat
};
}
/**
* Creates a dispatch set with its 8 Compute instances.
*
* @param {boolean} pickMode - Whether this set is for picking.
* @returns {GSplatLocalDispatchSet} The populated dispatch set.
* @private
*/ _createDispatchSet(pickMode) {
const device = this.device;
const set = new GSplatLocalDispatchSet(device, pickMode);
// Count compute is created lazily by set.getCountCompute()
// PlaceEntries: shared shader (replaces the old atomic scatter pass)
set.placeEntriesCompute = new Compute(device, this._placeEntriesShader, pickMode ? 'GSplatPickPlaceEntries' : 'GSplatLocalPlaceEntries');
// LargeSplat: cooperative tile count for deferred large splats
set.largeSplatCompute = new Compute(device, this._largeSplatShader, pickMode ? 'GSplatPickLargeTileCount' : 'GSplatLocalLargeTileCount');
// LargePlaceEntries: cooperative place entries for deferred large splats
set.largePlaceEntriesCompute = new Compute(device, this._largePlaceEntriesShader, pickMode ? 'GSplatPickLargePlaceEntries' : 'GSplatLocalLargePlaceEntries');
// Classify: shared shader
set.classifyCompute = new Compute(device, this._classifyShader, pickMode ? 'GSplatPickClassify' : 'GSplatLocalClassify');
// Sort: shared shader
set.sortCompute = new Compute(device, this._sortShader, pickMode ? 'GSplatPickTileSort' : 'GSplatLocalTileSort');
// BucketSort: shared shader
set.bucketSortCompute = new Compute(device, this._bucketSortShader, pickMode ? 'GSplatPickBucketSort' : 'GSplatLocalBucketSort');
// Copy: shared shader
set.copyCompute = new Compute(device, this._copyShader, pickMode ? 'GSplatPickCopy' : 'GSplatLocalCopy');
// ChunkSort: shared shader
set.chunkSortCompute = new Compute(device, this._chunkSortShader, pickMode ? 'GSplatPickChunkSort' : 'GSplatLocalChunkSort');
return set;
}
/**
* @param {GraphicsDevice} device - The graphics device.
* @param {GraphNode} node - The graph node.
* @param {GraphNode} cameraNode - The camera node.
* @param {Layer} layer - The layer to add mesh instances to.
* @param {GSplatWorkBuffer} workBuffer - The work buffer containing splat data.
*/ constructor(device, node, cameraNode, layer, workBuffer){
super(device, node, cameraNode, layer, workBuffer), /** @type {GSplatLocalDispatchSet|null} */ this._pickSet = null, /** @type {boolean} */ this._needsFramePassRegister = false, /** @type {number} */ this._textureSize = 0, /** @type {number} */ this._minPixelSize = 2.0, /** @type {number} */ this._minContribution = 3.0, /** @type {number} */ this._alphaClip = 0.3, /** @type {number} */ this._exposure = 1.0, /** @type {number} */ this._numSplats = 0, /** @type {StorageBuffer|null} */ this._compactedSplatIds = null, /** @type {StorageBuffer|null} */ this._sortElementCountBuffer = null, // --- Shared splat/entry-dependent buffers ---
/** @type {StorageBuffer|null} */ this._projCacheBuffer = null, /** @type {StorageBuffer|null} */ this._tileEntriesBuffer = null, // Pair buffer for scatter-free tile binning: stores packed (tileIdx << 16 | localOffset)
// per splat-tile intersection. Same size as tileEntries (one u32 per pair).
/** @type {StorageBuffer|null} */ this._pairBuffer = null, /**
* Packed atomic counters: [0] = global pair counter, [1] = large splat count.
*
* @type {StorageBuffer|null}
*/ this._countersBuffer = null, /** @type {StorageBuffer|null} */ this._splatPairStartBuffer = null, /** @type {StorageBuffer|null} */ this._splatPairCountBuffer = null, // --- Large-splat deferred processing buffers ---
/** @type {StorageBuffer|null} */ this._largeSplatIdsBuffer = null, /** @type {number} */ this._largeSplatIdsCapacity = INITIAL_LARGE_SPLAT_CAPACITY, /** @type {number} */ this._allocatedLargeSplatCapacity = 0, /** @type {number} */ this._allocatedSplatCapacity = 0, /** @type {number} */ this._allocatedEntryCapacity = 0, /** @type {number} */ this._tileEntryMultiplier = INITIAL_TILE_ENTRY_MULTIPLIER, /** @type {number} Last device.submitVersion when shared buffers were resized */ this._lastBufferSubmitVersion = -1, /** @type {number} Last readback entry count (-1 = consumed / no fresh data) */ this._lastReadbackEntryCount = -1, /** @type {number} Consecutive frames where usage < half capacity */ this._shrinkFrameCount = 0, /** @type {number} */ this._fisheye = 0, /** @type {StorageBuffer|null} */ this._placeEntryPrepDispatchBuffer = null, /** @type {Compute|null} */ this._placeEntryPrepCompute = null, /** @type {StorageBuffer|null} */ this._largeSplatDispatchBuffer = null, /** @type {Compute|null} */ this._largeSplatPrepCompute = null, /** @type {number} */ this._lastDrawSlot = 0, /** @type {number} */ this._lastNumTilesX = 0;
this._dataSource = workBuffer;
this._createSharedShaders();
this._mainSet = this._createDispatchSet(false);
this.framePass = new FramePassGSplatComputeLocal(this);
const thisCamera = cameraNode.camera;
this.tileComposite = new GSplatTileComposite(device, node, (camera)=>{
const renderMode = this.renderMode ?? 0;
return thisCamera.camera === camera && (renderMode & GSPLAT_FORWARD) !== 0 && this._mainSet._rasterizeTileListBuffer !== null;
});
}
}
export { GSplatComputeLocalRenderer };