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);