From e8d316cd40b7b92f4756428cfc5ac9856524c778 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Sat, 31 Oct 2026 20:48:33 +0000 Subject: [PATCH] fix: Space gsplat sort keys by log distance so stray far splats keep near precision Sort keys were spread linearly over the distance range, in camera-weighted bins out to the farthest splat. A few stray splats far away stretched the range, so splats near the camera shared keys and drew out of order (superspl.at scenes 0bb9b075 and 0839ceea). Radial keys, and the depth keys of a perspective camera, now come from the bit pattern of the distance as a float, which follows log2: every doubling of distance gets the same number of keys. They start ten doublings inside the near clip, since the splats closer than it are drawn too, with their depth clamped. The GPU projector and the CPU worker compute them the same way. Orthographic and fisheye depth sorts keep the bins, since they can draw splats at or behind the camera. The CPU worker writes the distances as floats first and converts them in a second pass, and caps log keys at 16 bits so the counting sort's table stays in cache. Its depth keys give the splats behind the camera the last sixteenth of the keys, spaced the same way, so a shader that moves them into view finds them in the order of their signed depth (the CPU sort reads the centers before any modifier). Log depth keys are measured from the camera, so a directional CPU sort that uses them now re-sorts when the camera moves, not only when it turns. A switch of projection or sort mode re-sorts too, even with the camera still. And a sort the worker turns away while one is in flight is no longer dropped: the request stays until it is sent, and the camera pose is recorded only then. Co-Authored-By: Claude Opus 5.5 --- .../gsplat-unified/gsplat-hybrid-renderer.js | 5 +- src/scene/gsplat-unified/gsplat-manager.js | 63 ++++++- src/scene/gsplat-unified/gsplat-projector.js | 52 ++++-- .../gsplat-unified/gsplat-sort-bin-weights.js | 13 +- .../gsplat-unified-sort-worker.js | 160 +++++++++++++++--- .../gsplat-unified/gsplat-unified-sorter.js | 12 +- .../chunks/gsplat/compute-gsplat-projector.js | 58 ++++--- .../gsplat-manager-sort.test.mjs | 119 +++++++++++++ .../gsplat-unified-sort-worker.test.mjs | 120 +++++++++++++ 9 files changed, 527 insertions(+), 75 deletions(-) create mode 100644 test/scene/gsplat-unified/gsplat-manager-sort.test.mjs create mode 100644 test/scene/gsplat-unified/gsplat-unified-sort-worker.test.mjs diff --git a/src/scene/gsplat-unified/gsplat-hybrid-renderer.js b/src/scene/gsplat-unified/gsplat-hybrid-renderer.js index 33a170f16e7..1df4806cb44 100644 --- a/src/scene/gsplat-unified/gsplat-hybrid-renderer.js +++ b/src/scene/gsplat-unified/gsplat-hybrid-renderer.js @@ -518,7 +518,10 @@ class GSplatHybridRenderer extends GSplatRenderer { let minDist = 0; let maxDist = 1; if (!stochastic) { - const numBits = Math.max(10, Math.min(20, Math.round(Math.log2(elementCount / 4)))); + // at least 16 bits whatever the splat count: log spaced keys (see GSplatProjector) are + // spread over every doubling of distance from inside the near clip out to the scene, + // and fewer leave the splats of a small scene far from the camera sharing keys + const numBits = Math.max(16, Math.min(20, Math.round(Math.log2(elementCount / 4)))); roundedNumBits = Math.ceil(numBits / gpuSorter.radixBits) * gpuSorter.radixBits; ({ minDist, maxDist } = this.computeDistanceRange(worldState, cameraNode, params.radialSorting)); } diff --git a/src/scene/gsplat-unified/gsplat-manager.js b/src/scene/gsplat-unified/gsplat-manager.js index 47bdb6d528b..d304fd8b9bb 100644 --- a/src/scene/gsplat-unified/gsplat-manager.js +++ b/src/scene/gsplat-unified/gsplat-manager.js @@ -14,7 +14,8 @@ import { GSPLAT_RENDERER_RASTER_GPU_SORT, GSPLAT_FORWARD, GSPLAT_SHADOW, - GSPLAT_DEBUG_AABBS + GSPLAT_DEBUG_AABBS, + PROJECTION_ORTHOGRAPHIC } from '../constants.js'; import { getLodDebugColors, lodDebugColorIndex } from './gsplat-lod-colors.js'; @@ -137,6 +138,9 @@ class GSplatManager { /** @type {Vec3} */ lastSortCameraFwd = new Vec3(Infinity, Infinity, Infinity); + /** @type {string|null} */ + lastSortKeyMode = null; + /** @type {boolean} */ sortNeeded = true; @@ -575,21 +579,33 @@ class GSplatManager { /** * Tests if the camera has moved enough to require re-sorting. + * - A change of sort keys (see {@link _sortKeyMode}) always re-sorts * - For radial sorting: only position matters (rotation doesn't affect sort order) - * - For directional sorting: only forward direction matters (position doesn't affect sort order) + * - For directional sorting: forward direction matters, and position too when the depth keys + * are log spaced * * @returns {boolean} True if camera moved enough to require re-sorting, otherwise false. */ testCameraMovedForSort() { + // a switch of projection or sort mode needs a new order, even from a camera that is still + if (this._sortKeyMode() !== this.lastSortKeyMode) { + return true; + } + const epsilon = 0.001; + const moved = this.lastSortCameraPos.distance(this.cameraNode.getPosition()) > epsilon; if (this.gsplat.radialSorting) { // For radial sorting, only position changes matter - const currentCameraPos = this.cameraNode.getPosition(); - return this.lastSortCameraPos.distance(currentCameraPos) > epsilon; + return moved; } - // For directional sorting, only forward direction changes matter + // For directional sorting, forward direction changes matter, and position changes too when + // the depth keys are log spaced: they are measured from the camera, and the splats at or + // behind it share the nearest key, so a move that brings them into view needs a new order + if (moved && this.lastSortKeyMode === 'logDepth') { + return true; + } if (Number.isFinite(this.lastSortCameraFwd.x)) { const currentCameraFwd = this.cameraNode.forward; const dot = Math.min(1, Math.max(-1, this.lastSortCameraFwd.dot(currentCameraFwd))); @@ -600,6 +616,30 @@ class GSplatManager { return true; } + /** + * Whether a directional CPU sort spaces its depth keys by log depth, which needs a perspective, + * non-fisheye camera. An orthographic or fisheye camera can draw splats at or behind it, so its + * depth keys keep the bins. + * + * @returns {boolean} True when the depth keys are log spaced. + * @private + */ + _logDepthKeys() { + return this.cameraNode.camera.projection !== PROJECTION_ORTHOGRAPHIC && !(this.gsplat.fisheye > 0); + } + + /** + * The keys a CPU sort gives the splats: 'radial', 'logDepth' (see {@link _logDepthKeys}) or + * 'depthBins'. + * + * @returns {string} The sort key mode. + * @private + */ + _sortKeyMode() { + if (this.gsplat.radialSorting) return 'radial'; + return this._logDepthKeys() ? 'logDepth' : 'depthBins'; + } + /** * Fires the frame:ready event with current sorting and loading state. */ @@ -721,6 +761,7 @@ class GSplatManager { // The CPU path posts to the worker only when a re-sort is needed. The version lifecycle // (markSorted on the first sort of a version) stays here in the manager. if (lastState) { + let sortSent = true; if (this.renderer.usesGpuSort) { this._markSortedIfNeeded(lastState); @@ -733,15 +774,18 @@ class GSplatManager { this.renderer.prepareRenderView(this.world, lastState, this._fillRenderViewParams()); } } else if (this.sortNeeded) { - this.sortCpu(lastState); + sortSent = this.sortCpu(lastState); } - if (this.sortNeeded) { + // The CPU sorter turns a sort away while one of the same version is in flight, so the + // request stays until it is sent: the camera tracking below must be the pose sorted for + if (this.sortNeeded && sortSent) { this.sortNeeded = false; // Update camera tracking for the next CPU re-sort check. this.lastSortCameraPos.copy(this.cameraNode.getPosition()); this.lastSortCameraFwd.copy(this.cameraNode.forward); + this.lastSortKeyMode = this._sortKeyMode(); } } @@ -819,10 +863,11 @@ class GSplatManager { * Sorts the splats using CPU worker (asynchronous). * * @param {GSplatWorldState} lastState - The last world state. + * @returns {boolean} True if the sort was sent to the worker; false if it was turned away. */ sortCpu(lastState) { Debug.assert(this.cpuSorter, 'CPU sorter not initialized'); - if (!this.cpuSorter) return; + if (!this.cpuSorter) return false; // Get camera's world-space properties const cameraNode = this.cameraNode; @@ -872,7 +917,7 @@ class GSplatManager { }); }); - this.cpuSorter.setSortParams(sorterRequest, radialSort); + return this.cpuSorter.setSortParams(sorterRequest, radialSort, cameraNode.camera.nearClip, this._logDepthKeys()); } } diff --git a/src/scene/gsplat-unified/gsplat-projector.js b/src/scene/gsplat-unified/gsplat-projector.js index 571b774db30..0c0b66b4520 100644 --- a/src/scene/gsplat-unified/gsplat-projector.js +++ b/src/scene/gsplat-unified/gsplat-projector.js @@ -20,6 +20,7 @@ import { SHADERLANGUAGE_WGSL, SHADERSTAGE_COMPUTE, UNIFORMTYPE_FLOAT, + UNIFORMTYPE_INT, UNIFORMTYPE_MAT4, UNIFORMTYPE_UINT, UNIFORMTYPE_UVEC4, @@ -60,6 +61,10 @@ const _viewProj1Data = new Float32Array(16); const _viewData = new Float32Array(16); const _shaderProjMat = new Mat4(); +// scratch for reading a float's bit pattern, see the sort keys +const _floatBits = new Float32Array(1); +const _floatBitsI32 = new Int32Array(_floatBits.buffer); + /** * Owns the per-splat compute pass for the hybrid GSplat renderer (Pass B in the pipeline): * @@ -293,7 +298,10 @@ class GSplatProjector { new UniformFormat('minDist', UNIFORMTYPE_FLOAT), new UniformFormat('invRange', UNIFORMTYPE_FLOAT), new UniformFormat('foveationStrength', UNIFORMTYPE_FLOAT), - new UniformFormat('foveationCenter', UNIFORMTYPE_FLOAT) + new UniformFormat('foveationCenter', UNIFORMTYPE_FLOAT), + new UniformFormat('sortKeyMinBits', UNIFORMTYPE_INT), + new UniformFormat('sortKeyScale', UNIFORMTYPE_FLOAT), + new UniformFormat('maxSortKey', UNIFORMTYPE_UINT) ]; this._projectorUniformBufferFormat = new UniformBufferFormat(device, baseFields); @@ -705,14 +713,6 @@ class GSplatProjector { const range = maxDist - minDist; const invRange = range > 0 ? 1.0 / range : 1.0; - // Bin weights — same pattern as CPU-side sort key preparation. - if (!stochastic) { - const bucketCount = (1 << numBits); - const cameraBin = GSplatSortBinWeights.computeCameraBin(radialMode, minDist, range); - const binWeights = this.binWeightsUtil.compute(cameraBin, bucketCount); - this.binWeightsBuffer.write(0, binWeights); - } - compute.setParameter('compactedSplatIds', compactedSplatIds); compute.setParameter('sortElementCount', sortElementCountBuffer); compute.setParameter('projCache', this.projCache); @@ -731,6 +731,7 @@ class GSplatProjector { // Camera + projection uniforms. const cameraComponent = cameraNode.camera; const cam = cameraComponent.camera; + const orthographic = cam.projection === PROJECTION_ORTHOGRAPHIC; const webgpu = this.device.isWebGPU; // focal length in pixels per axis (they differ when the viewport's pixel aspect doesn't // match the projection's) @@ -786,13 +787,44 @@ class GSplatProjector { compute.setParameter('minContribution', minContribution); compute.setParameter('foveationStrength', foveationStrength); compute.setParameter('foveationCenter', foveationCenter); - compute.setParameter('isOrtho', cam.projection === PROJECTION_ORTHOGRAPHIC ? 1 : 0); + compute.setParameter('isOrtho', orthographic ? 1 : 0); compute.setParameter('splatTextureSize', workBuffer.textureSize); compute.setParameter('numBins', GSplatSortBinWeights.NUM_BINS); compute.setParameter('minDist', minDist); compute.setParameter('invRange', invRange); compute.setParameter('stochastic', stochastic ? 1 : 0); + // Sort keys - same as the CPU worker. Radial keys, and the depth keys of a perspective + // camera, are spaced by log distance: they span the bit patterns of the distances from ten + // doublings inside the near clip (the splats closer than it are drawn too, with their depth + // clamped) out to the far end of the range, squared for radial (see the shader). An + // orthographic or fisheye camera can draw splats at or behind it, so its depth keys keep the + // camera-weighted bins. + const logKeys = !stochastic && (radialMode || (!orthographic && !fisheyeMode)); + let sortKeyMinBits = 0; + let sortKeyScale = 0; + const maxSortKey = logKeys ? (1 << numBits) - 1 : 0; + if (logKeys) { + // an orthographic near clip can be zero or negative, so a share of the range stands in + // for it then (radial only) + const nearDist = (cam.nearClip > 0 ? cam.nearClip : maxDist * 1e-6) / 1024; + _floatBits[0] = radialMode ? nearDist * nearDist : nearDist; + sortKeyMinBits = _floatBitsI32[0]; + // a linear range runs along the camera's +Z, which points backwards, so its far end is + // at -minDist + _floatBits[0] = radialMode ? maxDist * maxDist : -minDist; + const bitsRange = _floatBitsI32[0] - sortKeyMinBits; + sortKeyScale = bitsRange > 0 ? maxSortKey / bitsRange : 0; + } else if (!stochastic) { + const bucketCount = (1 << numBits); + const cameraBin = GSplatSortBinWeights.computeCameraBin(minDist, range); + const binWeights = this.binWeightsUtil.compute(cameraBin, bucketCount); + this.binWeightsBuffer.write(0, binWeights); + } + compute.setParameter('sortKeyMinBits', sortKeyMinBits); + compute.setParameter('sortKeyScale', sortKeyScale); + compute.setParameter('maxSortKey', maxSortKey); + if (fisheyeMode) { compute.setParameter('fisheye_k', fisheyeProj.k); compute.setParameter('fisheye_inv_k', fisheyeProj.invK); diff --git a/src/scene/gsplat-unified/gsplat-sort-bin-weights.js b/src/scene/gsplat-unified/gsplat-sort-bin-weights.js index d98f61159b2..03df12ceb00 100644 --- a/src/scene/gsplat-unified/gsplat-sort-bin-weights.js +++ b/src/scene/gsplat-unified/gsplat-sort-bin-weights.js @@ -73,20 +73,17 @@ class GSplatSortBinWeights { } /** - * Computes the camera bin index based on sort mode and distance range. + * Computes the camera bin index from the distance range of a linear sort. Only the depth sort + * of an orthographic or fisheye camera uses the bins; the other sort keys are spaced by log + * distance. * - * @param {boolean} radialSort - Whether using radial sort mode. * @param {number} minDist - Minimum distance. * @param {number} range - Distance range (maxDist - minDist). * @returns {number} The camera bin index (0 to NUM_BINS-1). */ - static computeCameraBin(radialSort, minDist, range) { + static computeCameraBin(minDist, range) { const numBins = GSplatSortBinWeights.NUM_BINS; - if (radialSort) { - // For radial sort with inverted distances, camera (dist=0) maps to the last bin - return numBins - 1; - } - // For linear sort, calculate where camera falls in the projected distance range + // calculate where camera falls in the projected distance range const cameraOffsetFromRangeStart = -minDist; const cameraBinFloat = (cameraOffsetFromRangeStart / range) * numBins; return Math.max(0, Math.min(numBins - 1, Math.floor(cameraBinFloat))); diff --git a/src/scene/gsplat-unified/gsplat-unified-sort-worker.js b/src/scene/gsplat-unified/gsplat-unified-sort-worker.js index cb721c056be..f6bac024c75 100644 --- a/src/scene/gsplat-unified/gsplat-unified-sort-worker.js +++ b/src/scene/gsplat-unified/gsplat-unified-sort-worker.js @@ -13,6 +13,12 @@ function UnifiedSortWorker(myself) { // Sorting mode: false = forward vector (directional), true = radial distance (for cubemaps) let _radialSort = false; + // The camera's near clip, which log spaced sort keys start inside of + let _nearClip = 0; + + // Whether a directional sort's keys are log spaced: a perspective, non-fisheye camera + let _logDepthKeys = false; + // Flag to warn only once about sortKey overflow let _warnedSortKeyOverflow = false; @@ -117,11 +123,79 @@ function UnifiedSortWorker(myself) { }); }; - const evaluateSortKeysRadial = (sortParams, minDist, range, distances, countBuffer, centersData) => { - evaluateSortKeysCommon(sortParams, minDist, range, distances, countBuffer, centersData, - (centers, params, intervalStart, intervalEnd, compactIdx, invBinRange, minDist, range, distances, countBuffer) => { + // scratch for reading a float's bit pattern, see logSortKeys + const floatBits = new Float32Array(1); + const floatBitsI32 = new Int32Array(floatBits.buffer); + + // Turns the distances written as floats over the key buffer into sort keys spaced by log + // distance, in place (same as the projector shader). A positive float's bit pattern grows with + // its value almost exactly as its log2, so keys taken from the bits give every doubling of + // distance the same number of keys, and splats far away cost no precision near the camera. The + // bits are measured from those of `nearValue`, and inverted so far splats sort first. Writing + // all the distances first costs less than reading the bits back a splat at a time. + const logSortKeys = (nearValue, farValue, maxKey, distances, countBuffer) => { + floatBits[0] = nearValue; + const minBits = floatBitsI32[0]; + floatBits[0] = farValue; + const bitsRange = floatBitsI32[0] - minBits; + const keyScale = bitsRange > 0 ? maxKey / bitsRange : 0; + + for (let i = 0; i < distances.length; i++) { + const keyFromNear = (distances[i] - minBits) * keyScale; + const sortKey = maxKey - (keyFromNear <= 0 ? 0 : (keyFromNear >= maxKey ? maxKey : keyFromNear >>> 0)); + distances[i] = sortKey; + countBuffer[sortKey]++; + } + }; + + // Same as logSortKeys for signed depths, whose negative values (the splats behind the camera) + // take the last `behindKeys` keys, spaced the same way out to `behindValue`, so they sort after + // the rest in the order of their signed depth. A loop of its own, as the sign test slows the + // radial keys' loop down. The sign picks each key's scale, clamp and direction arithmetically: + // a branch on it runs at half the speed, and slower still when splats in front of the camera + // and behind it are interleaved. + const signedLogSortKeys = (nearValue, farValue, behindValue, behindKeys, maxKey, distances, countBuffer) => { + floatBits[0] = nearValue; + const minBits = floatBitsI32[0]; + floatBits[0] = farValue; + const bitsRange = floatBitsI32[0] - minBits; + floatBits[0] = behindValue; + const behindBitsRange = floatBitsI32[0] - minBits; + + // the nearest key in front of the camera, and the last behind it, counted from the first + const nearestKey = maxKey - behindKeys; + const lastBehindKey = behindKeys - 1; + const keyScale = bitsRange > 0 ? nearestKey / bitsRange : 0; + const behindKeyScale = behindBitsRange > 0 ? lastBehindKey / behindBitsRange : 0; + const behindScaleStep = behindKeyScale - keyScale; + const behindLimitStep = lastBehindKey - nearestKey; + + // read as int32, so the sign bit is never a float + const keys = new Int32Array(distances.buffer, distances.byteOffset, distances.length); + for (let i = 0; i < keys.length; i++) { + const bits = keys[i]; + + // 1 behind the camera, 0 in front; the bits of the magnitude are measured from the near + // value's, and a key behind counts up from the one after the nearest, in front down + const behind = bits >>> 31; + const keyFromNear = ((bits & 0x7fffffff) - minBits) * (keyScale + behind * behindScaleStep); + const limit = nearestKey + behind * behindLimitStep; + const key = keyFromNear <= 0 ? 0 : (keyFromNear >= limit ? limit : keyFromNear >>> 0); + const sortKey = nearestKey + behind + (2 * behind - 1) * key; + keys[i] = sortKey; + countBuffer[sortKey]++; + } + }; + + // Radial keys from the squared distance, whose bits follow log2 as well. + const evaluateSortKeysRadial = (sortParams, nearDist, maxDist, maxKey, distances, countBuffer, centersData) => { + const squaredDistances = new Float32Array(distances.buffer, 0, distances.length); + + evaluateSortKeysCommon(sortParams, 0, maxDist, distances, countBuffer, centersData, + (centers, params, intervalStart, intervalEnd, compactIdx) => { // camera related params const { transformedPosition, scale } = params; + const scaleSq = scale * scale; // camera position in local space const cx = transformedPosition.x; @@ -134,23 +208,41 @@ function UnifiedSortWorker(myself) { const dy = centers[srcIndex + 1] - cy; const dz = centers[srcIndex + 2] - cz; - const distSq = dx * dx + dy * dy + dz * dz; - // World-space radial distance from camera - const dist = Math.sqrt(distSq) * scale; + // World-space squared distance from camera + squaredDistances[compactIdx++] = (dx * dx + dy * dy + dz * dz) * scaleSq; + } - // Invert distance so far objects get small keys (rendered first, back-to-front) - const invertedDist = range - dist; - // Bin-based mapping - const d = invertedDist * invBinRange; - const bin = d >>> 0; - const sortKey = (binBase[bin] + binDivider[bin] * (d - bin)) >>> 0; + return compactIdx; + }); - distances[compactIdx++] = sortKey; - countBuffer[sortKey]++; + logSortKeys(nearDist * nearDist, maxDist * maxDist, maxKey, distances, countBuffer); + }; + + // Depth keys of a perspective camera, from the signed depth in front of it. The sort direction + // is the camera's +Z, which points backwards, so the range runs from -minDist in front of the + // camera to maxDist behind it. Splats closer than `nearDist` on either side share a key. A shader + // can move splats from behind the camera into view (the sort reads the centers before any + // modifier), so those keep their order too, in a sixteenth of the keys. + const evaluateSortKeysDepth = (sortParams, nearDist, minDist, maxDist, maxKey, distances, countBuffer, centersData) => { + const depths = new Float32Array(distances.buffer, 0, distances.length); + + evaluateSortKeysCommon(sortParams, minDist, -minDist, distances, countBuffer, centersData, + (centers, params, intervalStart, intervalEnd, compactIdx) => { + // camera related params + const { transformedDirection, offset, scale } = params; + const sdx = transformedDirection.x * scale; + const sdy = transformedDirection.y * scale; + const sdz = transformedDirection.z * scale; + + // Process each center in this interval + for (let srcIndex = intervalStart; srcIndex < intervalEnd; srcIndex += 3) { + depths[compactIdx++] = -(centers[srcIndex] * sdx + centers[srcIndex + 1] * sdy + centers[srcIndex + 2] * sdz + offset); } return compactIdx; }); + + signedLogSortKeys(nearDist, -minDist, maxDist, (maxKey + 1) >>> 4, maxKey, distances, countBuffer); }; const countingSort = (bucketCount, countBuffer, numVertices, distances, order) => { @@ -267,7 +359,16 @@ function UnifiedSortWorker(myself) { // calculate number of bits needed to store sorting result const compareBits = Math.max(10, Math.min(20, Math.round(Math.log2(numVertices / 4)))); - const bucketCount = 2 ** compareBits + 1; + // Radial keys, and the depth keys of a perspective camera, are log spaced (an orthographic + // or fisheye camera can draw splats at or behind it, so its depth keys keep the bins). They + // take 16 bits whatever the splat count: they are spread over every doubling of distance + // from inside the near clip out to the scene, and 16 bits give each doubling a few thousand + // keys, where fewer leave the splats of a small scene far from the camera sharing keys. + // More would spread the counting sort over a count table too big for the cache + const logKeys = _radialSort || _logDepthKeys; + const keyBits = logKeys ? 16 : compareBits; + + const bucketCount = 2 ** keyBits + 1; // create distance buffer if (distances?.length !== numVertices) { @@ -280,19 +381,26 @@ function UnifiedSortWorker(myself) { countBuffer.fill(0); } - const range = maxDist - minDist; - - // Set up camera-relative bin weighting for near-camera precision (using shared utility) - // eslint-disable-next-line no-undef - const cameraBin = GSplatSortBinWeights.computeCameraBin(_radialSort, minDist, range); - - // Compute bin weights locally using shared utility - const binWeights = binWeightsUtil.compute(cameraBin, bucketCount); - unpackBinWeights(binWeights); + // Log keys start ten doublings inside the near clip: the splats closer than it are drawn + // too, with their depth clamped, so they need an order. An orthographic near clip can be + // zero or negative, so a share of the range stands in for it then (radial only). + const nearDist = (_nearClip > 0 ? _nearClip : maxDist * 1e-6) / 1024; if (_radialSort) { - evaluateSortKeysRadial(sortParams, minDist, range, distances, countBuffer, centersData); + evaluateSortKeysRadial(sortParams, nearDist, maxDist, bucketCount - 1, distances, countBuffer, centersData); + } else if (logKeys) { + evaluateSortKeysDepth(sortParams, nearDist, minDist, maxDist, bucketCount - 1, distances, countBuffer, centersData); } else { + const range = maxDist - minDist; + + // Set up camera-relative bin weighting for near-camera precision (using shared utility) + // eslint-disable-next-line no-undef + const cameraBin = GSplatSortBinWeights.computeCameraBin(minDist, range); + + // Compute bin weights locally using shared utility + const binWeights = binWeightsUtil.compute(cameraBin, bucketCount); + unpackBinWeights(binWeights); + evaluateSortKeysLinear(sortParams, minDist, range, distances, countBuffer, centersData); } @@ -362,6 +470,8 @@ function UnifiedSortWorker(myself) { // sort case 'sort': { _radialSort = msgData.radialSorting || false; + _nearClip = msgData.nearClip; + _logDepthKeys = msgData.logDepthKeys || false; const order = new Uint32Array(msgData.order); sort(msgData.sortParams, order, centersData); break; diff --git a/src/scene/gsplat-unified/gsplat-unified-sorter.js b/src/scene/gsplat-unified/gsplat-unified-sorter.js index 041c8b1b6b6..b5fab3d24fe 100644 --- a/src/scene/gsplat-unified/gsplat-unified-sorter.js +++ b/src/scene/gsplat-unified/gsplat-unified-sorter.js @@ -216,8 +216,14 @@ class GSplatUnifiedSorter extends EventHandler { * * @param {object} params - The sorting parameters - per-splat directions, offsets, scales, AABBs. * @param {boolean} radialSorting - Whether to use radial distance sorting. + * @param {number} nearClip - The camera's near clip distance, which log spaced sort keys start + * inside of. + * @param {boolean} logDepthKeys - Whether a directional sort's keys are log spaced, which needs + * a perspective, non-fisheye camera (radial sort keys always are). + * @returns {boolean} True if a sort job was sent; false if one is in flight for this version, + * in which case the caller asks again later. */ - setSortParams(params, radialSorting) { + setSortParams(params, radialSorting, nearClip, logDepthKeys) { // only process job requests if we have a new version or no jobs are in flight if (this.hasNewVersion || this.jobsInFlight === 0) { @@ -237,11 +243,15 @@ class GSplatUnifiedSorter extends EventHandler { command: 'sort', sortParams: params, radialSorting: radialSorting, + nearClip: nearClip, + logDepthKeys: logDepthKeys, order: orderData.buffer }, [ orderData.buffer ]); + return true; } + return false; } } diff --git a/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-projector.js b/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-projector.js index 0ae921c17db..e5092e9f927 100644 --- a/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-projector.js +++ b/src/scene/shader-lib/wgsl/chunks/gsplat/compute-gsplat-projector.js @@ -60,6 +60,9 @@ struct ProjectorUniforms { invRange: f32, foveationStrength: f32, foveationCenter: f32, + sortKeyMinBits: i32, + sortKeyScale: f32, + maxSortKey: u32, #ifdef GSPLAT_XR // Eye-1 view-projection (raw XR projViewOffMat). Eye 0 uses viewProj above. // Appended only for the stereo variant - matches the conditional CPU UBO field. @@ -89,6 +92,17 @@ struct ProjectorUniforms { var wgCount: atomic; var wgBase: u32; +// Sort key spaced by log distance (same as CPU worker). A positive float's bit pattern grows with +// its value almost exactly as its log2, so keys taken from the bits give every doubling of +// distance the same number of keys, and splats far away cost no precision near the camera. The +// bits are measured from those of a distance ten doublings inside the near clip (splats closer than +// it are drawn too), and inverted so far splats sort first. +fn logSortKey(value: f32) -> u32 { + let bits = bitcast(value) - uniforms.sortKeyMinBits; + let keyFromNear = clamp(f32(bits) * uniforms.sortKeyScale, 0.0, f32(uniforms.maxSortKey)); + return uniforms.maxSortKey - u32(keyFromNear); +} + @compute @workgroup_size(256) fn main( @builtin(global_invocation_id) gid: vec3u, @@ -200,31 +214,33 @@ fn main( if (uniforms.stochastic != 0u) { sortKey = projected.splatId; } else { - // Sort key — shared depth-bin weighting (same as CPU worker). - // The in-bin fraction is computed from the camera-relative distance so splats near the - // camera keep full f32 precision (instead of the ~2^-19 of a [0, numBins) float). - let numBinsF = f32(uniforms.numBins); - let invBinSize = uniforms.invRange * numBinsF; #ifdef RADIAL_SORT - let radialDist = length(center - uniforms.cameraPosition); - let d = (1.0 / uniforms.invRange - radialDist) * invBinSize; - let bin = u32(clamp(d, 0.0, numBinsF - 0.001)); - // measured from the bin's near (camera) edge, so the foreground keeps relative precision - let nearFrac = radialDist * invBinSize - (numBinsF - 1.0 - f32(bin)); + // from the squared distance, whose bits follow log2 as well + let delta = center - uniforms.cameraPosition; + sortKey = logSortKey(dot(delta, delta)); #else let s = dot(center - uniforms.cameraPosition, uniforms.cameraDirection); - let d = (s - uniforms.minDist) * invBinSize; - let bin = u32(clamp(d, 0.0, numBinsF - 0.001)); - let binFrac = s * invBinSize - (uniforms.minDist * invBinSize + f32(bin)); - #endif - let bw = binWeights[bin]; - let divider = u32(bw.divider); - #ifdef RADIAL_SORT - let offset = divider - 1u - min(u32(bw.divider * clamp(nearFrac, 0.0, 1.0)), divider - 1u); - #else - let offset = min(u32(bw.divider * clamp(binFrac, 0.0, 1.0)), divider - 1u); + // maxSortKey is zero when the keys use the depth bins + if (uniforms.maxSortKey != 0u) { + // depth in front of the camera: cameraDirection is the camera's +Z, which points + // backwards. Splats far inside the near clip share the nearest key. + sortKey = logSortKey(max(-s, 0.0)); + } else { + // Orthographic or fisheye, where splats at or behind the camera can be drawn: + // shared depth-bin weighting (same as CPU worker). The in-bin fraction is + // computed from the camera-relative distance so splats near the camera keep full + // f32 precision (instead of the ~2^-19 of a [0, numBins) float). + let numBinsF = f32(uniforms.numBins); + let invBinSize = uniforms.invRange * numBinsF; + let d = (s - uniforms.minDist) * invBinSize; + let bin = u32(clamp(d, 0.0, numBinsF - 0.001)); + let binFrac = s * invBinSize - (uniforms.minDist * invBinSize + f32(bin)); + let bw = binWeights[bin]; + let divider = u32(bw.divider); + let offset = min(u32(bw.divider * clamp(binFrac, 0.0, 1.0)), divider - 1u); + sortKey = u32(bw.base) + offset; + } #endif - sortKey = u32(bw.base) + offset; } // assemble (rgb, a) and run the render-stage color modifier on the modified center, diff --git a/test/scene/gsplat-unified/gsplat-manager-sort.test.mjs b/test/scene/gsplat-unified/gsplat-manager-sort.test.mjs new file mode 100644 index 00000000000..facea02be5f --- /dev/null +++ b/test/scene/gsplat-unified/gsplat-manager-sort.test.mjs @@ -0,0 +1,119 @@ +import { expect } from 'chai'; + +import { Vec3 } from '../../../src/core/math/vec3.js'; +import { PROJECTION_ORTHOGRAPHIC, PROJECTION_PERSPECTIVE } from '../../../src/scene/constants.js'; +import { GraphNode } from '../../../src/scene/graph-node.js'; +import { GSplatManager } from '../../../src/scene/gsplat-unified/gsplat-manager.js'; +import { GSplatUnifiedSorter } from '../../../src/scene/gsplat-unified/gsplat-unified-sorter.js'; + +describe('GSplatManager#testCameraMovedForSort', function () { + + // A manager whose last CPU sort was taken from the camera's current pose and sort mode, with + // only the properties the test reads. + const makeManager = ({ radialSorting = false, projection = PROJECTION_PERSPECTIVE, fisheye = 0 } = {}) => { + const cameraNode = new GraphNode(); + cameraNode.camera = { projection }; + cameraNode.setPosition(1, 2, 3); + cameraNode.setEulerAngles(10, 20, 0); + + const manager = Object.create(GSplatManager.prototype); + manager.gsplat = { radialSorting, fisheye }; + manager.cameraNode = cameraNode; + manager.lastSortCameraPos = cameraNode.getPosition().clone(); + manager.lastSortCameraFwd = cameraNode.forward.clone(); + manager.lastSortKeyMode = manager._sortKeyMode(); + return manager; + }; + + const move = (manager) => { + manager.cameraNode.setPosition(new Vec3(1, 2, 3).add(new Vec3(0.5, 0, 0))); + }; + + const turn = (manager) => { + manager.cameraNode.setEulerAngles(10, 25, 0); + }; + + it('does not re-sort a camera that has not changed', function () { + expect(makeManager().testCameraMovedForSort()).to.equal(false); + expect(makeManager({ radialSorting: true }).testCameraMovedForSort()).to.equal(false); + }); + + it('re-sorts a radial sort on a move but not on a turn', function () { + const moved = makeManager({ radialSorting: true }); + move(moved); + expect(moved.testCameraMovedForSort()).to.equal(true); + + const turned = makeManager({ radialSorting: true }); + turn(turned); + expect(turned.testCameraMovedForSort()).to.equal(false); + }); + + it('re-sorts a perspective depth sort on a move and on a turn', function () { + const moved = makeManager(); + move(moved); + expect(moved.testCameraMovedForSort()).to.equal(true); + + const turned = makeManager(); + turn(turned); + expect(turned.testCameraMovedForSort()).to.equal(true); + }); + + it('re-sorts an orthographic or fisheye depth sort on a turn only', function () { + for (const options of [{ projection: PROJECTION_ORTHOGRAPHIC }, { fisheye: 1 }]) { + const moved = makeManager(options); + move(moved); + expect(moved.testCameraMovedForSort()).to.equal(false); + + const turned = makeManager(options); + turn(turned); + expect(turned.testCameraMovedForSort()).to.equal(true); + } + }); + + it('re-sorts a still camera when its projection or sort mode changes', function () { + const orthographic = makeManager(); + orthographic.cameraNode.camera.projection = PROJECTION_ORTHOGRAPHIC; + expect(orthographic.testCameraMovedForSort()).to.equal(true); + + const fisheye = makeManager(); + fisheye.gsplat.fisheye = 1; + expect(fisheye.testCameraMovedForSort()).to.equal(true); + + const radial = makeManager(); + radial.gsplat.radialSorting = true; + expect(radial.testCameraMovedForSort()).to.equal(true); + }); +}); + +describe('GSplatUnifiedSorter#setSortParams', function () { + + // A sorter whose worker only counts the jobs posted to it. + const makeSorter = () => { + const sorter = Object.create(GSplatUnifiedSorter.prototype); + sorter.posted = 0; + sorter.worker = { + postMessage: () => { + sorter.posted++; + } + }; + sorter.bufferLength = 4; + sorter.availableOrderData = []; + sorter.jobsInFlight = 0; + sorter.hasNewVersion = false; + return sorter; + }; + + it('reports whether it sent the job, so a turned away sort can be asked for again', function () { + const sorter = makeSorter(); + expect(sorter.setSortParams([], false, 0.1, true)).to.equal(true); + + // a job of this version is in flight + expect(sorter.setSortParams([], false, 0.1, true)).to.equal(false); + expect(sorter.posted).to.equal(1); + + // the job returned + sorter.jobsInFlight = 0; + expect(sorter.setSortParams([], false, 0.1, true)).to.equal(true); + expect(sorter.posted).to.equal(2); + }); +}); diff --git a/test/scene/gsplat-unified/gsplat-unified-sort-worker.test.mjs b/test/scene/gsplat-unified/gsplat-unified-sort-worker.test.mjs new file mode 100644 index 00000000000..a71ec72ae8d --- /dev/null +++ b/test/scene/gsplat-unified/gsplat-unified-sort-worker.test.mjs @@ -0,0 +1,120 @@ +import { expect } from 'chai'; + +import { GSplatSortBinWeights } from '../../../src/scene/gsplat-unified/gsplat-sort-bin-weights.js'; +import { UnifiedSortWorker } from '../../../src/scene/gsplat-unified/gsplat-unified-sort-worker.js'; + +describe('UnifiedSortWorker', function () { + + // The worker's creator injects the bin weights class into the worker's scope as source text + before(function () { + globalThis.GSplatSortBinWeights = GSplatSortBinWeights; + }); + + after(function () { + delete globalThis.GSplatSortBinWeights; + }); + + // Sorts one placement of splats at the given positions for a camera at the origin looking down + // -Z, and returns the splat indices in draw order (back to front). + const sort = (positions, { radialSorting, nearClip = 0.01, logDepthKeys = false }) => { + let listener = null; + let result = null; + UnifiedSortWorker({ + addEventListener: (type, fn) => { + listener = fn; + }, + postMessage: (message) => { + result = message; + } + }); + + const centers = new Float32Array(positions.flat()); + const min = [0, 1, 2].map(axis => Math.min(...positions.map(p => p[axis]))); + const max = [0, 1, 2].map(axis => Math.max(...positions.map(p => p[axis]))); + + listener({ data: { command: 'addCenters', id: 1, centers: centers.buffer } }); + listener({ data: { command: 'intervals', ids: [1], pixelOffsets: [[0]], intervals: [[]], totalActiveSplats: positions.length, version: 1 } }); + listener({ + data: { + command: 'sort', + // the directional sort runs along the camera's +Z, which points backwards + sortParams: [{ + transformedPosition: { x: 0, y: 0, z: 0 }, + transformedDirection: { x: 0, y: 0, z: 1 }, + offset: 0, + scale: 1, + aabbMin: min, + aabbMax: max + }], + radialSorting, + nearClip, + logDepthKeys, + order: new Uint32Array(positions.length).buffer + } + }); + + return Array.from(new Uint32Array(result.order)); + }; + + describe('radial sort', function () { + + it('draws farther splats first', function () { + const order = sort([[0, 0, 2], [0, 3, 0], [1, 0, 0], [0, 0, -4]], { radialSorting: true }); + expect(order).to.deep.equal([3, 1, 0, 2]); + }); + + it('keeps nearby splats in order when a stray splat is very far away', function () { + // the farther of the two nearby splats comes first in the input, which is the order + // splats sharing one sort key would be drawn in reverse of + const order = sort([[0, 0, 2.2], [0, 0, 2], [0, 0, 1e6]], { radialSorting: true }); + expect(order).to.deep.equal([2, 0, 1]); + }); + + it('keeps splats closer than the near clip in order, as they are drawn too', function () { + const order = sort([[0, 0, 0.08], [0, 0, 0.04], [0, 0, 10]], { radialSorting: true, nearClip: 0.1 }); + expect(order).to.deep.equal([2, 0, 1]); + }); + + it('keeps a small scene far from the camera in order', function () { + // few splats would give few keys, spread over every doubling out from inside the near clip + const order = sort([[0, 0, 100.5], [0, 0, 100], [0, 0, 101]], { radialSorting: true, nearClip: 0.1 }); + expect(order).to.deep.equal([2, 0, 1]); + }); + }); + + describe('depth sort', function () { + + it('draws deeper splats first', function () { + const order = sort([[0, 0, -2], [3, 0, -3], [0, 1, -1], [0, 0, -4]], { radialSorting: false, logDepthKeys: true }); + expect(order).to.deep.equal([3, 1, 0, 2]); + }); + + it('keeps nearby splats in order when a stray splat is very far away', function () { + const order = sort([[0, 0, -2.2], [0, 0, -2], [0, 0, -1e6]], { radialSorting: false, logDepthKeys: true }); + expect(order).to.deep.equal([2, 0, 1]); + }); + + it('keeps splats closer than the near clip in order, as they are drawn too', function () { + const order = sort([[0, 0, -0.08], [0, 0, -0.04], [0, 0, -10]], { radialSorting: false, logDepthKeys: true, nearClip: 0.1 }); + expect(order).to.deep.equal([2, 0, 1]); + }); + + it('keeps a small scene far from the camera in order', function () { + const order = sort([[0, 0, -100.5], [0, 0, -100], [0, 0, -101]], { radialSorting: false, logDepthKeys: true, nearClip: 0.1 }); + expect(order).to.deep.equal([2, 0, 1]); + }); + + it('draws splats behind the camera after those in front of it, in the order of their depth', function () { + // a shader can move splats from behind the camera into view: here a modifier that moves + // every center by -2 along Z would show the two behind it at depths 1.5 and 1. The + // nearer of them comes first in the input, which splats sharing a key draw in reverse of + const order = sort([[0, 0, -2], [0, 0, 0.5], [0, 0, -1], [0, 0, 1]], { radialSorting: false, logDepthKeys: true }); + expect(order).to.deep.equal([0, 2, 1, 3]); + }); + + it('orders splats behind an orthographic camera by their depth bins', function () { + const order = sort([[0, 0, 2], [0, 0, -3], [0, 0, 1], [0, 0, -1]], { radialSorting: false, logDepthKeys: false }); + expect(order).to.deep.equal([1, 3, 2, 0]); + }); + }); +});