From 014602c86671bd72ea7dcf0c33f2dec1892667d5 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Wed, 1 Apr 2026 12:25:36 +0100 Subject: [PATCH 1/2] feat: add GSplatParams.renderer enum for runtime renderer selection Replace the internal USE_LOCAL_COMPUTE_RENDERER constant and gpuSorting boolean with a single renderer enum property that controls the GSplat rendering pipeline and supports runtime switching. Made-with: Cursor --- .../lod-streaming.controls.mjs | 27 +++-- .../lod-streaming.example.mjs | 18 ++-- .../gaussian-splatting/world.example.mjs | 4 +- src/scene/constants.js | 33 ++++++ src/scene/gsplat-unified/gsplat-manager.js | 100 +++++++++--------- src/scene/gsplat-unified/gsplat-params.js | 65 +++++++++++- 6 files changed, 168 insertions(+), 79 deletions(-) diff --git a/examples/src/examples/gaussian-splatting/lod-streaming.controls.mjs b/examples/src/examples/gaussian-splatting/lod-streaming.controls.mjs index 55c133170b6..e277a8bc029 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming.controls.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming.controls.mjs @@ -4,7 +4,6 @@ */ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { const { BindingTwoWay, LabelGroup, BooleanInput, Panel, SelectInput, SliderInput, Label } = ReactPCUI; - const isWebGPU = observer.get('isWebGPU'); return fragment( jsx( Panel, @@ -65,6 +64,22 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { jsx( Panel, { headerText: 'Settings' }, + jsx( + LabelGroup, + { text: 'Renderer' }, + jsx(SelectInput, { + type: 'number', + binding: new BindingTwoWay(), + link: { observer, path: 'renderer' }, + value: observer.get('renderer') ?? 0, + options: [ + { v: 0, t: 'Auto' }, + { v: 1, t: 'Raster (CPU Sort)' }, + { v: 2, t: 'Raster (GPU Sort)' }, + { v: 3, t: 'Compute' } + ] + }) + ), jsx( LabelGroup, { text: 'Min Pixel Size' }, @@ -86,16 +101,6 @@ export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => { value: observer.get('radialSorting') ?? true }) ), - isWebGPU && jsx( - LabelGroup, - { text: 'GPU Sorting' }, - jsx(BooleanInput, { - type: 'toggle', - binding: new BindingTwoWay(), - link: { observer, path: 'gpuSorting' }, - value: observer.get('gpuSorting') || false - }) - ), jsx( LabelGroup, { text: 'Compact' }, diff --git a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs index 98ca8ded104..8f9fdf3370a 100644 --- a/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs +++ b/examples/src/examples/gaussian-splatting/lod-streaming.example.mjs @@ -112,9 +112,6 @@ const assets = { ) }; -// Set device type flag before controls render (controls read this synchronously) -data.set('isWebGPU', device.isWebGPU); - const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets); assetListLoader.load(() => { app.start(); @@ -133,12 +130,13 @@ assetListLoader.load(() => { app.scene.gsplat.lodUpdateDistance = config.lodUpdateDistance; app.scene.gsplat.lodUnderfillLimit = config.lodUnderfillLimit; - // GPU sorting is a WebGPU-only feature - if (device.isWebGPU) { - data.on('gpuSorting:set', () => { - app.scene.gsplat.gpuSorting = !!data.get('gpuSorting'); - }); - } + data.on('renderer:set', () => { + app.scene.gsplat.renderer = data.get('renderer'); + const current = app.scene.gsplat.currentRenderer; + if (current !== data.get('renderer')) { + data.set('renderer', current); + } + }); data.on('minPixelSize:set', () => { app.scene.gsplat.minPixelSize = data.get('minPixelSize'); }); @@ -155,7 +153,7 @@ assetListLoader.load(() => { data.set('exposure', 1.5); data.set('minPixelSize', 2); data.set('radialSorting', true); - data.set('gpuSorting', false); + data.set('renderer', pc.GSPLAT_RENDERER_AUTO); data.set('culling', device.isWebGPU); data.set('compact', true); data.set('debugLod', false); diff --git a/examples/src/examples/gaussian-splatting/world.example.mjs b/examples/src/examples/gaussian-splatting/world.example.mjs index e091c38b4ea..b2a8c690a69 100644 --- a/examples/src/examples/gaussian-splatting/world.example.mjs +++ b/examples/src/examples/gaussian-splatting/world.example.mjs @@ -88,9 +88,9 @@ const assets = { const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets); assetListLoader.load(() => { - // Enable GPU sorting (desktop only for now) + // Use GPU sorting on desktop (experimental, WebGPU only) if (!pc.platform.mobile) { - app.scene.gsplat.gpuSorting = true; + app.scene.gsplat.renderer = pc.GSPLAT_RENDERER_RASTER_GPU_SORT; } app.start(); diff --git a/src/scene/constants.js b/src/scene/constants.js index bf8a0aa8cd4..b31ebcd0c7c 100644 --- a/src/scene/constants.js +++ b/src/scene/constants.js @@ -1213,3 +1213,36 @@ export const GSPLATDATA_LARGE = 'large'; * @category Graphics */ export const GSPLATDATA_COMPACT = 'compact'; + +/** + * Automatically selects the best rendering pipeline for the current platform. + * + * @type {number} + * @category Graphics + */ +export const GSPLAT_RENDERER_AUTO = 0; + +/** + * Rasterization-based rendering with CPU-side sorting. + * + * @type {number} + * @category Graphics + */ +export const GSPLAT_RENDERER_RASTER_CPU_SORT = 1; + +/** + * Rasterization-based rendering with compute shader sorting. WebGPU only. Experimental with + * limited functionality. + * + * @type {number} + * @category Graphics + */ +export const GSPLAT_RENDERER_RASTER_GPU_SORT = 2; + +/** + * Full compute pipeline for rendering. WebGPU only. Experimental with limited functionality. + * + * @type {number} + * @category Graphics + */ +export const GSPLAT_RENDERER_COMPUTE = 3; diff --git a/src/scene/gsplat-unified/gsplat-manager.js b/src/scene/gsplat-unified/gsplat-manager.js index f9fef628ba0..5e247ca3cf1 100644 --- a/src/scene/gsplat-unified/gsplat-manager.js +++ b/src/scene/gsplat-unified/gsplat-manager.js @@ -16,6 +16,9 @@ import { GSplatIntervalCompaction } from './gsplat-interval-compaction.js'; import { ComputeRadixSort } from '../graphics/compute-radix-sort.js'; import { Debug } from '../../core/debug.js'; import { BoundingBox } from '../../core/shape/bounding-box.js'; +import { + GSPLAT_RENDERER_RASTER_CPU_SORT, GSPLAT_RENDERER_RASTER_GPU_SORT, GSPLAT_RENDERER_COMPUTE +} from '../constants.js'; import { Color } from '../../core/math/color.js'; import { GSplatBudgetBalancer } from './gsplat-budget-balancer.js'; import { BlockAllocator } from '../../core/block-allocator.js'; @@ -32,8 +35,6 @@ import { BlockAllocator } from '../../core/block-allocator.js'; * @import { GSplatRenderer } from './gsplat-renderer.js' */ -const USE_LOCAL_COMPUTE_RENDERER = false; - const cameraPosition = new Vec3(); const cameraDirection = new Vec3(); const translation = new Vec3(); @@ -131,19 +132,12 @@ class GSplatManager { lastWorldStateVersion = 0; /** - * Whether to use GPU-based sorting (WebGPU only). Starts as undefined so the first - * prepareSortMode() call always creates the appropriate resources. + * The currently active renderer mode. Starts as undefined so the first + * prepareRendererMode() call always creates the appropriate resources. * - * @type {boolean|undefined} - */ - useGpuSorting; - - /** - * Whether the local compute renderer is active (compaction-only, no sorting). - * - * @type {boolean} + * @type {number|undefined} */ - useLocalRenderer = false; + activeRenderer; /** * CPU-based sorter (when not using GPU sorting). @@ -384,20 +378,10 @@ class GSplatManager { this._allocator = new BlockAllocator(budget > 0 ? Math.ceil(budget * allocatorGrowMultiplier) : 0, allocatorGrowMultiplier); this.workBuffer = new GSplatWorkBuffer(device, this.scene.gsplat.format); - if (USE_LOCAL_COMPUTE_RENDERER && device.isWebGPU) { - this.renderer = new GSplatComputeLocalRenderer(device, this.node, this.cameraNode, layer, this.workBuffer); - this.useLocalRenderer = true; - this.useGpuSorting = true; - } else { - this.renderer = new GSplatQuadRenderer(device, this.node, this.cameraNode, layer, this.workBuffer); - this.useLocalRenderer = false; - } - this._workBufferFormatVersion = this.workBuffer.format.extraStreamsVersion; - // Local renderer handles its own compaction; skip full sort initialization. - if (!this.useLocalRenderer) { - this.prepareSortMode(); - } + this.layer = layer; + this._createRenderer(this.scene.gsplat.currentRenderer); + this._workBufferFormatVersion = this.workBuffer.format.extraStreamsVersion; } destroy() { @@ -525,7 +509,7 @@ class GSplatManager { * @returns {import('../mesh-instance.js').MeshInstance|null} The pick mesh instance, or null. */ prepareForPicking(camera, width, height) { - if (!this.useLocalRenderer) return null; + if (this.activeRenderer !== GSPLAT_RENDERER_COMPUTE) return null; /** @type {GSplatComputeLocalRenderer} */ const localRenderer = /** @type {any} */ (this.renderer); @@ -564,31 +548,48 @@ class GSplatManager { * @private */ get canCull() { - return (this.useLocalRenderer || !!this.useGpuSorting) && + return this.activeRenderer !== GSPLAT_RENDERER_RASTER_CPU_SORT && this.workBuffer.frustumCuller.totalBoundsEntries > 0; } /** - * Prepares the sorting mode by matching it to the current gpuSorting setting. Lazily creates - * GPU or CPU sorting resources as needed and handles transitions between modes. + * Creates the renderer and sort resources for the given mode. Used at init time. * + * @param {number} mode - The GSPLAT_RENDERER_* constant. * @private */ - prepareSortMode() { - const gpuSorting = this.device.isWebGPU && this.scene.gsplat.gpuSorting; - if (gpuSorting !== this.useGpuSorting) { - - if (gpuSorting) { - this.destroyCpuSorting(); + _createRenderer(mode) { + if (mode === GSPLAT_RENDERER_COMPUTE) { + this.renderer = new GSplatComputeLocalRenderer(this.device, this.node, this.cameraNode, this.layer, this.workBuffer); + } else { + this.renderer = new GSplatQuadRenderer(this.device, this.node, this.cameraNode, this.layer, this.workBuffer); + if (mode === GSPLAT_RENDERER_RASTER_GPU_SORT) { this.initGpuSorting(); } else { - this.destroyGpuSorting(); this.initCpuSorting(); } - - this.useGpuSorting = gpuSorting; - this.sortNeeded = true; } + this.activeRenderer = mode; + } + + /** + * Checks whether the resolved renderer mode has changed and transitions to the new mode. + * Handles both sort-mode transitions (CPU <-> GPU sort) and full renderer swaps + * (quad <-> compute). + * + * @private + */ + prepareRendererMode() { + const requested = this.scene.gsplat.currentRenderer; + if (requested === this.activeRenderer) return; + + this.destroyGpuSorting(); + this.destroyCpuSorting(); + this.renderer.destroy(); + this._createRenderer(requested); + this.renderer.setRenderMode(this.renderMode); + this._workBufferRebuildRequired = true; + this.sortNeeded = true; } /** @@ -822,7 +823,7 @@ class GSplatManager { // Bounds and transforms storage buffers are needed for frustum culling, // which only runs with interval compaction (local renderer or GPU sorting). - if (this.useLocalRenderer || this.useGpuSorting) { + if (this.activeRenderer !== GSPLAT_RENDERER_RASTER_CPU_SORT) { this.workBuffer.frustumCuller.updateBoundsData(worldState.boundsGroups); this.workBuffer.frustumCuller.updateTransformsData(worldState.boundsGroups); } @@ -1293,11 +1294,8 @@ class GSplatManager { this.sortNeeded = true; } - // Prepare sorting mode for current gpuSorting setting (may switch GPU <-> CPU). - // Skipped for the local renderer — it handles its own compaction without sorting. - if (!this.useLocalRenderer) { - this.prepareSortMode(); - } + // Check for runtime renderer mode changes and transition if needed. + this.prepareRendererMode(); // apply any pending sorted results (CPU path only) if (this.cpuSorter) { @@ -1305,7 +1303,7 @@ class GSplatManager { } // GPU sorting is always ready, CPU sorting is ready if not too many jobs in flight - const sorterAvailable = this.useLocalRenderer || this.useGpuSorting || (this.cpuSorter && this.cpuSorter.jobsInFlight < 3); + const sorterAvailable = this.activeRenderer !== GSPLAT_RENDERER_RASTER_CPU_SORT || (this.cpuSorter && this.cpuSorter.jobsInFlight < 3); // full update every 10 frames let fullUpdate = false; @@ -1478,11 +1476,11 @@ class GSplatManager { // kick off sorting / compaction only if needed let gpuSortedThisFrame = false; if (this.sortNeeded && lastState) { - if (this.useLocalRenderer) { - // Local renderer: run compaction only (no key generation or radix sort) + if (this.activeRenderer === GSPLAT_RENDERER_COMPUTE) { + // Compute renderer: run compaction only (no key generation or radix sort) this.compactGpu(lastState); gpuSortedThisFrame = true; - } else if (this.useGpuSorting) { + } else if (this.activeRenderer === GSPLAT_RENDERER_RASTER_GPU_SORT) { // GPU sort runs compaction internally, so indirect draw is always valid this.sortGpu(lastState); gpuSortedThisFrame = true; @@ -1503,7 +1501,7 @@ class GSplatManager { // Refresh the per-frame indirect draw slot on non-sort frames // (sortGpu already handled GPU-sort frames). - if (!this.useLocalRenderer && this.intervalCompaction && !gpuSortedThisFrame) { + if (this.activeRenderer !== GSPLAT_RENDERER_COMPUTE && this.intervalCompaction && !gpuSortedThisFrame) { this.refreshIndirectDraw(); } diff --git a/src/scene/gsplat-unified/gsplat-params.js b/src/scene/gsplat-unified/gsplat-params.js index e9e0dc577ef..a85bc30b603 100644 --- a/src/scene/gsplat-unified/gsplat-params.js +++ b/src/scene/gsplat-unified/gsplat-params.js @@ -2,9 +2,14 @@ import { PIXELFORMAT_R32U, PIXELFORMAT_RGBA16F, PIXELFORMAT_RGBA16U, PIXELFORMAT_RGBA32U, PIXELFORMAT_RG32U } from '../../platform/graphics/constants.js'; +import { Debug } from '../../core/debug.js'; import { ShaderMaterial } from '../materials/shader-material.js'; import { GSplatFormat } from '../gsplat/gsplat-format.js'; -import { GSPLATDATA_COMPACT } from '../constants.js'; +import { + GSPLATDATA_COMPACT, + GSPLAT_RENDERER_AUTO, GSPLAT_RENDERER_RASTER_CPU_SORT, + GSPLAT_RENDERER_RASTER_GPU_SORT, GSPLAT_RENDERER_COMPUTE +} from '../constants.js'; import glslCompactRead from '../shader-lib/glsl/chunks/gsplat/vert/formats/containerCompactRead.js'; import glslCompactWrite from '../shader-lib/glsl/chunks/gsplat/frag/formats/containerCompactWrite.js'; @@ -129,12 +134,62 @@ class GSplatParams { radialSorting = false; /** - * Enables GPU-based sorting using compute shaders. WebGPU only. + * @type {number} + * @private + */ + _renderer = GSPLAT_RENDERER_AUTO; + + /** + * @type {number} + * @private + */ + _currentRenderer = GSPLAT_RENDERER_RASTER_CPU_SORT; + + /** + * The rendering pipeline used for gaussian splatting. Can be: * - * @type {boolean} - * @ignore + * - {@link GSPLAT_RENDERER_AUTO}: Automatically selects the best pipeline for the platform. + * - {@link GSPLAT_RENDERER_RASTER_CPU_SORT}: Rasterization with CPU-side sorting. + * - {@link GSPLAT_RENDERER_RASTER_GPU_SORT}: Rasterization with compute shader sorting + * (WebGPU only, experimental). + * - {@link GSPLAT_RENDERER_COMPUTE}: Full compute pipeline (WebGPU only, experimental). + * + * Defaults to {@link GSPLAT_RENDERER_AUTO}. Modes requiring WebGPU fall back to + * {@link GSPLAT_RENDERER_RASTER_CPU_SORT} on WebGL devices. + * + * @type {number} */ - gpuSorting = false; + set renderer(value) { + if (this._renderer !== value) { + this._renderer = value; + + if (value === GSPLAT_RENDERER_AUTO) { + this._currentRenderer = GSPLAT_RENDERER_RASTER_CPU_SORT; + } else if ((value === GSPLAT_RENDERER_RASTER_GPU_SORT || value === GSPLAT_RENDERER_COMPUTE) && + !this._device.isWebGPU) { + Debug.warnOnce(`GSplatParams: renderer mode ${value} requires WebGPU, falling back to GSPLAT_RENDERER_RASTER_CPU_SORT.`); + this._currentRenderer = GSPLAT_RENDERER_RASTER_CPU_SORT; + } else { + this._currentRenderer = value; + } + } + } + + get renderer() { + return this._renderer; + } + + /** + * The current rendering pipeline in effect after platform-based fallback resolution. When + * {@link renderer} is set to a mode requiring WebGPU on a WebGL device, this returns the + * fallback mode actually being used. + * + * @type {number} + * @readonly + */ + get currentRenderer() { + return this._currentRenderer; + } /** * Enables debug rendering of AABBs for GSplat octree nodes. Defaults to false. From d4c49526bd18cebe7525ba1a15228c6b9c567299 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Wed, 1 Apr 2026 12:29:07 +0100 Subject: [PATCH 2/2] types --- src/scene/gsplat-unified/gsplat-params.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/scene/gsplat-unified/gsplat-params.js b/src/scene/gsplat-unified/gsplat-params.js index a85bc30b603..ea27556ce20 100644 --- a/src/scene/gsplat-unified/gsplat-params.js +++ b/src/scene/gsplat-unified/gsplat-params.js @@ -185,7 +185,6 @@ class GSplatParams { * fallback mode actually being used. * * @type {number} - * @readonly */ get currentRenderer() { return this._currentRenderer;