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
35 changes: 29 additions & 6 deletions packages/exojs-tilemap/src/webgpu/WebGpuTileChunkRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,12 +683,35 @@ export class WebGpuTileChunkRenderer extends AbstractWebGpuRenderer<TileChunkNod
const active = coordinator.acquirePass();
const pass = active.pass;

pass.setPipeline(this._getPipeline(payload.blendMode, backend.renderTargetFormat, coordinator.stencilActive));
pass.setBindGroup(0, bundle.getBindGroup(device, this._uniformBindGroupLayout!, false));
pass.setBindGroup(1, textureBindGroup);
pass.setVertexBuffer(0, bundle.instanceBuffer, payload.byteOffset);
pass.setIndexBuffer(this._indexBuffer, 'uint16');
pass.drawIndexed(indicesPerInstance, payload.instanceCount, 0, 0, 0);
const nativePipeline = this._getPipeline(payload.blendMode, backend.renderTargetFormat, coordinator.stencilActive);
const nativeFrameBindGroup = bundle.getBindGroup(device, this._uniformBindGroupLayout!, false);

const nativeCompatible = backend.colorAttachmentCount === 1;
if (!nativeCompatible) bundle.nativeReplay.skipPass();

if (
!nativeCompatible ||
!bundle.nativeReplay.draw(
device,
active,
payload,
backend.renderTargetFormat,
nativePipeline,
nativeFrameBindGroup,
textureBindGroup,
this._indexBuffer,
'uint16',
indicesPerInstance,
payload.instanceCount,
)
) {
pass.setPipeline(nativePipeline);
pass.setBindGroup(0, nativeFrameBindGroup);
pass.setBindGroup(1, textureBindGroup);
pass.setVertexBuffer(0, bundle.instanceBuffer, payload.byteOffset);
pass.setIndexBuffer(this._indexBuffer, 'uint16');
pass.drawIndexed(indicesPerInstance, payload.instanceCount, 0, 0, 0);
}

bundle.drawsInPass = active;
coordinator.markPassDraws();
Expand Down
6 changes: 6 additions & 0 deletions site/src/content/guide/rendering/retained-containers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ On the first clean frame after a capture, each backend (WebGL2 and WebGPU alike,

That is what makes the cost **O(batches)** rather than O(nodes) in the group. It is also why rendering the same retained subtree through more than one [`View`](/ExoJS/en/api/view/) at once — split-screen, a minimap, picture-in-picture — stays cheap: each additional view replays the same recorded batches, it does not re-walk or re-collect the subtree per view. A single descendant transform move still patches just that node's row in place (see below) rather than dropping the whole recording; any structural change (add/remove/texture swap) drops the recording and the next clean frame re-records it from a fresh entry replay.

### Adaptive WebGPU command reuse

WebGPU can promote sufficiently large, stable recorded groups to native render bundles. Supported sprite, scalable-sprite geometry, text and tilemap batches then reuse their encoded draw commands while camera, transform and uniform-buffer updates remain live. Promotion observes consecutive replay frames and limits construction per frame; its thresholds are internal heuristics, not an API or a guarantee of faster rendering.

Recording replacement or resource identity changes discard native commands and restart observation. Stencil, writable-depth and multiple-color-attachment passes use ordinary recorded replay. Device invalidation and group destruction release the cached commands. There is no application setting to enable this tier, and WebGL2 continues to use recorded-batch replay. Measure your actual workload: stable history does not guarantee enough future reuse to recover native construction cost.

<Callout type="hint" title="Measured, not assumed">
On a real-GPU run (WebGL2 and WebGPU, both backends), a 100k-sprite retained `static-heavy` scene costs 0.215 ms (WebGL2) and 0.252 ms (WebGPU) per frame — the two backends within ~1.17× of each other, both near the CPU-timer floor. On `batch-breaking` (a scene deliberately designed to defeat sprite-batching with many distinct textures) at 25k retained nodes, WebGL2 costs 23.7 ms and WebGPU 4.6 ms — WebGPU is the faster backend on this workload. Both are the recorded-batch tier at work: cost tracks batch count, not node count.
</Callout>
Expand Down
5 changes: 5 additions & 0 deletions src/rendering/webgpu/WebGpuBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ export class WebGpuBackend implements RenderBackend {
private _texture: Texture | RenderTexture | null = null;
private _clearRequested = false;
private _hasPresentedFrame = false;
private readonly _nativeReplayFrame = { id: 0, remainingBuilds: 32 };
private readonly _stats: RenderStats = createRenderStats();
private readonly _accountant: GpuResourceAccountant = new GpuResourceAccountant(this._stats);
private _transformStorage: WebGpuTransformStorage | null = new WebGpuTransformStorage();
Expand Down Expand Up @@ -671,6 +672,8 @@ export class WebGpuBackend implements RenderBackend {
}

public resetStats(): this {
this._nativeReplayFrame.id++;
this._nativeReplayFrame.remainingBuilds = 32;
resetRenderStats(this._stats);
// The transform buffer is frame-scoped: reset it once per frame here (was
// previously reset per render() call in _beginDrawPlan).
Expand Down Expand Up @@ -1995,6 +1998,7 @@ export class WebGpuBackend implements RenderBackend {
// RetainedBatchInstruction), not its instance count.
this._stats.submittedNodes += batch.nodeCount ?? batch.instanceCount;
this._setActiveRenderer(payload.renderer);
payload.bundle.nativeReplay.beginFrame(this._nativeReplayFrame);
payload.renderer.replayRetainedBatch(payload);
}

Expand Down Expand Up @@ -2263,6 +2267,7 @@ export class WebGpuBackend implements RenderBackend {
// Growth is safe against the open pass: a bundle can only be re-recorded
// on a frame whose set was invalid at collect time, so no draw recorded
// into the open pass references the buffers replaced here.
bundle.nativeReplay.invalidate();
bundle.ensureCapacity(device, frame.totalBytes, transformBytes, tintBytes);

for (const batch of staged) {
Expand Down
165 changes: 165 additions & 0 deletions src/rendering/webgpu/WebGpuNativeRetainedReplay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import type { WebGpuRetainedBatchPayload } from './retainedGroupResources';
import type { WebGpuActiveRenderPass } from './WebGpuPassCoordinator';

export interface WebGpuNativeRetainedReplayFrame {
readonly id: number;
remainingBuilds: number;
}

interface NativeEntry {
readonly device: GPUDevice;
readonly colorFormat: GPUTextureFormat;
readonly pipeline: GPURenderPipeline;
readonly group0: GPUBindGroup;
readonly group1: GPUBindGroup;
readonly group2: GPUBindGroup | null;
readonly vertexBuffer: GPUBuffer;
readonly byteOffset: number;
readonly indexBuffer: GPUBuffer;
readonly indexFormat: GPUIndexFormat;
readonly indexCount: number;
readonly instanceCount: number;
seenFrame: number;
bundles: GPURenderBundle[] | null;
}

/** Group-owned acceleration for stable retained draws; false leaves encoding to the caller. */
export class WebGpuNativeRetainedReplay {
private readonly _entries = new Map<WebGpuRetainedBatchPayload, NativeEntry>();
private _frame: WebGpuNativeRetainedReplayFrame | null = null;
private _lastFrame = -1;
private _observationFrame = -1;
private _stableFrames = 0;
private _seenCount = 0;
private _fallbackFrame = false;

public beginFrame(frame: WebGpuNativeRetainedReplayFrame): void {
if (frame.id !== this._lastFrame) {
if (frame.id !== this._lastFrame + 1 || (!this._fallbackFrame && this._seenCount !== this._entries.size)) {
this.invalidate();
} else if (!this._fallbackFrame && this._entries.size >= 32) {
this._stableFrames++;
}

this._seenCount = 0;
this._fallbackFrame = false;
this._lastFrame = frame.id;
}

this._frame = frame;
}

/** Drops references to recorded commands and restarts stability observation. */
public invalidate(): void {
this._entries.clear();
this._observationFrame = -1;
this._stableFrames = 0;
this._seenCount = 0;
this._fallbackFrame = false;
}

/** Pauses observation for a pass whose commands cannot use the normal-pass cache. */
public skipPass(): void {
this._fallbackFrame = true;
}

public draw(
device: GPUDevice,
activePass: WebGpuActiveRenderPass,
payload: WebGpuRetainedBatchPayload,
colorFormat: GPUTextureFormat,
pipeline: GPURenderPipeline,
group0: GPUBindGroup,
group1: GPUBindGroup,
indexBuffer: GPUBuffer,
indexFormat: GPUIndexFormat,
indexCount: number,
instanceCount: number,
group2: GPUBindGroup | null = null,
): boolean {
const frame = this._frame;
const vertexBuffer = payload.bundle.instanceBuffer;

if (frame === null || vertexBuffer === null) {
this.invalidate();
return false;
}

if (activePass.stencilEnabled || activePass.depthWrites) {
this.skipPass();
return false;
}

let entry = this._entries.get(payload);

if (
entry !== undefined &&
(entry.device !== device ||
entry.colorFormat !== colorFormat ||
entry.pipeline !== pipeline ||
entry.group0 !== group0 ||
entry.group1 !== group1 ||
entry.group2 !== group2 ||
entry.vertexBuffer !== vertexBuffer ||
entry.byteOffset !== payload.byteOffset ||
entry.indexBuffer !== indexBuffer ||
entry.indexFormat !== indexFormat ||
entry.indexCount !== indexCount ||
entry.instanceCount !== instanceCount)
) {
this.invalidate();
entry = undefined;
}

if (entry === undefined) {
// The first observed frame establishes the complete batch set. Later additions
// invalidate that set so partial or changing replays never accumulate age.
if (this._observationFrame !== -1 && this._observationFrame !== this._lastFrame) this.invalidate();
this._observationFrame = this._lastFrame;
entry = {
device,
colorFormat,
pipeline,
group0,
group1,
group2,
vertexBuffer,
byteOffset: payload.byteOffset,
indexBuffer,
indexFormat,
indexCount,
instanceCount,
seenFrame: this._lastFrame,
bundles: null,
};
this._entries.set(payload, entry);
this._seenCount++;
return false;
}

if (entry.seenFrame !== this._lastFrame) {
entry.seenFrame = this._lastFrame;
this._seenCount++;
}

if (this._stableFrames < 30 || this._entries.size < 32) return false;

if (entry.bundles === null) {
if (frame.remainingBuilds <= 0) return false;
frame.remainingBuilds--;

const encoder = device.createRenderBundleEncoder({ colorFormats: [colorFormat] });
encoder.setPipeline(pipeline);
encoder.setBindGroup(0, group0);
encoder.setBindGroup(1, group1);
if (group2 !== null) encoder.setBindGroup(2, group2);
encoder.setVertexBuffer(0, vertexBuffer, payload.byteOffset);
encoder.setIndexBuffer(indexBuffer, indexFormat);
encoder.drawIndexed(indexCount, instanceCount);
entry.bundles = [encoder.finish()];
}

activePass.pass.executeBundles(entry.bundles);
return true;
}
}
29 changes: 25 additions & 4 deletions src/rendering/webgpu/WebGpuRetainedGroupBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DirtyRowTracker } from './DirtyRowTracker';
import type { WebGpuRetainedRendererReplayState } from './retainedGroupResources';
import { retainedGroupUniformBytes, retainedTintSlotBytes, retainedTransformSlotBytes } from './retainedGroupResources';
import { requireRepresentableStorageGrowth } from './storageLimits';
import { WebGpuNativeRetainedReplay } from './WebGpuNativeRetainedReplay';
import type { WebGpuActiveRenderPass } from './WebGpuPassCoordinator';

/** Power-of-two growth from `current` to at least `min` (min 256 B). */
Expand Down Expand Up @@ -35,7 +36,7 @@ const rowsPerPatchBlock = 64;
* the shared sprite UBO (projection mat4 + group mat4) so the existing
* bind-group(0) layout - and therefore every existing pipeline - is reused
* as-is at replay,
* - one cached bind group(0) pairing the two.
* - cached bind groups(0) pairing them with each renderer layout.
*
* Buffers are grow-only across recaptures - no realloc churn under
* motion-stop/start. {@link generation} bumps whenever GPU resources are
Expand All @@ -44,6 +45,8 @@ const rowsPerPatchBlock = 64;
* to entry replay.
*/
export class WebGpuRetainedGroupBundle implements RetainedGroupBundle {
public readonly nativeReplay = new WebGpuNativeRetainedReplay();

private _generation = 1;
private _instanceBuffer: GPUBuffer | null = null;
private _instanceCapacity = 0;
Expand Down Expand Up @@ -77,6 +80,7 @@ export class WebGpuRetainedGroupBundle implements RetainedGroupBundle {
private _bindGroup: GPUBindGroup | null = null;
private _bindGroupLayout: GPUBindGroupLayout | null = null;
private _bindGroupIncludesTint = false;
private readonly _bindGroups = new Map<GPUBindGroupLayout, [GPUBindGroup | null, GPUBindGroup | null]>();
private readonly _accountant: GpuResourceAccountant;
private _accountedBytes = 0;
private _onRelease: ((bundle: WebGpuRetainedGroupBundle) => void) | null;
Expand Down Expand Up @@ -234,8 +238,10 @@ export class WebGpuRetainedGroupBundle implements RetainedGroupBundle {
}

if (recreated) {
this.nativeReplay.invalidate();
this._generation++;
this._bindGroup = null;
this._bindGroups.clear();
this._accountedBytes = this._accountant.reallocate(
this._accountedBytes,
this._instanceCapacity + this._transformCapacity + this._tintCapacity + retainedGroupUniformBytes,
Expand Down Expand Up @@ -368,9 +374,8 @@ export class WebGpuRetainedGroupBundle implements RetainedGroupBundle {
/**
* The bind group(0) pairing the group UBO with the group transform storage
* (and, for a renderer that reads per-instance tint - sprite - the tint
* storage too), against the calling renderer's own uniform layout. Cached;
* rebuilt when a buffer, `includeTint`, or the layout (device restore)
* changed. `includeTint` MUST match what `layout` actually declares
* storage too), cached per renderer layout until buffers are recreated.
* `includeTint` MUST match what `layout` actually declares
* (binding 2 present or not) - the entries list below is built to fit
* exactly, since WebGPU bind group creation requires an exact match against
* the layout's binding set (nine-slice/repeating's layout has no binding 2).
Expand All @@ -382,6 +387,19 @@ export class WebGpuRetainedGroupBundle implements RetainedGroupBundle {

this._bindGroupLayout = layout;
this._bindGroupIncludesTint = includeTint;
let cached = this._bindGroups.get(layout);
const slot = includeTint ? 1 : 0;

if (cached !== undefined && cached[slot] !== null) {
this._bindGroup = cached[slot];
return this._bindGroup;
}

if (cached === undefined) {
cached = [null, null];
this._bindGroups.set(layout, cached);
}

this._bindGroup = device.createBindGroup({
label: 'sprite:retained-bind-group',
layout,
Expand All @@ -397,6 +415,7 @@ export class WebGpuRetainedGroupBundle implements RetainedGroupBundle {
],
});

cached[slot] = this._bindGroup;
return this._bindGroup;
}

Expand All @@ -407,6 +426,7 @@ export class WebGpuRetainedGroupBundle implements RetainedGroupBundle {
* set recorded against the old resources fails validation and re-records.
*/
public invalidateDeviceState(destroyBuffers: boolean): void {
this.nativeReplay.invalidate();
if (destroyBuffers) {
this._instanceBuffer?.destroy();
this._transformBuffer?.destroy();
Expand All @@ -426,6 +446,7 @@ export class WebGpuRetainedGroupBundle implements RetainedGroupBundle {
this._dirtyTransforms.reset(0);
this._dirtyTints.reset(0);
this._bindGroup = null;
this._bindGroups.clear();
this._bindGroupLayout = null;
this._generation++;
this.uboWritten = false;
Expand Down
35 changes: 29 additions & 6 deletions src/rendering/webgpu/WebGpuScalableSpriteRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,12 +760,35 @@ export class WebGpuScalableSpriteRenderer extends AbstractWebGpuRenderer<NineSli
const active = coordinator.acquirePass();
const pass = active.pass;

pass.setPipeline(this._getPipeline('geo', payload.blendMode, backend.renderTargetFormat, coordinator.stencilActive));
pass.setBindGroup(0, bundle.getBindGroup(device, this._uniformBindGroupLayout!, false));
pass.setBindGroup(1, textureBindGroup);
pass.setVertexBuffer(0, bundle.instanceBuffer, payload.byteOffset);
pass.setIndexBuffer(this._indexBuffer, 'uint16');
pass.drawIndexed(indicesPerInstance, payload.instanceCount, 0, 0, 0);
const nativePipeline = this._getPipeline('geo', payload.blendMode, backend.renderTargetFormat, coordinator.stencilActive);
const nativeFrameBindGroup = bundle.getBindGroup(device, this._uniformBindGroupLayout!, false);

const nativeCompatible = backend.colorAttachmentCount === 1;
if (!nativeCompatible) bundle.nativeReplay.skipPass();

if (
!nativeCompatible ||
!bundle.nativeReplay.draw(
device,
active,
payload,
backend.renderTargetFormat,
nativePipeline,
nativeFrameBindGroup,
textureBindGroup,
this._indexBuffer,
'uint16',
indicesPerInstance,
payload.instanceCount,
)
) {
pass.setPipeline(nativePipeline);
pass.setBindGroup(0, nativeFrameBindGroup);
pass.setBindGroup(1, textureBindGroup);
pass.setVertexBuffer(0, bundle.instanceBuffer, payload.byteOffset);
pass.setIndexBuffer(this._indexBuffer, 'uint16');
pass.drawIndexed(indicesPerInstance, payload.instanceCount, 0, 0, 0);
}

bundle.drawsInPass = active;
coordinator.markPassDraws();
Expand Down
Loading
Loading