From a6a4683f5211202cb79d13187a0802894a8ffa01 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Wed, 4 Mar 2026 13:41:36 +0000 Subject: [PATCH] Fix WebGPU "buffer used in submit while mapped" error in non-unified GSplat path The GSplatSorter uploaded sort results directly from the web worker message callback, which could fire multiple times between GPU submits. When two results arrived before the command buffer was submitted, the staging buffer from the first upload was mapped while still referenced by the unsubmitted encoder, causing a WebGPU validation error. Defer sort result uploads to GSplatInstance.update() via a pendingSorted field (matching the unified sorter pattern). Only the latest result is kept, avoiding both the validation error and redundant GPU uploads. Also adds a submitVersion counter to WebgpuGraphicsDevice and a diagnostic assert in WebgpuUploadStream to catch future violations of the one-upload- per-submit constraint. Made-with: Cursor --- .../graphics/webgpu/webgpu-graphics-device.js | 9 ++++++ .../graphics/webgpu/webgpu-upload-stream.js | 31 ++++++++++++++---- src/scene/gsplat/gsplat-instance.js | 4 +++ src/scene/gsplat/gsplat-sorter.js | 32 ++++++++++++++++--- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/platform/graphics/webgpu/webgpu-graphics-device.js b/src/platform/graphics/webgpu/webgpu-graphics-device.js index 8730ad4c93a..d817370840f 100644 --- a/src/platform/graphics/webgpu/webgpu-graphics-device.js +++ b/src/platform/graphics/webgpu/webgpu-graphics-device.js @@ -151,6 +151,14 @@ class WebgpuGraphicsDevice extends GraphicsDevice { */ emptyBindGroup; + /** + * Monotonically increasing counter incremented each time queue.submit() is called. + * + * @type {number} + * @ignore + */ + submitVersion = 0; + /** * Current command buffer encoder. * @@ -1117,6 +1125,7 @@ class WebgpuGraphicsDevice extends GraphicsDevice { this.wgpu.queue.submit(this.commandBuffers); this.commandBuffers.length = 0; + this.submitVersion++; // notify dynamic buffers this.dynamicBuffers.onCommandBuffersSubmitted(); diff --git a/src/platform/graphics/webgpu/webgpu-upload-stream.js b/src/platform/graphics/webgpu/webgpu-upload-stream.js index 5c8e5a03ab5..72b9591eb6f 100644 --- a/src/platform/graphics/webgpu/webgpu-upload-stream.js +++ b/src/platform/graphics/webgpu/webgpu-upload-stream.js @@ -31,6 +31,15 @@ class WebgpuUploadStream { _destroyed = false; + /** + * The device's _submitVersion at the time the last staging copy was recorded. + * Used to detect whether the copy has been submitted before the next upload. + * + * @type {number} + * @private + */ + _lastUploadSubmitVersion = -1; + /** * @param {UploadStream} uploadStream - The upload stream. */ @@ -138,6 +147,18 @@ class WebgpuUploadStream { const byteOffset = offset * data.BYTES_PER_ELEMENT; const byteSize = size * data.BYTES_PER_ELEMENT; + // Detect when a previous staging copy is still on an unsubmitted command buffer. + // update() will call mapAsync on that buffer, putting it in "mapping pending" state, + // which causes WebGPU validation errors ("buffer used in submit while mapped") when + // the command buffer is eventually submitted. + if (this.pendingStagingBuffers.length > 0) { + // @ts-ignore - submitVersion is available on WebgpuGraphicsDevice + Debug.assert(device.submitVersion !== this._lastUploadSubmitVersion, + 'UploadStream: each instance can only upload once per submit. A previous staging ' + + 'buffer copy has not been submitted yet. This causes WebGPU "buffer used in submit ' + + 'while mapped" errors. Ensure the caller defers uploads to one per frame.'); + } + // Update staging buffers this.update(byteSize); @@ -170,15 +191,11 @@ class WebgpuUploadStream { byteSize ); - // Detect multiple uploads per frame (indicates command buffer hasn't been submitted yet) - Debug.assert( - this.pendingStagingBuffers.length === 0, - 'Multiple WebGPU staging buffer uploads detected in the same frame before command buffer submission. ' + - 'This can cause "buffer used while mapped" errors. Ensure only one upload occurs per frame.' - ); - // Track for recycling this.pendingStagingBuffers.push(buffer); + + // @ts-ignore - submitVersion is available on WebgpuGraphicsDevice + this._lastUploadSubmitVersion = device.submitVersion; } } diff --git a/src/scene/gsplat/gsplat-instance.js b/src/scene/gsplat/gsplat-instance.js index f66b92dc69f..d2a44e72319 100644 --- a/src/scene/gsplat/gsplat-instance.js +++ b/src/scene/gsplat/gsplat-instance.js @@ -214,6 +214,10 @@ class GSplatInstance { } update() { + + // Apply deferred sort results (at most one upload per frame). + this.sorter?.applyPendingSorted(); + if (this.cameras.length > 0) { // sort by the first camera it's visible for diff --git a/src/scene/gsplat/gsplat-sorter.js b/src/scene/gsplat/gsplat-sorter.js index b13e71f3eb8..6d35ab265ca 100644 --- a/src/scene/gsplat/gsplat-sorter.js +++ b/src/scene/gsplat/gsplat-sorter.js @@ -25,6 +25,14 @@ class GSplatSorter extends EventHandler { /** @type {UploadStream} */ uploadStream; + /** + * Pending sorted result from the worker, applied on the next applyPendingSorted() call. + * When multiple results arrive between frames, only the latest is kept. + * + * @type {{ count: number, data: Uint32Array }|null} + */ + pendingSorted = null; + /** * @param {GraphicsDevice} device - The graphics device. * @param {import('../scene.js').Scene} [scene] - The scene to fire sort timing events on. @@ -49,12 +57,13 @@ class GSplatSorter extends EventHandler { order: oldOrder }, [oldOrder]); - // upload new order data to GPU - const data = new Uint32Array(newOrder); + // Store result for deferred application. Only the latest result is kept, + // avoiding redundant uploads when multiple worker messages arrive between frames. this.orderData = newOrder; - this.uploadStream.upload(data, this.target); - - this.fire('updated', msgData.count); + this.pendingSorted = { + count: msgData.count, + data: new Uint32Array(newOrder) + }; }; const workerSource = `(${SortWorker.toString()})()`; @@ -108,6 +117,19 @@ class GSplatSorter extends EventHandler { this.worker.postMessage(obj, transfer); } + /** + * Applies the most recent pending sorted result (if any), uploading to GPU + * and firing the 'updated' event. Call once per frame from the instance's update(). + */ + applyPendingSorted() { + if (this.pendingSorted) { + const { count, data } = this.pendingSorted; + this.pendingSorted = null; + this.uploadStream.upload(data, this.target); + this.fire('updated', count); + } + } + setMapping(mapping) { if (mapping) { const centers = new Float32Array(mapping.length * 3);