UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

353 lines (350 loc) 17.9 kB
import { Debug } from '../../core/debug.js'; import { Vec2 } from '../../core/math/vec2.js'; import { Vec3 } from '../../core/math/vec3.js'; import { Compute } from '../../platform/graphics/compute.js'; import { Shader } from '../../platform/graphics/shader.js'; import { StorageBuffer } from '../../platform/graphics/storage-buffer.js'; import { BindGroupFormat, BindTextureFormat, BindStorageBufferFormat, BindUniformBufferFormat } from '../../platform/graphics/bind-group-format.js'; import { UniformBufferFormat, UniformFormat } from '../../platform/graphics/uniform-buffer-format.js'; import { SHADERLANGUAGE_WGSL, UNIFORMTYPE_VEC3, UNIFORMTYPE_UINT, UNIFORMTYPE_FLOAT, SHADERSTAGE_COMPUTE, SAMPLETYPE_UINT, BUFFERUSAGE_COPY_SRC, BUFFERUSAGE_COPY_DST } from '../../platform/graphics/constants.js'; import { computeGsplatSortKeySource } from '../shader-lib/wgsl/chunks/gsplat/compute-gsplat-sort-key.js'; import { GSplatSortBinWeights } from './gsplat-sort-bin-weights.js'; /** * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' * @import { GSplatWorkBuffer } from './gsplat-work-buffer.js' * @import { GraphNode } from '../graph-node.js' */ // Constants const WORKGROUP_SIZE_X = 16; const WORKGROUP_SIZE_Y = 16; const THREADS_PER_WORKGROUP = WORKGROUP_SIZE_X * WORKGROUP_SIZE_Y; // 256 // Temporary Vec3 for camera direction (avoids allocation in hot path) const _cameraDir = new Vec3(); // Reusable Vec2 for dispatch size calculations (avoids per-frame allocations) const _dispatchSize = new Vec2(); /** * A class for generating GPU sort keys from GSplat world-space positions using compute shaders. * Supports both linear (forward vector) and radial (distance) sorting modes with camera-relative * bin weighting for precision optimization near the camera. * * @ignore */ class GSplatSortKeyCompute { /** * Destroys all resources. */ destroy() { this.keysBuffer?.destroy(); this.binWeightsBuffer?.destroy(); this.compute?.shader?.destroy(); this.bindGroupFormat?.destroy(); this.bindGroupFormatIndirect?.destroy(); this.keysBuffer = null; this.binWeightsBuffer = null; this.compute = null; this.bindGroupFormat = null; this.bindGroupFormatIndirect = null; this.uniformBufferFormat = null; } /** * Gets or creates the compute instance for the specified sort mode. * Destroys and recreates the compute instance if the mode changes. * * @param {boolean} computeRadialSort - Whether to get the radial sort variant. * @param {boolean} computeUseIndirectSort - Whether indirect dispatch with compaction is used. * @returns {Compute} The compute instance. * @private */ _getCompute(computeRadialSort, computeUseIndirectSort = false) { if (!this.compute || this.computeRadialSort !== computeRadialSort || this.computeUseIndirectSort !== computeUseIndirectSort) { // Destroy old compute instance if mode changed this.compute?.shader?.destroy(); // compute shader const modeName = computeRadialSort ? 'Radial' : 'Linear'; const name = `GSplatSortKeyCompute-${modeName}${computeUseIndirectSort ? '-Indirect' : ''}`; const cdefines = new Map([ [ '{WORKGROUP_SIZE_X}', `${WORKGROUP_SIZE_X}` ], [ '{WORKGROUP_SIZE_Y}', `${WORKGROUP_SIZE_Y}` ] ]); if (computeRadialSort) { cdefines.set('RADIAL_SORT', ''); } if (computeUseIndirectSort) { cdefines.set('USE_INDIRECT_SORT', ''); } const bgFormat = computeUseIndirectSort ? this.bindGroupFormatIndirect : this.bindGroupFormat; const shader = new Shader(this.device, { name: name, shaderLanguage: SHADERLANGUAGE_WGSL, cshader: computeGsplatSortKeySource, cdefines: cdefines, computeBindGroupFormat: bgFormat, computeUniformBufferFormats: { uniforms: this.uniformBufferFormat } }); // Create new compute instance for the requested mode this.compute = new Compute(this.device, shader, name); this.computeRadialSort = computeRadialSort; this.computeUseIndirectSort = computeUseIndirectSort; } return this.compute; } /** * Creates the bind group formats for the compute shaders. * * @private */ _createBindGroupFormat() { const device = this.device; // Create uniform buffer format this.uniformBufferFormat = new UniformBufferFormat(device, [ new UniformFormat('cameraPosition', UNIFORMTYPE_VEC3), new UniformFormat('elementCount', UNIFORMTYPE_UINT), new UniformFormat('cameraDirection', UNIFORMTYPE_VEC3), new UniformFormat('numBits', UNIFORMTYPE_UINT), new UniformFormat('textureSize', UNIFORMTYPE_UINT), new UniformFormat('minDist', UNIFORMTYPE_FLOAT), new UniformFormat('invRange', UNIFORMTYPE_FLOAT), new UniformFormat('numWorkgroupsX', UNIFORMTYPE_UINT), new UniformFormat('numBins', UNIFORMTYPE_UINT) ]); // Base bind group format (without compaction): // 0: dataTransformA (texture_2d<u32>) - input world positions // 1: sortKeys (storage, read_write) - output sort keys // 2: uniforms (uniform buffer) // 3: binWeights (storage, read) - combined bin base and divider values this.bindGroupFormat = new BindGroupFormat(device, [ new BindTextureFormat('dataTransformA', SHADERSTAGE_COMPUTE, undefined, SAMPLETYPE_UINT, false), new BindStorageBufferFormat('sortKeys', SHADERSTAGE_COMPUTE, false), new BindUniformBufferFormat('uniforms', SHADERSTAGE_COMPUTE), new BindStorageBufferFormat('binWeights', SHADERSTAGE_COMPUTE, true) ]); // Indirect sort bind group format (compaction + indirect dispatch): // 0-3: same as above // 4: compactedSplatIds (storage, read) // 5: sortElementCountBuf (storage, read) — same buffer the radix sort reads this.bindGroupFormatIndirect = new BindGroupFormat(device, [ new BindTextureFormat('dataTransformA', SHADERSTAGE_COMPUTE, undefined, SAMPLETYPE_UINT, false), new BindStorageBufferFormat('sortKeys', SHADERSTAGE_COMPUTE, false), new BindUniformBufferFormat('uniforms', SHADERSTAGE_COMPUTE), new BindStorageBufferFormat('binWeights', SHADERSTAGE_COMPUTE, true), new BindStorageBufferFormat('compactedSplatIds', SHADERSTAGE_COMPUTE, true), new BindStorageBufferFormat('sortElementCountBuf', SHADERSTAGE_COMPUTE, true) ]); } /** * Ensures the keys buffer has at least the required capacity. * * @param {number} elementCount - Required number of elements. * @private */ _ensureCapacity(elementCount) { if (elementCount > this.allocatedCount) { // Destroy old buffer if exists this.keysBuffer?.destroy(); // Allocate new buffer this.allocatedCount = elementCount; this.keysBuffer = new StorageBuffer(this.device, elementCount * 4, BUFFERUSAGE_COPY_SRC); } } /** * Generates sort keys from the work buffer using direct dispatch (no culling/compaction). * * @param {GSplatWorkBuffer} workBuffer - The work buffer containing world-space splat data. * @param {GraphNode} cameraNode - The camera node for position and direction. * @param {boolean} computeRadialSort - Whether to use radial sorting mode. * @param {number} elementCount - Number of splats to process. * @param {number} numBits - Number of bits for sort keys (determines bucket count). * @param {number} minDist - Minimum distance value for normalization. * @param {number} maxDist - Maximum distance value for normalization. * @returns {StorageBuffer} The storage buffer containing generated sort keys. */ generate(workBuffer, cameraNode, computeRadialSort, elementCount, numBits, minDist, maxDist) { Debug.assert(elementCount > 0, 'GSplatSortKeyCompute.generate: elementCount must be > 0'); // Ensure capacity this._ensureCapacity(elementCount); // Calculate workgroup dimensions const workgroupCount = Math.ceil(elementCount / THREADS_PER_WORKGROUP); Compute.calcDispatchSize(workgroupCount, _dispatchSize, this.device.limits.maxComputeWorkgroupsPerDimension || 65535); // Get or create compute instance for direct dispatch (no compaction) const compute = this._getCompute(computeRadialSort); // Get camera world position and direction // Use Z-axis (not forward) to match CPU sorter const cameraPos = cameraNode.getPosition(); const cameraMat = cameraNode.getWorldTransform(); const cameraDir = cameraMat.getZ(_cameraDir).normalize(); // Calculate normalization parameters const range = maxDist - minDist; const invRange = range > 0 ? 1.0 / range : 1.0; // Calculate bucket count from numBits const bucketCount = 1 << numBits; // Determine camera bin for weighting (using shared utility) const cameraBin = GSplatSortBinWeights.computeCameraBin(computeRadialSort, minDist, range); // Compute bin weights using shared utility const binWeights = this.binWeightsUtil.compute(cameraBin, bucketCount); // Upload to GPU this.binWeightsBuffer.write(0, binWeights); // Set parameters compute.setParameter('dataTransformA', workBuffer.getTexture('dataTransformA')); compute.setParameter('sortKeys', this.keysBuffer); compute.setParameter('binWeights', this.binWeightsBuffer); // Set uniforms this.cameraPositionData[0] = cameraPos.x; this.cameraPositionData[1] = cameraPos.y; this.cameraPositionData[2] = cameraPos.z; compute.setParameter('cameraPosition', this.cameraPositionData); this.cameraDirectionData[0] = cameraDir.x; this.cameraDirectionData[1] = cameraDir.y; this.cameraDirectionData[2] = cameraDir.z; compute.setParameter('cameraDirection', this.cameraDirectionData); compute.setParameter('elementCount', elementCount); compute.setParameter('numBits', numBits); compute.setParameter('textureSize', workBuffer.textureSize); compute.setParameter('minDist', minDist); compute.setParameter('invRange', invRange); compute.setParameter('numWorkgroupsX', _dispatchSize.x); compute.setParameter('numBins', GSplatSortBinWeights.NUM_BINS); // Dispatch compute.setupDispatch(_dispatchSize.x, _dispatchSize.y, 1); this.device.computeDispatch([ compute ], 'GSplatSortKeyCompute'); return this.keysBuffer; } /** * Generates sort keys using indirect dispatch. Only `visibleCount` threads are launched * (GPU-determined), reducing key generation work proportionally to the culled fraction. * * @param {GSplatWorkBuffer} workBuffer - The work buffer containing world-space splat data. * @param {GraphNode} cameraNode - The camera node for position and direction. * @param {boolean} computeRadialSort - Whether to use radial sorting mode. * @param {number} maxElementCount - Maximum number of splats (buffer allocation size). * @param {number} numBits - Number of bits for sort keys. * @param {number} minDist - Minimum distance value for normalization. * @param {number} maxDist - Maximum distance value for normalization. * @param {StorageBuffer} compactedSplatIds - Compacted visible splat IDs. * @param {StorageBuffer} sortElementCountBuffer - GPU-written buffer containing visible count. * @param {number} dispatchSlot - Slot index in the device's indirect dispatch buffer. * @returns {StorageBuffer} The storage buffer containing generated sort keys. */ generateIndirect(workBuffer, cameraNode, computeRadialSort, maxElementCount, numBits, minDist, maxDist, compactedSplatIds, sortElementCountBuffer, dispatchSlot) { Debug.assert(maxElementCount > 0, 'GSplatSortKeyCompute.generateIndirect: maxElementCount must be > 0'); // Ensure capacity for max element count this._ensureCapacity(maxElementCount); // Get or create compute instance for indirect sort (implies compaction) const compute = this._getCompute(computeRadialSort, true); // Get camera world position and direction const cameraPos = cameraNode.getPosition(); const cameraMat = cameraNode.getWorldTransform(); const cameraDir = cameraMat.getZ(_cameraDir).normalize(); // Calculate normalization parameters const range = maxDist - minDist; const invRange = range > 0 ? 1.0 / range : 1.0; // Calculate bucket count from numBits const bucketCount = 1 << numBits; // Determine camera bin for weighting const cameraBin = GSplatSortBinWeights.computeCameraBin(computeRadialSort, minDist, range); // Compute and upload bin weights const binWeights = this.binWeightsUtil.compute(cameraBin, bucketCount); this.binWeightsBuffer.write(0, binWeights); // Set parameters compute.setParameter('dataTransformA', workBuffer.getTexture('dataTransformA')); compute.setParameter('sortKeys', this.keysBuffer); compute.setParameter('binWeights', this.binWeightsBuffer); compute.setParameter('compactedSplatIds', compactedSplatIds); compute.setParameter('sortElementCountBuf', sortElementCountBuffer); // Set uniforms - elementCount is maxElementCount for the numWorkgroupsX-based GID calculation this.cameraPositionData[0] = cameraPos.x; this.cameraPositionData[1] = cameraPos.y; this.cameraPositionData[2] = cameraPos.z; compute.setParameter('cameraPosition', this.cameraPositionData); this.cameraDirectionData[0] = cameraDir.x; this.cameraDirectionData[1] = cameraDir.y; this.cameraDirectionData[2] = cameraDir.z; compute.setParameter('cameraDirection', this.cameraDirectionData); compute.setParameter('elementCount', maxElementCount); compute.setParameter('numBits', numBits); compute.setParameter('textureSize', workBuffer.textureSize); compute.setParameter('minDist', minDist); compute.setParameter('invRange', invRange); // For indirect dispatch, use the same workgroup layout as direct path const workgroupCount = Math.ceil(maxElementCount / THREADS_PER_WORKGROUP); Compute.calcDispatchSize(workgroupCount, _dispatchSize, this.device.limits.maxComputeWorkgroupsPerDimension || 65535); compute.setParameter('numWorkgroupsX', _dispatchSize.x); compute.setParameter('numBins', GSplatSortBinWeights.NUM_BINS); // Use indirect dispatch compute.setupIndirectDispatch(dispatchSlot); this.device.computeDispatch([ compute ], 'GSplatSortKeyCompute-Indirect'); return this.keysBuffer; } /** * Creates a new GSplatSortKeyCompute instance. * * @param {GraphicsDevice} device - The graphics device (must support compute). */ constructor(device){ /** * Allocated capacity for sort keys (grow-only). * * @type {number} */ this.allocatedCount = 0; /** * Output sort keys storage buffer. * * @type {StorageBuffer|null} */ this.keysBuffer = null; /** * Storage buffer for combined bin weights (binBase + binDivider). * * @type {StorageBuffer|null} */ this.binWeightsBuffer = null; /** * Current compute instance. * * @type {Compute|null} */ this.compute = null; /** * Whether the current compute instance is for radial sorting. * * @type {boolean} */ this.computeRadialSort = false; /** * Whether the current compute instance uses indirect sort (with compaction). * * @type {boolean} */ this.computeUseIndirectSort = false; /** * Bind group format for the compute shader (without compaction). * * @type {BindGroupFormat|null} */ this.bindGroupFormat = null; /** * Bind group format for the compute shader (with indirect sort + compaction). * * @type {BindGroupFormat|null} */ this.bindGroupFormatIndirect = null; /** * Uniform buffer format. * * @type {UniformBufferFormat|null} */ this.uniformBufferFormat = null; /** * Reusable array for camera position uniform. * * @type {Float32Array} */ this.cameraPositionData = new Float32Array(3); /** * Reusable array for camera direction uniform. * * @type {Float32Array} */ this.cameraDirectionData = new Float32Array(3); Debug.assert(device.supportsCompute, 'GSplatSortKeyCompute requires compute shader support (WebGPU)'); this.device = device; // Create shared bin weights utility this.binWeightsUtil = new GSplatSortBinWeights(); // Create bin storage buffer (64 floats = 256 bytes) this.binWeightsBuffer = new StorageBuffer(device, GSplatSortBinWeights.NUM_BINS * 2 * 4, BUFFERUSAGE_COPY_SRC | BUFFERUSAGE_COPY_DST); this._createBindGroupFormat(); } } export { GSplatSortKeyCompute };