UNPKG

playcanvas

Version:

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

477 lines (476 loc) 23.4 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { Debug, DebugHelper } from "../../../core/debug.js"; import { Vec2 } from "../../../core/math/vec2.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, BindStorageBufferFormat, BindUniformBufferFormat } from "../../../platform/graphics/bind-group-format.js"; import { UniformBufferFormat, UniformFormat } from "../../../platform/graphics/uniform-buffer-format.js"; import { BUFFERUSAGE_COPY_DST, SHADERLANGUAGE_WGSL, SHADERSTAGE_COMPUTE, UNIFORMTYPE_UINT } from "../../../platform/graphics/constants.js"; import { onesweepGlobalHistSource } from "../../shader-lib/wgsl/chunks/radix-sort/onesweep-global-hist.js"; import { onesweepScanSource } from "../../shader-lib/wgsl/chunks/radix-sort/onesweep-scan.js"; import { onesweepBinningSource } from "../../shader-lib/wgsl/chunks/radix-sort/onesweep-binning.js"; import { ComputeRadixSortBase } from "./compute-radix-sort-base.js"; const D_DIM = 256; const KEYS_PER_THREAD = 15; const PART_SIZE = D_DIM * KEYS_PER_THREAD; const G_HIST_DIM = 128; const G_HIST_PART_SIZE = 32768; const RADIX = 256; const MAX_PASSES = 4; class ComputeRadixSortOneSweep extends ComputeRadixSortBase { /** * @param {GraphicsDevice} device - The graphics device (must support * compute and subgroups). * @param {boolean} [indirect] - Whether this instance is for indirect dispatch only. */ constructor(device, indirect = false) { super(device, indirect); /** * Number of DigitBinningPass workgroups actually dispatched for the * current sort. Derived from `elementCount` (not `capacity`) so that * smaller sorts issue fewer workgroups even when buffers are sized for * a larger high-water mark. * * @type {number} */ __publicField(this, "_threadBlocks", 0); /** * Allocated thread-block capacity (buffer sizing). Buffers are only * reallocated when this value changes. Always `>= _threadBlocks`. * * @type {number} */ __publicField(this, "_allocatedThreadBlocks", 0); /** * Per-pass 256-entry digit histograms, concatenated across MAX_PASSES * passes. Written by GlobalHistogram, consumed by Scan. * * @type {StorageBuffer|null} */ __publicField(this, "_globalHist", null); /** * Chained-scan lookback buffer: `MAX_PASSES × threadBlocks × RADIX` u32. * Block 0's slot of each pass is initialised by Scan with FLAG_INCLUSIVE * and the global exclusive prefix. Other blocks' slots are populated by * DigitBinningPass. * * @type {StorageBuffer|null} */ __publicField(this, "_passHist", null); /** * Atomic counters for partition-tile assignment, one per pass. * * @type {StorageBuffer|null} */ __publicField(this, "_index", null); /** @type {Vec2} */ __publicField(this, "_binningDispatchSize", new Vec2(1, 1)); /** @type {Vec2} */ __publicField(this, "_globalHistDispatchSize", new Vec2(1, 1)); /** @type {BindGroupFormat|null} */ __publicField(this, "_globalHistBindGroupFormat", null); /** @type {BindGroupFormat|null} */ __publicField(this, "_scanBindGroupFormat", null); /** @type {BindGroupFormat|null} */ __publicField(this, "_binningBindGroupFormat", null); /** @type {UniformBufferFormat|null} */ __publicField(this, "_globalHistUniformFormat", null); /** @type {UniformBufferFormat|null} */ __publicField(this, "_scanUniformFormat", null); /** @type {UniformBufferFormat|null} */ __publicField(this, "_binningUniformFormat", null); /** @type {Shader|null} */ __publicField(this, "_globalHistShader", null); /** @type {Shader|null} */ __publicField(this, "_scanShader", null); /** @type {Shader|null} */ __publicField(this, "_binningShader", null); /** @type {Compute|null} */ __publicField(this, "_globalHistCompute", null); /** @type {Compute|null} */ __publicField(this, "_scanCompute", null); /** @type {Compute[]} */ __publicField(this, "_binningComputes", []); const info = this._indirectInfo; info[0] = 2; info[1] = PART_SIZE; info[2] = G_HIST_PART_SIZE; Debug.assert(device.supportsCompute, "ComputeRadixSortOneSweep requires compute shader support (WebGPU)"); Debug.assert(device.supportsSubgroups, "ComputeRadixSortOneSweep requires subgroup support"); Debug.assert(device.minSubgroupSize <= 32, "ComputeRadixSortOneSweep currently requires runtime subgroup size <= 32 (binning shader uses 32-bit subgroup masks)"); this._globalHistUniformFormat = new UniformBufferFormat(device, [ new UniformFormat("numKeys", UNIFORMTYPE_UINT), new UniformFormat("threadBlocks", UNIFORMTYPE_UINT), new UniformFormat("numPasses", UNIFORMTYPE_UINT), new UniformFormat("_pad", UNIFORMTYPE_UINT) ]); this._scanUniformFormat = new UniformBufferFormat(device, [ new UniformFormat("threadBlocks", UNIFORMTYPE_UINT), new UniformFormat("_pad0", UNIFORMTYPE_UINT), new UniformFormat("_pad1", UNIFORMTYPE_UINT), new UniformFormat("_pad2", UNIFORMTYPE_UINT) ]); this._binningUniformFormat = new UniformBufferFormat(device, [ new UniformFormat("numKeys", UNIFORMTYPE_UINT), new UniformFormat("threadBlocks", UNIFORMTYPE_UINT), new UniformFormat("pass_", UNIFORMTYPE_UINT), new UniformFormat("flags", UNIFORMTYPE_UINT) ]); const minSubgroupSize = device.minSubgroupSize || device.maxSubgroupSize || 32; const maxSubgroups = Math.max(1, Math.ceil(256 / minSubgroupSize)); const suffix = indirect ? "Indirect" : ""; const histGroupEntries = [ new BindStorageBufferFormat("b_sort", SHADERSTAGE_COMPUTE, true), new BindStorageBufferFormat("b_globalHist", SHADERSTAGE_COMPUTE, false), new BindUniformBufferFormat("uniforms", SHADERSTAGE_COMPUTE) ]; const scanGroupEntries = [ new BindStorageBufferFormat("b_globalHist", SHADERSTAGE_COMPUTE, true), new BindStorageBufferFormat("b_passHist", SHADERSTAGE_COMPUTE, false), new BindUniformBufferFormat("uniforms", SHADERSTAGE_COMPUTE) ]; const binGroupEntries = [ new BindStorageBufferFormat("inputKeys", SHADERSTAGE_COMPUTE, true), new BindStorageBufferFormat("outputKeys", SHADERSTAGE_COMPUTE, false), new BindStorageBufferFormat("inputValues", SHADERSTAGE_COMPUTE, true), new BindStorageBufferFormat("outputValues", SHADERSTAGE_COMPUTE, false), new BindStorageBufferFormat("b_passHist", SHADERSTAGE_COMPUTE, false), new BindStorageBufferFormat("b_index", SHADERSTAGE_COMPUTE, false), new BindUniformBufferFormat("uniforms", SHADERSTAGE_COMPUTE) ]; if (indirect) { histGroupEntries.push(new BindStorageBufferFormat("b_sortElementCount", SHADERSTAGE_COMPUTE, true)); scanGroupEntries.push(new BindStorageBufferFormat("b_sortElementCount", SHADERSTAGE_COMPUTE, true)); binGroupEntries.push(new BindStorageBufferFormat("b_sortElementCount", SHADERSTAGE_COMPUTE, true)); } this._globalHistBindGroupFormat = new BindGroupFormat(device, histGroupEntries); this._scanBindGroupFormat = new BindGroupFormat(device, scanGroupEntries); this._binningBindGroupFormat = new BindGroupFormat(device, binGroupEntries); const histDefines = /* @__PURE__ */ new Map(); histDefines.set("{G_HIST_DIM}", G_HIST_DIM); histDefines.set("{G_HIST_PART_SIZE}", G_HIST_PART_SIZE); if (indirect) histDefines.set("USE_INDIRECT_SORT", ""); this._globalHistShader = new Shader(device, { name: `OneSweepGlobalHist${suffix}`, shaderLanguage: SHADERLANGUAGE_WGSL, cshader: onesweepGlobalHistSource, cdefines: histDefines, computeBindGroupFormat: this._globalHistBindGroupFormat, computeUniformBufferFormats: { uniforms: this._globalHistUniformFormat } }); const scanDefines = /* @__PURE__ */ new Map(); scanDefines.set("{MAX_SUBGROUPS}", maxSubgroups); scanDefines.set("{PART_SIZE}", PART_SIZE); if (indirect) scanDefines.set("USE_INDIRECT_SORT", ""); this._scanShader = new Shader(device, { name: `OneSweepScan${suffix}`, shaderLanguage: SHADERLANGUAGE_WGSL, cshader: onesweepScanSource, cdefines: scanDefines, computeBindGroupFormat: this._scanBindGroupFormat, computeUniformBufferFormats: { uniforms: this._scanUniformFormat } }); const binDefines = /* @__PURE__ */ new Map(); binDefines.set("{D_DIM}", D_DIM); binDefines.set("{KEYS_PER_THREAD}", KEYS_PER_THREAD); binDefines.set("{MAX_SUBGROUPS}", maxSubgroups); if (indirect) binDefines.set("USE_INDIRECT_SORT", ""); this._binningShader = new Shader(device, { name: `OneSweepBinning${suffix}`, shaderLanguage: SHADERLANGUAGE_WGSL, cshader: onesweepBinningSource, cdefines: binDefines, computeBindGroupFormat: this._binningBindGroupFormat, computeUniformBufferFormats: { uniforms: this._binningUniformFormat } }); this._globalHistCompute = new Compute(device, this._globalHistShader, this._globalHistShader.name); this._scanCompute = new Compute(device, this._scanShader, this._scanShader.name); } /** * Releases all GPU resources owned by this instance. */ destroy() { this._destroyBuffers(); this._globalHistShader?.destroy(); this._scanShader?.destroy(); this._binningShader?.destroy(); this._globalHistBindGroupFormat?.destroy(); this._scanBindGroupFormat?.destroy(); this._binningBindGroupFormat?.destroy(); this._globalHistShader = null; this._scanShader = null; this._binningShader = null; this._globalHistCompute = null; this._scanCompute = null; this._binningComputes.length = 0; this._globalHistBindGroupFormat = null; this._scanBindGroupFormat = null; this._binningBindGroupFormat = null; this._globalHistUniformFormat = null; this._scanUniformFormat = null; this._binningUniformFormat = null; super.destroy(); } /** @private */ _destroyBuffers() { this._destroyPingPongBuffers(); this._globalHist?.destroy(); this._passHist?.destroy(); this._index?.destroy(); this._globalHist = null; this._passHist = null; this._index = null; this._allocatedThreadBlocks = 0; this._threadBlocks = 0; } /** * Radix width in bits (always 8 for OneSweep). Exposed so callers can * align key-bit counts to the radix boundary generically across sort * backends. * * @type {number} */ get radixBits() { return 8; } /** * Ensures there are enough Compute objects for the requested pass count. * Each pass uses its own Compute (bindings differ because of ping-pong). * * @param {number} numPasses - Number of radix passes. * @private */ _ensureBinningComputes(numPasses) { while (this._binningComputes.length < numPasses) { this._binningComputes.push(new Compute(this.device, this._binningShader, `OneSweepBinning-${this._binningComputes.length}`)); } } /** * Allocates or resizes internal buffers. * * @param {number} elementCount - Number of elements to sort. * @param {boolean} [forceRealloc] - Force buffer reallocation even if sizes match. * @private */ _allocateBuffers(elementCount, forceRealloc = false) { const effectiveCount = Math.max(elementCount, this.capacity); const allocThreadBlocks = Math.max(1, Math.ceil(effectiveCount / PART_SIZE)); const currentThreadBlocks = Math.max(1, Math.ceil(elementCount / PART_SIZE)); const needRealloc = forceRealloc || allocThreadBlocks !== this._allocatedThreadBlocks || !this._keys0; if (needRealloc) { this._destroyBuffers(); this._allocatedThreadBlocks = allocThreadBlocks; this.capacity = effectiveCount; const device = this.device; this._allocatePingPongElementBuffers(effectiveCount); this._globalHist = new StorageBuffer(device, MAX_PASSES * RADIX * 4, BUFFERUSAGE_COPY_DST); this._passHist = new StorageBuffer(device, MAX_PASSES * allocThreadBlocks * RADIX * 4, BUFFERUSAGE_COPY_DST); this._index = new StorageBuffer(device, MAX_PASSES * 4, BUFFERUSAGE_COPY_DST); DebugHelper.setName(this._globalHist, "ComputeRadixSortOnesweep.globalHist"); DebugHelper.setName(this._passHist, "ComputeRadixSortOnesweep.passHist"); DebugHelper.setName(this._index, "ComputeRadixSortOnesweep.index"); } this._threadBlocks = currentThreadBlocks; const maxPerDim = this.device.limits.maxComputeWorkgroupsPerDimension || 65535; Debug.assert( currentThreadBlocks <= maxPerDim, `ComputeRadixSortOneSweep: threadBlocks (${currentThreadBlocks}) exceeds maxComputeWorkgroupsPerDimension (${maxPerDim}). Binning requires an exact 1D dispatch.` ); Compute.calcDispatchSize(currentThreadBlocks, this._binningDispatchSize, maxPerDim); const histBlocks = Math.max(1, Math.ceil(elementCount / G_HIST_PART_SIZE)); Compute.calcDispatchSize(histBlocks, this._globalHistDispatchSize, maxPerDim); } /** * Sorts the keys in `keysBuffer` and returns a storage buffer of sorted * values. Matches the {@link ComputeRadixSort#sort} signature for * drop-in A/B testing. * * @param {StorageBuffer} keysBuffer - Input u32 keys buffer (read-only). * @param {number} elementCount - Number of elements to sort. * @param {number} [numBits] - Number of bits to sort. Must be a multiple * of 8 (the OneSweep radix width is fixed at 8). Defaults to 16. * @param {StorageBuffer} [initialValues] - Optional caller-supplied * initial values for pass 0. When omitted, pass 0 synthesises * sequential indices and the sort returns sorted indices. * @param {boolean} [skipLastPassKeyWrite] - Skip writing sorted keys on * the last pass. Marginal perf win; only use when sorted keys are not * needed. * @param {boolean} [destructiveKeys] - When true, the sort may overwrite * `keysBuffer` after the first pass reads it (saves one internal key * buffer). The caller must not read `keysBuffer` after the sort returns. * @returns {StorageBuffer} Sorted values buffer. */ sort(keysBuffer, elementCount, numBits = 16, initialValues, skipLastPassKeyWrite = false, destructiveKeys = false) { Debug.assert(numBits <= 32, `ComputeRadixSortOneSweep.sort: numBits must be <= 32, got ${numBits}`); const numPasses = numBits / 8; const hasInitialValues = !!initialValues; this._elementCount = elementCount; this._numBits = numBits; this._hasInitialValues = hasInitialValues; this._skipLastPassKeyWrite = skipLastPassKeyWrite; const prevDestructiveKeys = this._destructiveKeys; this._destructiveKeys = destructiveKeys; this._allocateBuffers(elementCount, destructiveKeys !== prevDestructiveKeys); this._ensureBinningComputes(numPasses); if (destructiveKeys) { this._keys1 = keysBuffer; } const device = this.device; this._globalHist.clear(); this._passHist.clear(0, MAX_PASSES * this._threadBlocks * RADIX * 4); this._index.clear(); const histCompute = this._globalHistCompute; histCompute.setParameter("b_sort", keysBuffer); histCompute.setParameter("b_globalHist", this._globalHist); histCompute.setParameter("numKeys", elementCount); histCompute.setParameter("threadBlocks", this._threadBlocks); histCompute.setParameter("numPasses", numPasses); histCompute.setParameter("_pad", 0); histCompute.setupDispatch(this._globalHistDispatchSize.x, this._globalHistDispatchSize.y, 1); device.computeDispatch([histCompute], "OneSweep-GlobalHist"); const scanCompute = this._scanCompute; scanCompute.setParameter("b_globalHist", this._globalHist); scanCompute.setParameter("b_passHist", this._passHist); scanCompute.setParameter("threadBlocks", this._threadBlocks); scanCompute.setParameter("_pad0", 0); scanCompute.setParameter("_pad1", 0); scanCompute.setParameter("_pad2", 0); scanCompute.setupDispatch(numPasses, 1, 1); device.computeDispatch([scanCompute], "OneSweep-Scan"); let currentKeys = keysBuffer; let currentValues = initialValues ?? this._values0; let nextKeys = this._keys0; let nextValues = this._values1; for (let pass = 0; pass < numPasses; pass++) { const isFirstPass = pass === 0 && !hasInitialValues; const isLastPass = pass === numPasses - 1; const flags = (isFirstPass ? 1 : 0) | (isLastPass && skipLastPassKeyWrite ? 2 : 0); const binCompute = this._binningComputes[pass]; binCompute.setParameter("inputKeys", currentKeys); binCompute.setParameter("outputKeys", nextKeys); binCompute.setParameter("inputValues", currentValues); binCompute.setParameter("outputValues", nextValues); binCompute.setParameter("b_passHist", this._passHist); binCompute.setParameter("b_index", this._index); binCompute.setParameter("numKeys", elementCount); binCompute.setParameter("threadBlocks", this._threadBlocks); binCompute.setParameter("pass_", pass); binCompute.setParameter("flags", flags); binCompute.setupDispatch(this._binningDispatchSize.x, this._binningDispatchSize.y, 1); device.computeDispatch([binCompute], `OneSweep-Binning-${pass}`); if (!isLastPass) { currentKeys = nextKeys; nextKeys = currentKeys === this._keys0 ? this._keys1 : this._keys0; const t = currentValues; currentValues = nextValues; nextValues = t; } } return this.sortedIndices; } /** * Indirect-dispatch variant of {@link sort}. Workgroup counts for the * GlobalHistogram and DigitBinningPass kernels are read from the device's * built-in indirect dispatch buffer at consecutive slots starting at * `sortSlotBase`, written beforehand by the caller using the * `writeSortIndirectArgs` WGSL helper (chunk `sortIndirectArgsCS`) with * metadata from {@link prepareIndirect}. `numKeys` and `threadBlocks` * inside the shaders are computed from `sortElementCountBuffer[0]`; the * uniform values of those fields are ignored in indirect mode. * * Buffers are sized for `maxElementCount` (the allocation high-water * mark); the actual sort size may be any value in `[0, maxElementCount]`. * * @param {StorageBuffer} keysBuffer - Input u32 keys buffer (read-only). * @param {number} maxElementCount - Maximum element count; sizes internal * buffers. Must be >= the GPU-written element count. * @param {number} numBits - Number of bits to sort. Must be a multiple of 8. * @param {number} sortSlotBase - Base indirect dispatch slot index. The * backend uses 2 consecutive slots starting here (see {@link prepareIndirect}). * @param {StorageBuffer} sortElementCountBuffer - GPU-written storage * buffer; element `[0]` holds the actual number of keys to sort. * @param {StorageBuffer} [initialValues] - Optional initial values for pass 0. * @param {boolean} [skipLastPassKeyWrite] - Skip writing keys on the last pass. * @param {boolean} [destructiveKeys] - When true, the sort may overwrite * `keysBuffer` after the first pass reads it (saves one internal key * buffer). The caller must not read `keysBuffer` after the sort returns. * @returns {StorageBuffer} Sorted values buffer. */ sortIndirect(keysBuffer, maxElementCount, numBits, sortSlotBase, sortElementCountBuffer, initialValues, skipLastPassKeyWrite = false, destructiveKeys = false) { Debug.assert(numBits <= 32, `ComputeRadixSortOneSweep.sortIndirect: numBits must be <= 32, got ${numBits}`); const numPasses = numBits / 8; const hasInitialValues = !!initialValues; this._elementCount = maxElementCount; this._numBits = numBits; this._hasInitialValues = hasInitialValues; this._skipLastPassKeyWrite = skipLastPassKeyWrite; const prevDestructiveKeys = this._destructiveKeys; this._destructiveKeys = destructiveKeys; this._allocateBuffers(maxElementCount, destructiveKeys !== prevDestructiveKeys); this._ensureBinningComputes(numPasses); if (destructiveKeys) { this._keys1 = keysBuffer; } const device = this.device; this._globalHist.clear(); this._passHist.clear(0, MAX_PASSES * this._allocatedThreadBlocks * RADIX * 4); this._index.clear(); const histCompute = this._globalHistCompute; histCompute.setParameter("b_sort", keysBuffer); histCompute.setParameter("b_globalHist", this._globalHist); histCompute.setParameter("b_sortElementCount", sortElementCountBuffer); histCompute.setParameter("numKeys", 0); histCompute.setParameter("threadBlocks", 0); histCompute.setParameter("numPasses", numPasses); histCompute.setParameter("_pad", 0); histCompute.setupIndirectDispatch(sortSlotBase + 1); device.computeDispatch([histCompute], "OneSweep-GlobalHistIndirect"); const scanCompute = this._scanCompute; scanCompute.setParameter("b_globalHist", this._globalHist); scanCompute.setParameter("b_passHist", this._passHist); scanCompute.setParameter("b_sortElementCount", sortElementCountBuffer); scanCompute.setParameter("threadBlocks", 0); scanCompute.setParameter("_pad0", 0); scanCompute.setParameter("_pad1", 0); scanCompute.setParameter("_pad2", 0); scanCompute.setupDispatch(numPasses, 1, 1); device.computeDispatch([scanCompute], "OneSweep-ScanIndirect"); let currentKeys = keysBuffer; let currentValues = initialValues ?? this._values0; let nextKeys = this._keys0; let nextValues = this._values1; for (let pass = 0; pass < numPasses; pass++) { const isFirstPass = pass === 0 && !hasInitialValues; const isLastPass = pass === numPasses - 1; const flags = (isFirstPass ? 1 : 0) | (isLastPass && skipLastPassKeyWrite ? 2 : 0); const binCompute = this._binningComputes[pass]; binCompute.setParameter("inputKeys", currentKeys); binCompute.setParameter("outputKeys", nextKeys); binCompute.setParameter("inputValues", currentValues); binCompute.setParameter("outputValues", nextValues); binCompute.setParameter("b_passHist", this._passHist); binCompute.setParameter("b_index", this._index); binCompute.setParameter("b_sortElementCount", sortElementCountBuffer); binCompute.setParameter("numKeys", 0); binCompute.setParameter("threadBlocks", 0); binCompute.setParameter("pass_", pass); binCompute.setParameter("flags", flags); binCompute.setupIndirectDispatch(sortSlotBase); device.computeDispatch([binCompute], `OneSweep-BinningIndirect-${pass}`); if (!isLastPass) { currentKeys = nextKeys; nextKeys = currentKeys === this._keys0 ? this._keys1 : this._keys0; const t = currentValues; currentValues = nextValues; nextValues = t; } } return this.sortedIndices; } } export { ComputeRadixSortOneSweep };