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]); + }); + }); +});