Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/platform/graphics/webgpu/webgpu-graphics-device.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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();
Expand Down
31 changes: 24 additions & 7 deletions src/platform/graphics/webgpu/webgpu-upload-stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/scene/gsplat/gsplat-instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 27 additions & 5 deletions src/scene/gsplat/gsplat-sorter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()})()`;
Expand Down Expand Up @@ -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);
Expand Down