@forgeax/engine-rhi-debug 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +178 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/bootstrap-integration.test.d.ts +2 -0
- package/dist/__tests__/bootstrap-integration.test.d.ts.map +1 -0
- package/dist/__tests__/consumer-inventory.unit.test.d.ts +2 -0
- package/dist/__tests__/consumer-inventory.unit.test.d.ts.map +1 -0
- package/dist/__tests__/copy-src-usage-validity.dawn.test.d.ts +15 -0
- package/dist/__tests__/copy-src-usage-validity.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/coverage-invariant.test-d.d.ts +2 -0
- package/dist/__tests__/coverage-invariant.test-d.d.ts.map +1 -0
- package/dist/__tests__/coverage-invariant.unit.test.d.ts +2 -0
- package/dist/__tests__/coverage-invariant.unit.test.d.ts.map +1 -0
- package/dist/__tests__/e2e.browser.test.d.ts +2 -0
- package/dist/__tests__/e2e.browser.test.d.ts.map +1 -0
- package/dist/__tests__/error-code-narrowing.test-d.d.ts +2 -0
- package/dist/__tests__/error-code-narrowing.test-d.d.ts.map +1 -0
- package/dist/__tests__/errors-v7.test-d.d.ts +2 -0
- package/dist/__tests__/errors-v7.test-d.d.ts.map +1 -0
- package/dist/__tests__/errors-v7.unit.test.d.ts +2 -0
- package/dist/__tests__/errors-v7.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/event-semantics-coverage.unit.test.d.ts +2 -0
- package/dist/__tests__/event-semantics-coverage.unit.test.d.ts.map +1 -0
- package/dist/__tests__/format-info.unit.test.d.ts +2 -0
- package/dist/__tests__/format-info.unit.test.d.ts.map +1 -0
- package/dist/__tests__/frame-model-parity.test-d.d.ts +2 -0
- package/dist/__tests__/frame-model-parity.test-d.d.ts.map +1 -0
- package/dist/__tests__/frame-model-parity.unit.test.d.ts +2 -0
- package/dist/__tests__/frame-model-parity.unit.test.d.ts.map +1 -0
- package/dist/__tests__/guard-gates.test.d.ts +2 -0
- package/dist/__tests__/guard-gates.test.d.ts.map +1 -0
- package/dist/__tests__/public-surface.integration.test.d.ts +2 -0
- package/dist/__tests__/public-surface.integration.test.d.ts.map +1 -0
- package/dist/__tests__/readback-fault-cleanup.unit.test.d.ts +2 -0
- package/dist/__tests__/readback-fault-cleanup.unit.test.d.ts.map +1 -0
- package/dist/__tests__/readback-format-matrix.dawn.test.d.ts +2 -0
- package/dist/__tests__/readback-format-matrix.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/readback-format-matrix.unit.test.d.ts +2 -0
- package/dist/__tests__/readback-format-matrix.unit.test.d.ts.map +1 -0
- package/dist/__tests__/readback-matrix-fixture.d.ts +32 -0
- package/dist/__tests__/readback-matrix-fixture.d.ts.map +1 -0
- package/dist/__tests__/recorder-bootstrap.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-bootstrap.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-budget.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-budget.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-fail-fast.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-fail-fast.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-push-event.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-push-event.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-session.integration.test.d.ts +2 -0
- package/dist/__tests__/recorder-session.integration.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-session.unit.test.d.ts +2 -0
- package/dist/__tests__/recorder-session.unit.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-steady-frame.dawn.test.d.ts +2 -0
- package/dist/__tests__/recorder-steady-frame.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/replay-fail-fast.unit.test.d.ts +2 -0
- package/dist/__tests__/replay-fail-fast.unit.test.d.ts.map +1 -0
- package/dist/__tests__/replay-session.dawn.test.d.ts +2 -0
- package/dist/__tests__/replay-session.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/replay-session.test-d.d.ts +2 -0
- package/dist/__tests__/replay-session.test-d.d.ts.map +1 -0
- package/dist/__tests__/replay-session.unit.test.d.ts +2 -0
- package/dist/__tests__/replay-session.unit.test.d.ts.map +1 -0
- package/dist/__tests__/resolve-texture-descriptor.unit.test.d.ts +2 -0
- package/dist/__tests__/resolve-texture-descriptor.unit.test.d.ts.map +1 -0
- package/dist/__tests__/resource-lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/resource-lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-debug-fresh-replay.dawn.test.d.ts +2 -0
- package/dist/__tests__/rhi-debug-fresh-replay.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/tape-index.unit.test.d.ts +2 -0
- package/dist/__tests__/tape-index.unit.test.d.ts.map +1 -0
- package/dist/__tests__/tape-v7-invalid.unit.test.d.ts +2 -0
- package/dist/__tests__/tape-v7-invalid.unit.test.d.ts.map +1 -0
- package/dist/__tests__/tape-v7-roundtrip.unit.test.d.ts +2 -0
- package/dist/__tests__/tape-v7-roundtrip.unit.test.d.ts.map +1 -0
- package/dist/__tests__/tape-v7-version.unit.test.d.ts +2 -0
- package/dist/__tests__/tape-v7-version.unit.test.d.ts.map +1 -0
- package/dist/__tests__/tree-shake.unit.test.d.ts +2 -0
- package/dist/__tests__/tree-shake.unit.test.d.ts.map +1 -0
- package/dist/__tests__/types.test.d.ts +2 -0
- package/dist/__tests__/types.test.d.ts.map +1 -0
- package/dist/browser.d.ts +45 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.mjs +83 -0
- package/dist/browser.mjs.map +1 -0
- package/dist/errors.d.ts +85 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/frame-model.d.ts +158 -0
- package/dist/frame-model.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +10415 -0
- package/dist/index.mjs.map +1 -0
- package/dist/protocol/codec.d.ts +6 -0
- package/dist/protocol/codec.d.ts.map +1 -0
- package/dist/protocol/event-semantics.d.ts +15 -0
- package/dist/protocol/event-semantics.d.ts.map +1 -0
- package/dist/protocol/tape-index.d.ts +30 -0
- package/dist/protocol/tape-index.d.ts.map +1 -0
- package/dist/protocol/types.d.ts +44 -0
- package/dist/protocol/types.d.ts.map +1 -0
- package/dist/protocol/validation.d.ts +7 -0
- package/dist/protocol/validation.d.ts.map +1 -0
- package/dist/readback.d.ts +156 -0
- package/dist/readback.d.ts.map +1 -0
- package/dist/recorder/assemble.d.ts +11 -0
- package/dist/recorder/assemble.d.ts.map +1 -0
- package/dist/recorder/closure.d.ts +39 -0
- package/dist/recorder/closure.d.ts.map +1 -0
- package/dist/recorder/core.d.ts +296 -0
- package/dist/recorder/core.d.ts.map +1 -0
- package/dist/recorder/device.d.ts +4 -0
- package/dist/recorder/device.d.ts.map +1 -0
- package/dist/recorder/encoder.d.ts +5 -0
- package/dist/recorder/encoder.d.ts.map +1 -0
- package/dist/recorder/lifecycle.d.ts +20 -0
- package/dist/recorder/lifecycle.d.ts.map +1 -0
- package/dist/recorder/pass.d.ts +6 -0
- package/dist/recorder/pass.d.ts.map +1 -0
- package/dist/recorder/proxy.d.ts +19 -0
- package/dist/recorder/proxy.d.ts.map +1 -0
- package/dist/recorder/queue.d.ts +4 -0
- package/dist/recorder/queue.d.ts.map +1 -0
- package/dist/recorder/resource-registry.d.ts +15 -0
- package/dist/recorder/resource-registry.d.ts.map +1 -0
- package/dist/recorder/session.d.ts +25 -0
- package/dist/recorder/session.d.ts.map +1 -0
- package/dist/recorder/shader.d.ts +3 -0
- package/dist/recorder/shader.d.ts.map +1 -0
- package/dist/recorder/snapshot.d.ts +10 -0
- package/dist/recorder/snapshot.d.ts.map +1 -0
- package/dist/recorder/wrap.d.ts +4 -0
- package/dist/recorder/wrap.d.ts.map +1 -0
- package/dist/recorder.d.ts +7 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/replay/device-request.d.ts +8 -0
- package/dist/replay/device-request.d.ts.map +1 -0
- package/dist/replay/execute-support.d.ts +33 -0
- package/dist/replay/execute-support.d.ts.map +1 -0
- package/dist/replay/execute.d.ts +18 -0
- package/dist/replay/execute.d.ts.map +1 -0
- package/dist/replay/readback.d.ts +32 -0
- package/dist/replay/readback.d.ts.map +1 -0
- package/dist/replay/resources.d.ts +57 -0
- package/dist/replay/resources.d.ts.map +1 -0
- package/dist/replay/session.d.ts +49 -0
- package/dist/replay/session.d.ts.map +1 -0
- package/dist/replay/texture-format.d.ts +21 -0
- package/dist/replay/texture-format.d.ts.map +1 -0
- package/dist/texel-decode.d.ts +35 -0
- package/dist/texel-decode.d.ts.map +1 -0
- package/dist/texel-layout.d.ts +76 -0
- package/dist/texel-layout.d.ts.map +1 -0
- package/dist/types.d.ts +722 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +72 -0
- package/src/__tests__/bootstrap-integration.test.ts +268 -0
- package/src/__tests__/consumer-inventory.unit.test.ts +154 -0
- package/src/__tests__/copy-src-usage-validity.dawn.test.ts +117 -0
- package/src/__tests__/coverage-invariant.test-d.ts +115 -0
- package/src/__tests__/coverage-invariant.unit.test.ts +45 -0
- package/src/__tests__/e2e.browser.test.ts +239 -0
- package/src/__tests__/error-code-narrowing.test-d.ts +32 -0
- package/src/__tests__/errors-v7.test-d.ts +19 -0
- package/src/__tests__/errors-v7.unit.test.ts +15 -0
- package/src/__tests__/errors.unit.test.ts +83 -0
- package/src/__tests__/event-semantics-coverage.unit.test.ts +22 -0
- package/src/__tests__/format-info.unit.test.ts +85 -0
- package/src/__tests__/frame-model-parity.test-d.ts +20 -0
- package/src/__tests__/frame-model-parity.unit.test.ts +285 -0
- package/src/__tests__/guard-gates.test.ts +317 -0
- package/src/__tests__/public-surface.integration.test.ts +38 -0
- package/src/__tests__/readback-fault-cleanup.unit.test.ts +66 -0
- package/src/__tests__/readback-format-matrix.dawn.test.ts +378 -0
- package/src/__tests__/readback-format-matrix.unit.test.ts +120 -0
- package/src/__tests__/readback-matrix-fixture.ts +20 -0
- package/src/__tests__/recorder-bootstrap.unit.test.ts +302 -0
- package/src/__tests__/recorder-budget.unit.test.ts +42 -0
- package/src/__tests__/recorder-fail-fast.unit.test.ts +434 -0
- package/src/__tests__/recorder-lifecycle.unit.test.ts +42 -0
- package/src/__tests__/recorder-push-event.unit.test.ts +573 -0
- package/src/__tests__/recorder-session.integration.test.ts +32 -0
- package/src/__tests__/recorder-session.unit.test.ts +63 -0
- package/src/__tests__/recorder-steady-frame.dawn.test.ts +52 -0
- package/src/__tests__/replay-fail-fast.unit.test.ts +155 -0
- package/src/__tests__/replay-session.dawn.test.ts +220 -0
- package/src/__tests__/replay-session.test-d.ts +27 -0
- package/src/__tests__/replay-session.unit.test.ts +171 -0
- package/src/__tests__/resolve-texture-descriptor.unit.test.ts +144 -0
- package/src/__tests__/resource-lifecycle.unit.test.ts +114 -0
- package/src/__tests__/rhi-debug-fresh-replay.dawn.test.ts +178 -0
- package/src/__tests__/tape-index.unit.test.ts +56 -0
- package/src/__tests__/tape-v7-invalid.unit.test.ts +45 -0
- package/src/__tests__/tape-v7-roundtrip.unit.test.ts +66 -0
- package/src/__tests__/tape-v7-version.unit.test.ts +13 -0
- package/src/__tests__/tree-shake.unit.test.ts +151 -0
- package/src/__tests__/types.test.ts +257 -0
- package/src/browser.ts +146 -0
- package/src/errors.ts +173 -0
- package/src/frame-model.ts +753 -0
- package/src/index.ts +92 -0
- package/src/pako.d.ts +8 -0
- package/src/protocol/codec.ts +220 -0
- package/src/protocol/event-semantics.ts +180 -0
- package/src/protocol/tape-index.ts +120 -0
- package/src/protocol/types.ts +60 -0
- package/src/protocol/validation.ts +173 -0
- package/src/readback.ts +804 -0
- package/src/recorder/assemble.ts +192 -0
- package/src/recorder/closure.ts +408 -0
- package/src/recorder/core.ts +587 -0
- package/src/recorder/device.ts +474 -0
- package/src/recorder/encoder.ts +251 -0
- package/src/recorder/lifecycle.ts +847 -0
- package/src/recorder/pass.ts +241 -0
- package/src/recorder/proxy.ts +59 -0
- package/src/recorder/queue.ts +109 -0
- package/src/recorder/resource-registry.ts +53 -0
- package/src/recorder/session.ts +152 -0
- package/src/recorder/shader.ts +47 -0
- package/src/recorder/snapshot.ts +38 -0
- package/src/recorder/wrap.ts +157 -0
- package/src/recorder.ts +15 -0
- package/src/replay/device-request.ts +38 -0
- package/src/replay/execute-support.ts +243 -0
- package/src/replay/execute.ts +870 -0
- package/src/replay/readback.ts +671 -0
- package/src/replay/resources.ts +154 -0
- package/src/replay/session.ts +509 -0
- package/src/replay/texture-format.ts +99 -0
- package/src/texel-decode.ts +289 -0
- package/src/texel-layout.ts +324 -0
- package/src/types.ts +888 -0
package/src/readback.ts
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
// @forgeax/engine-rhi-debug/src/readback — shared GPU texture→host readback utilities.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from inspector.ts (round 1 fix-up 34be40d6, I-7) for reuse by
|
|
4
|
+
// replayer.readbackRt() (m5b-1) and e2e.dawn.test.ts (m5b-3).
|
|
5
|
+
//
|
|
6
|
+
// Related: plan-strategy §5.3.1; m5b-1 / m5b-3.
|
|
7
|
+
|
|
8
|
+
/// <reference types="@webgpu/types" />
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
Buffer,
|
|
12
|
+
MappedBuffer,
|
|
13
|
+
RhiCommandEncoder,
|
|
14
|
+
RhiDevice,
|
|
15
|
+
RhiQueue,
|
|
16
|
+
} from '@forgeax/engine-rhi';
|
|
17
|
+
import type { Result } from '@forgeax/engine-types';
|
|
18
|
+
import { err, ok } from '@forgeax/engine-types';
|
|
19
|
+
import { createRhiDebugError, type RhiDebugError } from './errors';
|
|
20
|
+
import type { RhiCallEvent } from './types';
|
|
21
|
+
|
|
22
|
+
// GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ = 8 | 1 = 9.
|
|
23
|
+
const COPY_DST_MAP_READ = 9;
|
|
24
|
+
const TEXTURE_READBACK_USAGE = COPY_DST_MAP_READ;
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// resolveTextureDescriptor — tape handle -> source texture descriptor (SSOT)
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/** Resolved descriptor for a texture (or texture-view) handle from the tape. */
|
|
31
|
+
export interface ResolvedTextureDescriptor {
|
|
32
|
+
/** The source GPUTexture handleId (copyTextureToBuffer needs a texture, not a view). */
|
|
33
|
+
readonly handleId: string;
|
|
34
|
+
readonly width: number;
|
|
35
|
+
readonly height: number;
|
|
36
|
+
readonly format: string;
|
|
37
|
+
/** The view's dimension ('2d' | 'cube' | '2d-array' | '3d' | ...); '2d' when no view event. */
|
|
38
|
+
readonly dimension: string;
|
|
39
|
+
/** The source texture's depthOrArrayLayers (slice count); 1 for a plain 2D texture. */
|
|
40
|
+
readonly arrayLayers: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walk the tape events to resolve a view-or-texture handleId to its source
|
|
45
|
+
* GPUTexture descriptor (handleId, real dimensions, format, view dimension).
|
|
46
|
+
*
|
|
47
|
+
* The single source of truth for "tape handle -> texture descriptor": both the
|
|
48
|
+
* color-attachment RT path (resolveAttachmentSize / readbackDrawRt) and the
|
|
49
|
+
* viewer's depth + bound-texture preview paths resolve handles this way —
|
|
50
|
+
* createTextureView.resultHandleId -> sourceHandleId -> createTexture, falling
|
|
51
|
+
* back to the id itself when it is a direct texture handle (no view event).
|
|
52
|
+
*
|
|
53
|
+
* Size is read from the raw createTexture event. Returns null when no
|
|
54
|
+
* createTexture event declares the resolved handle.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveTextureDescriptor(
|
|
57
|
+
events: readonly RhiCallEvent[],
|
|
58
|
+
viewOrTextureHandleId: string,
|
|
59
|
+
): ResolvedTextureDescriptor | null {
|
|
60
|
+
// Step 1: resolve texture view -> source texture handleId + capture view dimension.
|
|
61
|
+
let sourceTextureHandleId: string | undefined;
|
|
62
|
+
let viewDimension: string | undefined;
|
|
63
|
+
for (const ev of events) {
|
|
64
|
+
if (ev.kind === 'createTextureView' && ev.resultHandleId === viewOrTextureHandleId) {
|
|
65
|
+
sourceTextureHandleId = ev.sourceHandleId;
|
|
66
|
+
viewDimension = ev.desc.dimension;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Some handles are texture handles directly (no view event).
|
|
71
|
+
const targetHandleId = sourceTextureHandleId ?? viewOrTextureHandleId;
|
|
72
|
+
|
|
73
|
+
// Step 2: find the createTexture event for the resolved texture handleId.
|
|
74
|
+
for (const ev of events) {
|
|
75
|
+
if (ev.kind === 'createTexture' && ev.handleId === targetHandleId) {
|
|
76
|
+
const sz = ev.desc.size;
|
|
77
|
+
let width: number;
|
|
78
|
+
let height: number;
|
|
79
|
+
let arrayLayers: number;
|
|
80
|
+
// GPUExtent3DStrict: { width, height?, depthOrArrayLayers? } or [w, h?, d?]
|
|
81
|
+
if (Array.isArray(sz)) {
|
|
82
|
+
width = typeof sz[0] === 'number' ? sz[0] : 512;
|
|
83
|
+
height = typeof sz[1] === 'number' ? sz[1] : width;
|
|
84
|
+
arrayLayers = typeof sz[2] === 'number' ? sz[2] : 1;
|
|
85
|
+
} else {
|
|
86
|
+
const obj = sz as { width: number; height?: number; depthOrArrayLayers?: number };
|
|
87
|
+
width = typeof obj.width === 'number' ? obj.width : 512;
|
|
88
|
+
height = typeof obj.height === 'number' ? obj.height : width;
|
|
89
|
+
arrayLayers = typeof obj.depthOrArrayLayers === 'number' ? obj.depthOrArrayLayers : 1;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
handleId: targetHandleId,
|
|
93
|
+
width,
|
|
94
|
+
height,
|
|
95
|
+
format: ev.desc.format,
|
|
96
|
+
// View dimension wins; else the texture's own dimension; else '2d'.
|
|
97
|
+
dimension: viewDimension ?? ev.desc.dimension ?? '2d',
|
|
98
|
+
arrayLayers,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// resolveAttachmentSize — walk tape events to find texture dimensions
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Walk the tape events to find the real texture dimensions for a given
|
|
112
|
+
* color attachment view/target handleId. Avoids hard-coding 512×512.
|
|
113
|
+
*
|
|
114
|
+
* Thin wrapper over {@link resolveTextureDescriptor}; returns
|
|
115
|
+
* { width: 512, height: 512 } as a conservative fallback when no createTexture
|
|
116
|
+
* event is found (should not happen for a real frame).
|
|
117
|
+
*/
|
|
118
|
+
export function resolveAttachmentSize(
|
|
119
|
+
events: readonly RhiCallEvent[],
|
|
120
|
+
attachmentViewHandleId: string,
|
|
121
|
+
): { readonly width: number; readonly height: number } {
|
|
122
|
+
const desc = resolveTextureDescriptor(events, attachmentViewHandleId);
|
|
123
|
+
if (desc === null) return { width: 512, height: 512 };
|
|
124
|
+
return { width: desc.width, height: desc.height };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ============================================================================
|
|
128
|
+
// readbackTexturePixels — copyTextureToBuffer + mapAsync + getMappedRange
|
|
129
|
+
// ============================================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read back raw RGBA8 pixels from a GPU texture into a host-side Uint8Array.
|
|
133
|
+
*
|
|
134
|
+
* Steps:
|
|
135
|
+
* 1. Create a staging buffer (COPY_DST | MAP_READ) sized to aligned rows.
|
|
136
|
+
* 2. Create a command encoder + copyTextureToBuffer.
|
|
137
|
+
* 3. Finish + submit + await onSubmittedWorkDone.
|
|
138
|
+
* 4. mapAsync(READ) + getMappedRange() → new Uint8Array(slice).
|
|
139
|
+
* 5. Unmap + destroy staging buffer.
|
|
140
|
+
*
|
|
141
|
+
* The returned Uint8Array has length = texWidth * texHeight * 4 (tight;
|
|
142
|
+
* alignment padding is stripped). The buffer alignment is WebGPU 256-byte
|
|
143
|
+
* row requirement.
|
|
144
|
+
*
|
|
145
|
+
* @param device - The RHI device that owns the texture.
|
|
146
|
+
* @param texture - The texture to read back (opaque branded handle at the boundary).
|
|
147
|
+
* @param texWidth - Texture width in pixels.
|
|
148
|
+
* @param texHeight - Texture height in pixels.
|
|
149
|
+
*/
|
|
150
|
+
export async function readbackTexturePixels(
|
|
151
|
+
device: RhiDevice,
|
|
152
|
+
texture: unknown,
|
|
153
|
+
texWidth: number,
|
|
154
|
+
texHeight: number,
|
|
155
|
+
opts?: {
|
|
156
|
+
/** Bytes in one uncompressed texel; retained for depth/color callers. */
|
|
157
|
+
bytesPerTexel?: number;
|
|
158
|
+
/** Compressed-format footprint; defaults to bytesPerTexel with a 1x1 block. */
|
|
159
|
+
bytesPerBlock?: number;
|
|
160
|
+
blockWidth?: number;
|
|
161
|
+
blockHeight?: number;
|
|
162
|
+
mipLevel?: number;
|
|
163
|
+
baseArrayLayer?: number;
|
|
164
|
+
aspect?: 'all' | 'depth-only' | 'stencil-only';
|
|
165
|
+
},
|
|
166
|
+
): Promise<Uint8Array> {
|
|
167
|
+
const bytesPerBlock = opts?.bytesPerBlock ?? opts?.bytesPerTexel ?? 4;
|
|
168
|
+
const blockWidth = opts?.blockWidth ?? 1;
|
|
169
|
+
const blockHeight = opts?.blockHeight ?? 1;
|
|
170
|
+
const blockCountX = Math.ceil(texWidth / blockWidth);
|
|
171
|
+
const blockCountY = Math.ceil(texHeight / blockHeight);
|
|
172
|
+
const copyWidth = blockCountX * blockWidth;
|
|
173
|
+
const copyHeight = blockCountY * blockHeight;
|
|
174
|
+
const mipLevel = opts?.mipLevel ?? 0;
|
|
175
|
+
const baseArrayLayer = opts?.baseArrayLayer ?? 0;
|
|
176
|
+
const aspect = opts?.aspect;
|
|
177
|
+
const rowBytes = blockCountX * bytesPerBlock;
|
|
178
|
+
const alignedRowBytes = Math.ceil(rowBytes / 256) * 256; // WebGPU alignment
|
|
179
|
+
const bufferSize = alignedRowBytes * blockCountY;
|
|
180
|
+
|
|
181
|
+
const readbackBufferResult = device.createBuffer({
|
|
182
|
+
size: bufferSize,
|
|
183
|
+
usage: COPY_DST_MAP_READ,
|
|
184
|
+
});
|
|
185
|
+
if (!readbackBufferResult.ok) {
|
|
186
|
+
throw new Error(`createBuffer for readback failed: ${readbackBufferResult.error.code}`);
|
|
187
|
+
}
|
|
188
|
+
const readbackBuffer = readbackBufferResult.value;
|
|
189
|
+
|
|
190
|
+
const encoderResult = device.createCommandEncoder({});
|
|
191
|
+
if (!encoderResult.ok) {
|
|
192
|
+
device.destroyBuffer(readbackBuffer);
|
|
193
|
+
throw new Error(`createCommandEncoder for readback failed: ${encoderResult.error.code}`);
|
|
194
|
+
}
|
|
195
|
+
const encoder = encoderResult.value;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
encoder.copyTextureToBuffer(
|
|
199
|
+
{
|
|
200
|
+
texture,
|
|
201
|
+
mipLevel,
|
|
202
|
+
origin: { x: 0, y: 0, z: baseArrayLayer },
|
|
203
|
+
// aspect selects depth vs stencil plane on combined depth-stencil
|
|
204
|
+
// textures. stencil-only IS copyable on depth24plus-stencil8 (the
|
|
205
|
+
// depth plane is not). Omitted -> backend default ('all').
|
|
206
|
+
...(aspect !== undefined ? { aspect } : {}),
|
|
207
|
+
} as unknown as never,
|
|
208
|
+
{
|
|
209
|
+
buffer: readbackBuffer,
|
|
210
|
+
offset: 0,
|
|
211
|
+
bytesPerRow: alignedRowBytes,
|
|
212
|
+
rowsPerImage: blockCountY,
|
|
213
|
+
} as unknown as never,
|
|
214
|
+
{ width: copyWidth, height: copyHeight, depthOrArrayLayers: 1 },
|
|
215
|
+
);
|
|
216
|
+
} catch {
|
|
217
|
+
device.destroyBuffer(readbackBuffer);
|
|
218
|
+
throw new Error('copyTextureToBuffer failed');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const finishResult = encoder.finish();
|
|
222
|
+
if (!finishResult.ok) {
|
|
223
|
+
device.destroyBuffer(readbackBuffer);
|
|
224
|
+
throw new Error(`encoder.finish failed: ${finishResult.error.code}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const queue: RhiQueue = device.queue;
|
|
228
|
+
queue.submit([finishResult.value as unknown as never] as unknown as readonly never[]);
|
|
229
|
+
await queue.onSubmittedWorkDone();
|
|
230
|
+
|
|
231
|
+
// RHI Buffer.mapAsync / MappedBuffer.getMappedRange return Result wrappers, not
|
|
232
|
+
// the raw spec void / ArrayBuffer. The previous `as unknown as { ... }` casts
|
|
233
|
+
// hid that: mapAsync was called with mode=2 (which is GPUMapMode.WRITE, not
|
|
234
|
+
// READ=0x1) and getMappedRange's Result object was fed straight into
|
|
235
|
+
// `new Uint8Array(...)`, yielding a zero-length array — every RT readback came
|
|
236
|
+
// back all-zero (transparent black), which the e2e delta check missed because
|
|
237
|
+
// baseline and replay were equally empty (empty-vs-empty trap).
|
|
238
|
+
const buffer = readbackBuffer as unknown as Buffer;
|
|
239
|
+
// GPUMapMode.READ = 0x1
|
|
240
|
+
const mapResult = await buffer.mapAsync(0x1);
|
|
241
|
+
if (!mapResult.ok) {
|
|
242
|
+
device.destroyBuffer(readbackBuffer);
|
|
243
|
+
throw new Error(`mapAsync(READ) failed: ${mapResult.error.code}`);
|
|
244
|
+
}
|
|
245
|
+
const mapped: MappedBuffer = mapResult.value;
|
|
246
|
+
|
|
247
|
+
const rangeResult = mapped.getMappedRange();
|
|
248
|
+
if (!rangeResult.ok) {
|
|
249
|
+
mapped.unmap();
|
|
250
|
+
device.destroyBuffer(readbackBuffer);
|
|
251
|
+
throw new Error(`getMappedRange failed: ${rangeResult.error.code}`);
|
|
252
|
+
}
|
|
253
|
+
const fullPixels = new Uint8Array(rangeResult.value);
|
|
254
|
+
|
|
255
|
+
// Extract tight pixels (strip alignment padding)
|
|
256
|
+
const tightPixels = new Uint8Array(blockCountX * blockCountY * bytesPerBlock);
|
|
257
|
+
for (let y = 0; y < blockCountY; y++) {
|
|
258
|
+
const srcOffset = y * alignedRowBytes;
|
|
259
|
+
const dstOffset = y * rowBytes;
|
|
260
|
+
for (let x = 0; x < rowBytes; x++) {
|
|
261
|
+
tightPixels[dstOffset + x] = fullPixels[srcOffset + x] ?? 0;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Cleanup
|
|
266
|
+
mapped.unmap();
|
|
267
|
+
device.destroyBuffer(readbackBuffer);
|
|
268
|
+
|
|
269
|
+
return tightPixels;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ============================================================================
|
|
273
|
+
// readbackBufferBytes — copyBufferToBuffer + mapAsync + getMappedRange (D-7)
|
|
274
|
+
// ============================================================================
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read back the raw bytes of a GPU buffer into a host-side ArrayBuffer.
|
|
278
|
+
*
|
|
279
|
+
* Sibling of readbackTexturePixels under the single "GPU byte readback"
|
|
280
|
+
* responsibility unit (plan-strategy D-7) — snapshotResource calls this to
|
|
281
|
+
* capture a buffer's initial GPU bytes at frame-header time.
|
|
282
|
+
*
|
|
283
|
+
* Steps:
|
|
284
|
+
* 1. Create a staging buffer (COPY_DST | MAP_READ) sized to `size`.
|
|
285
|
+
* 2. Create a command encoder + copyBufferToBuffer(src, 0, staging, 0, size).
|
|
286
|
+
* 3. Finish + submit + await onSubmittedWorkDone.
|
|
287
|
+
* 4. mapAsync(READ=0x1) + getMappedRange() -> sliced ArrayBuffer copy.
|
|
288
|
+
* 5. Unmap + destroy staging buffer.
|
|
289
|
+
*
|
|
290
|
+
* Returns Ok(ArrayBuffer) (a detached copy independent of the mapped range)
|
|
291
|
+
* or Err(readback-failed) with `.detail.phase` narrowing
|
|
292
|
+
* the failure point (copy / map). The buffer is passed opaque (`unknown`)
|
|
293
|
+
* because RHI handles are branded; the caller resolved it from the descriptor
|
|
294
|
+
* registry. The caller (snapshotResource) holds the handleId and maps this
|
|
295
|
+
* readback error to the capture-snapshot-failed boundary.
|
|
296
|
+
*
|
|
297
|
+
* Reuses the M0-fixed mapAsync(0x1) + Result-unwrap pattern from
|
|
298
|
+
* readbackTexturePixels (never the all-zero mode=2 bug).
|
|
299
|
+
*
|
|
300
|
+
* @param device - The RHI device that owns the buffer.
|
|
301
|
+
* @param buffer - The source buffer (opaque branded handle) to read back.
|
|
302
|
+
* @param size - Number of bytes to read back (the buffer's recorded size).
|
|
303
|
+
*/
|
|
304
|
+
export async function readbackBufferBytes(
|
|
305
|
+
device: RhiDevice,
|
|
306
|
+
buffer: unknown,
|
|
307
|
+
size: number,
|
|
308
|
+
): Promise<Result<ArrayBuffer, RhiDebugError>> {
|
|
309
|
+
const fail = (phase: 'copy' | 'map', cause: string): Result<ArrayBuffer, RhiDebugError> =>
|
|
310
|
+
err(createRhiDebugError('readback-failed', { stage: 'readback', phase, cause }));
|
|
311
|
+
|
|
312
|
+
const readbackBufferResult = device.createBuffer({ size, usage: COPY_DST_MAP_READ });
|
|
313
|
+
if (!readbackBufferResult.ok) {
|
|
314
|
+
return fail('copy', `staging buffer creation failed: ${readbackBufferResult.error.code}`);
|
|
315
|
+
}
|
|
316
|
+
const readbackBuffer = readbackBufferResult.value;
|
|
317
|
+
|
|
318
|
+
const encoderResult = device.createCommandEncoder({});
|
|
319
|
+
if (!encoderResult.ok) {
|
|
320
|
+
device.destroyBuffer(readbackBuffer);
|
|
321
|
+
return fail('copy', `command encoder creation failed: ${encoderResult.error.code}`);
|
|
322
|
+
}
|
|
323
|
+
const encoder = encoderResult.value;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
encoder.copyBufferToBuffer(buffer as Buffer, 0, readbackBuffer, 0, size);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
device.destroyBuffer(readbackBuffer);
|
|
329
|
+
return fail('copy', `copyBufferToBuffer failed: ${String(e)}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const finishResult = encoder.finish();
|
|
333
|
+
if (!finishResult.ok) {
|
|
334
|
+
device.destroyBuffer(readbackBuffer);
|
|
335
|
+
return fail('copy', `encoder.finish failed: ${finishResult.error.code}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const queue: RhiQueue = device.queue;
|
|
339
|
+
queue.submit([finishResult.value as unknown as never] as unknown as readonly never[]);
|
|
340
|
+
await queue.onSubmittedWorkDone();
|
|
341
|
+
|
|
342
|
+
const stagingBuffer = readbackBuffer as unknown as Buffer;
|
|
343
|
+
// GPUMapMode.READ = 0x1
|
|
344
|
+
const mapResult = await stagingBuffer.mapAsync(0x1);
|
|
345
|
+
if (!mapResult.ok) {
|
|
346
|
+
device.destroyBuffer(readbackBuffer);
|
|
347
|
+
return fail('map', `mapAsync(READ) failed: ${mapResult.error.code}`);
|
|
348
|
+
}
|
|
349
|
+
const mapped: MappedBuffer = mapResult.value;
|
|
350
|
+
|
|
351
|
+
const rangeResult = mapped.getMappedRange();
|
|
352
|
+
if (!rangeResult.ok) {
|
|
353
|
+
mapped.unmap();
|
|
354
|
+
device.destroyBuffer(readbackBuffer);
|
|
355
|
+
return fail('map', `getMappedRange failed: ${rangeResult.error.code}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Copy the mapped bytes into a standalone ArrayBuffer before unmap — the
|
|
359
|
+
// mapped range is invalidated on unmap.
|
|
360
|
+
const bytes = new Uint8Array(rangeResult.value).slice();
|
|
361
|
+
|
|
362
|
+
mapped.unmap();
|
|
363
|
+
device.destroyBuffer(readbackBuffer);
|
|
364
|
+
|
|
365
|
+
return ok(bytes.buffer as ArrayBuffer);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A load-time buffer that can be read back as part of one GPU submission. */
|
|
369
|
+
export interface BufferReadbackBatchRequest {
|
|
370
|
+
readonly handleId: string;
|
|
371
|
+
readonly buffer: unknown;
|
|
372
|
+
readonly size: number;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface BufferReadbackBatchCallbacks {
|
|
376
|
+
readonly onResourceStart?: (handleId: string) => void;
|
|
377
|
+
readonly onResourceComplete?: (handleId: string) => void;
|
|
378
|
+
/** Return true when the owning snapshot generation has been invalidated. */
|
|
379
|
+
readonly isCancelled?: () => boolean;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function raceCancellation<T>(
|
|
383
|
+
work: Promise<T>,
|
|
384
|
+
isCancelled: (() => boolean) | undefined,
|
|
385
|
+
): Promise<{ readonly cancelled: true } | { readonly cancelled: false; readonly value: T }> {
|
|
386
|
+
if (isCancelled === undefined) return { cancelled: false, value: await work };
|
|
387
|
+
if (isCancelled()) return { cancelled: true };
|
|
388
|
+
|
|
389
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
390
|
+
const cancelled = new Promise<{ readonly cancelled: true }>((resolve) => {
|
|
391
|
+
timer = setInterval(() => {
|
|
392
|
+
if (isCancelled()) resolve({ cancelled: true });
|
|
393
|
+
}, 1);
|
|
394
|
+
});
|
|
395
|
+
try {
|
|
396
|
+
return await Promise.race([
|
|
397
|
+
work.then((value) => ({ cancelled: false as const, value })),
|
|
398
|
+
cancelled,
|
|
399
|
+
]);
|
|
400
|
+
} finally {
|
|
401
|
+
if (timer !== undefined) clearInterval(timer);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Read back multiple buffers with one command submission and one queue drain.
|
|
407
|
+
*
|
|
408
|
+
* Prism City exposed the cost of the old one-buffer helper: every resource
|
|
409
|
+
* submitted and awaited independently, so thousands of small buffers spent
|
|
410
|
+
* most of capture time in synchronization rather than byte transfer. The
|
|
411
|
+
* batch keeps each staging buffer isolated but submits all copies together;
|
|
412
|
+
* mapping remains per-resource so a timeout can still identify the current
|
|
413
|
+
* handle and the caller can preserve the original initialData event order.
|
|
414
|
+
*/
|
|
415
|
+
export async function readbackBufferBytesBatch(
|
|
416
|
+
device: RhiDevice,
|
|
417
|
+
requests: readonly BufferReadbackBatchRequest[],
|
|
418
|
+
callbacks: BufferReadbackBatchCallbacks = {},
|
|
419
|
+
): Promise<Result<ReadonlyMap<string, ArrayBuffer>, RhiDebugError>> {
|
|
420
|
+
if (requests.length === 0) return ok(new Map());
|
|
421
|
+
const firstRequest = requests[0];
|
|
422
|
+
if (firstRequest === undefined) return ok(new Map());
|
|
423
|
+
|
|
424
|
+
const fail = (handleId: string, phase: 'copy' | 'map', cause: string) =>
|
|
425
|
+
err(
|
|
426
|
+
createRhiDebugError('readback-failed', {
|
|
427
|
+
stage: 'readback',
|
|
428
|
+
phase,
|
|
429
|
+
cause: `${handleId}: ${cause}`,
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
const staging: Array<{ readonly request: BufferReadbackBatchRequest; readonly buffer: Buffer }> =
|
|
433
|
+
[];
|
|
434
|
+
const mapped = new Map<Buffer, MappedBuffer>();
|
|
435
|
+
const cleaned = new Set<Buffer>();
|
|
436
|
+
const cleanup = () => {
|
|
437
|
+
for (const mappedBuffer of mapped.values()) mappedBuffer.unmap();
|
|
438
|
+
for (const item of staging) {
|
|
439
|
+
if (!cleaned.has(item.buffer)) {
|
|
440
|
+
device.destroyBuffer(item.buffer);
|
|
441
|
+
cleaned.add(item.buffer);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
let encoder: RhiCommandEncoder;
|
|
447
|
+
try {
|
|
448
|
+
const encoderResult = device.createCommandEncoder({});
|
|
449
|
+
if (!encoderResult.ok)
|
|
450
|
+
return fail(
|
|
451
|
+
firstRequest.handleId,
|
|
452
|
+
'copy',
|
|
453
|
+
`command encoder creation failed: ${encoderResult.error.code}`,
|
|
454
|
+
);
|
|
455
|
+
encoder = encoderResult.value;
|
|
456
|
+
for (const request of requests) {
|
|
457
|
+
const stagingResult = device.createBuffer({ size: request.size, usage: COPY_DST_MAP_READ });
|
|
458
|
+
if (!stagingResult.ok) {
|
|
459
|
+
cleanup();
|
|
460
|
+
return fail(
|
|
461
|
+
request.handleId,
|
|
462
|
+
'copy',
|
|
463
|
+
`staging buffer creation failed: ${stagingResult.error.code}`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const stagingBuffer = stagingResult.value;
|
|
467
|
+
staging.push({ request, buffer: stagingBuffer });
|
|
468
|
+
try {
|
|
469
|
+
encoder.copyBufferToBuffer(request.buffer as Buffer, 0, stagingBuffer, 0, request.size);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
cleanup();
|
|
472
|
+
return fail(request.handleId, 'copy', `copyBufferToBuffer failed: ${String(error)}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const finishResult = encoder.finish();
|
|
476
|
+
if (!finishResult.ok) {
|
|
477
|
+
cleanup();
|
|
478
|
+
return fail(
|
|
479
|
+
firstRequest.handleId,
|
|
480
|
+
'copy',
|
|
481
|
+
`encoder.finish failed: ${finishResult.error.code}`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
device.queue.submit([finishResult.value as unknown as never] as unknown as readonly never[]);
|
|
485
|
+
await device.queue.onSubmittedWorkDone();
|
|
486
|
+
|
|
487
|
+
// Start every mapAsync together. The GPU work has already been submitted
|
|
488
|
+
// and drained; awaiting each map before starting the next one recreates a
|
|
489
|
+
// per-resource synchronization wall even after batching the copies.
|
|
490
|
+
const mapResultsPromise = Promise.all(
|
|
491
|
+
staging.map(async (item) => {
|
|
492
|
+
callbacks.onResourceStart?.(item.request.handleId);
|
|
493
|
+
try {
|
|
494
|
+
const result = await item.buffer.mapAsync(0x1);
|
|
495
|
+
if (result.ok) {
|
|
496
|
+
if (callbacks.isCancelled?.()) result.value.unmap();
|
|
497
|
+
else mapped.set(item.buffer, result.value);
|
|
498
|
+
}
|
|
499
|
+
return { item, result };
|
|
500
|
+
} catch (error) {
|
|
501
|
+
return { item, error };
|
|
502
|
+
}
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
const mapResults = await raceCancellation(mapResultsPromise, callbacks.isCancelled);
|
|
506
|
+
if (mapResults.cancelled) {
|
|
507
|
+
cleanup();
|
|
508
|
+
return fail(
|
|
509
|
+
firstRequest.handleId,
|
|
510
|
+
'map',
|
|
511
|
+
'buffer batch readback cancelled after the snapshot generation was invalidated',
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const result = new Map<string, ArrayBuffer>();
|
|
515
|
+
for (const mappedResult of mapResults.value) {
|
|
516
|
+
if ('error' in mappedResult) {
|
|
517
|
+
cleanup();
|
|
518
|
+
return fail(
|
|
519
|
+
mappedResult.item.request.handleId,
|
|
520
|
+
'map',
|
|
521
|
+
`mapAsync(READ) failed: ${String(mappedResult.error)}`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (!mappedResult.result.ok) {
|
|
525
|
+
cleanup();
|
|
526
|
+
return fail(
|
|
527
|
+
mappedResult.item.request.handleId,
|
|
528
|
+
'map',
|
|
529
|
+
`mapAsync(READ) failed: ${mappedResult.result.error.code}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const mappedBuffer = mappedResult.result.value;
|
|
533
|
+
const item = mappedResult.item;
|
|
534
|
+
const rangeResult = mappedBuffer.getMappedRange();
|
|
535
|
+
if (!rangeResult.ok) {
|
|
536
|
+
cleanup();
|
|
537
|
+
return fail(
|
|
538
|
+
item.request.handleId,
|
|
539
|
+
'map',
|
|
540
|
+
`getMappedRange failed: ${rangeResult.error.code}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
result.set(
|
|
544
|
+
item.request.handleId,
|
|
545
|
+
new Uint8Array(rangeResult.value).slice().buffer as ArrayBuffer,
|
|
546
|
+
);
|
|
547
|
+
mappedBuffer.unmap();
|
|
548
|
+
mapped.delete(item.buffer);
|
|
549
|
+
device.destroyBuffer(item.buffer);
|
|
550
|
+
cleaned.add(item.buffer);
|
|
551
|
+
callbacks.onResourceComplete?.(item.request.handleId);
|
|
552
|
+
}
|
|
553
|
+
return ok(result);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
cleanup();
|
|
556
|
+
return fail(firstRequest.handleId, 'map', `buffer batch readback failed: ${String(error)}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** One (layer, mip) copy contributing to a complete texture snapshot blob. */
|
|
561
|
+
export interface TextureReadbackBatchSlice {
|
|
562
|
+
readonly layer: number;
|
|
563
|
+
readonly mip: number;
|
|
564
|
+
readonly width: number;
|
|
565
|
+
readonly height: number;
|
|
566
|
+
readonly byteOffset: number;
|
|
567
|
+
readonly byteLength: number;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** A complete texture whose subresources are copied in one bounded batch. */
|
|
571
|
+
export interface TextureReadbackBatchRequest {
|
|
572
|
+
readonly handleId: string;
|
|
573
|
+
readonly texture: unknown;
|
|
574
|
+
readonly bytesPerBlock: number;
|
|
575
|
+
readonly blockWidth: number;
|
|
576
|
+
readonly blockHeight: number;
|
|
577
|
+
readonly totalBytes: number;
|
|
578
|
+
readonly slices: readonly TextureReadbackBatchSlice[];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export interface TextureReadbackBatchCallbacks {
|
|
582
|
+
readonly onResourceStart?: (handleId: string) => void;
|
|
583
|
+
readonly onResourceComplete?: (handleId: string) => void;
|
|
584
|
+
/** Return true when the owning snapshot generation has been invalidated. */
|
|
585
|
+
readonly isCancelled?: () => boolean;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Read complete texture snapshots with one submission and drain per bounded
|
|
590
|
+
* resource batch. Each subresource retains its own staging buffer, so mip and
|
|
591
|
+
* array-layer bytes are copied without padding or ordering loss.
|
|
592
|
+
*/
|
|
593
|
+
export async function readbackTexturePixelsBatch(
|
|
594
|
+
device: RhiDevice,
|
|
595
|
+
requests: readonly TextureReadbackBatchRequest[],
|
|
596
|
+
callbacks: TextureReadbackBatchCallbacks = {},
|
|
597
|
+
): Promise<Result<ReadonlyMap<string, ArrayBuffer>, RhiDebugError>> {
|
|
598
|
+
if (requests.length === 0) return ok(new Map());
|
|
599
|
+
const firstRequest = requests[0];
|
|
600
|
+
if (firstRequest === undefined) return ok(new Map());
|
|
601
|
+
|
|
602
|
+
const fail = (handleId: string, phase: 'copy' | 'map', cause: string) =>
|
|
603
|
+
err(
|
|
604
|
+
createRhiDebugError('readback-failed', {
|
|
605
|
+
stage: 'readback',
|
|
606
|
+
phase,
|
|
607
|
+
cause: `${handleId}: ${cause}`,
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
const staging: Array<{
|
|
611
|
+
readonly request: TextureReadbackBatchRequest;
|
|
612
|
+
readonly slice: TextureReadbackBatchSlice;
|
|
613
|
+
readonly buffer: Buffer;
|
|
614
|
+
readonly bytesPerBlock: number;
|
|
615
|
+
readonly blockWidth: number;
|
|
616
|
+
readonly blockHeight: number;
|
|
617
|
+
}> = [];
|
|
618
|
+
const mapped = new Map<Buffer, MappedBuffer>();
|
|
619
|
+
const cleaned = new Set<Buffer>();
|
|
620
|
+
const cleanup = () => {
|
|
621
|
+
for (const mappedBuffer of mapped.values()) mappedBuffer.unmap();
|
|
622
|
+
for (const item of staging) {
|
|
623
|
+
if (!cleaned.has(item.buffer)) {
|
|
624
|
+
device.destroyBuffer(item.buffer);
|
|
625
|
+
cleaned.add(item.buffer);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
let encoder: RhiCommandEncoder;
|
|
631
|
+
try {
|
|
632
|
+
const encoderResult = device.createCommandEncoder({});
|
|
633
|
+
if (!encoderResult.ok)
|
|
634
|
+
return fail(
|
|
635
|
+
firstRequest.handleId,
|
|
636
|
+
'copy',
|
|
637
|
+
`command encoder creation failed: ${encoderResult.error.code}`,
|
|
638
|
+
);
|
|
639
|
+
encoder = encoderResult.value;
|
|
640
|
+
for (const request of requests) {
|
|
641
|
+
for (const slice of request.slices) {
|
|
642
|
+
const blockCountX = Math.ceil(slice.width / request.blockWidth);
|
|
643
|
+
const blockCountY = Math.ceil(slice.height / request.blockHeight);
|
|
644
|
+
const rowBytes = blockCountX * request.bytesPerBlock;
|
|
645
|
+
const alignedRowBytes = Math.ceil(rowBytes / 256) * 256;
|
|
646
|
+
const stagingResult = device.createBuffer({
|
|
647
|
+
size: alignedRowBytes * blockCountY,
|
|
648
|
+
usage: TEXTURE_READBACK_USAGE,
|
|
649
|
+
});
|
|
650
|
+
if (!stagingResult.ok) {
|
|
651
|
+
cleanup();
|
|
652
|
+
return fail(
|
|
653
|
+
request.handleId,
|
|
654
|
+
'copy',
|
|
655
|
+
`staging buffer creation failed: ${stagingResult.error.code}`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
const stagingBuffer = stagingResult.value;
|
|
659
|
+
staging.push({
|
|
660
|
+
request,
|
|
661
|
+
slice,
|
|
662
|
+
buffer: stagingBuffer,
|
|
663
|
+
bytesPerBlock: request.bytesPerBlock,
|
|
664
|
+
blockWidth: request.blockWidth,
|
|
665
|
+
blockHeight: request.blockHeight,
|
|
666
|
+
});
|
|
667
|
+
try {
|
|
668
|
+
encoder.copyTextureToBuffer(
|
|
669
|
+
{
|
|
670
|
+
texture: request.texture,
|
|
671
|
+
mipLevel: slice.mip,
|
|
672
|
+
origin: { x: 0, y: 0, z: slice.layer },
|
|
673
|
+
} as unknown as never,
|
|
674
|
+
{
|
|
675
|
+
buffer: stagingBuffer,
|
|
676
|
+
offset: 0,
|
|
677
|
+
bytesPerRow: alignedRowBytes,
|
|
678
|
+
rowsPerImage: blockCountY,
|
|
679
|
+
} as unknown as never,
|
|
680
|
+
{
|
|
681
|
+
width: blockCountX * request.blockWidth,
|
|
682
|
+
height: blockCountY * request.blockHeight,
|
|
683
|
+
depthOrArrayLayers: 1,
|
|
684
|
+
},
|
|
685
|
+
);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
cleanup();
|
|
688
|
+
return fail(request.handleId, 'copy', `copyTextureToBuffer failed: ${String(error)}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const finishResult = encoder.finish();
|
|
693
|
+
if (!finishResult.ok) {
|
|
694
|
+
cleanup();
|
|
695
|
+
return fail(
|
|
696
|
+
firstRequest.handleId,
|
|
697
|
+
'copy',
|
|
698
|
+
`encoder.finish failed: ${finishResult.error.code}`,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
device.queue.submit([finishResult.value as unknown as never] as unknown as readonly never[]);
|
|
702
|
+
const drain = raceCancellation(device.queue.onSubmittedWorkDone(), callbacks.isCancelled);
|
|
703
|
+
const drainResult = await drain;
|
|
704
|
+
if (drainResult.cancelled) {
|
|
705
|
+
cleanup();
|
|
706
|
+
return fail(
|
|
707
|
+
firstRequest.handleId,
|
|
708
|
+
'map',
|
|
709
|
+
'texture batch readback cancelled after the snapshot generation was invalidated',
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
for (const request of requests) callbacks.onResourceStart?.(request.handleId);
|
|
714
|
+
const mapResultsPromise = Promise.all(
|
|
715
|
+
staging.map(async (item) => {
|
|
716
|
+
try {
|
|
717
|
+
const result = await item.buffer.mapAsync(0x1);
|
|
718
|
+
if (result.ok) {
|
|
719
|
+
if (callbacks.isCancelled?.()) result.value.unmap();
|
|
720
|
+
else mapped.set(item.buffer, result.value);
|
|
721
|
+
}
|
|
722
|
+
return { item, result };
|
|
723
|
+
} catch (error) {
|
|
724
|
+
return { item, error };
|
|
725
|
+
}
|
|
726
|
+
}),
|
|
727
|
+
);
|
|
728
|
+
const mapResults = await raceCancellation(mapResultsPromise, callbacks.isCancelled);
|
|
729
|
+
if (mapResults.cancelled) {
|
|
730
|
+
cleanup();
|
|
731
|
+
return fail(
|
|
732
|
+
firstRequest.handleId,
|
|
733
|
+
'map',
|
|
734
|
+
'texture batch readback cancelled after the snapshot generation was invalidated',
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const bytesByHandle = new Map<string, Uint8Array>();
|
|
739
|
+
for (const request of requests)
|
|
740
|
+
bytesByHandle.set(request.handleId, new Uint8Array(request.totalBytes));
|
|
741
|
+
for (const mappedResult of mapResults.value) {
|
|
742
|
+
if ('error' in mappedResult) {
|
|
743
|
+
cleanup();
|
|
744
|
+
return fail(
|
|
745
|
+
mappedResult.item.request.handleId,
|
|
746
|
+
'map',
|
|
747
|
+
`mapAsync(READ) failed: ${String(mappedResult.error)}`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
if (!mappedResult.result.ok) {
|
|
751
|
+
cleanup();
|
|
752
|
+
return fail(
|
|
753
|
+
mappedResult.item.request.handleId,
|
|
754
|
+
'map',
|
|
755
|
+
`mapAsync(READ) failed: ${mappedResult.result.error.code}`,
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
const item = mappedResult.item;
|
|
759
|
+
const mappedBuffer = mappedResult.result.value;
|
|
760
|
+
const rangeResult = mappedBuffer.getMappedRange();
|
|
761
|
+
if (!rangeResult.ok) {
|
|
762
|
+
cleanup();
|
|
763
|
+
return fail(
|
|
764
|
+
item.request.handleId,
|
|
765
|
+
'map',
|
|
766
|
+
`getMappedRange failed: ${rangeResult.error.code}`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
const fullBytes = new Uint8Array(rangeResult.value);
|
|
770
|
+
const blockCountX = Math.ceil(item.slice.width / item.blockWidth);
|
|
771
|
+
const blockCountY = Math.ceil(item.slice.height / item.blockHeight);
|
|
772
|
+
const rowBytes = blockCountX * item.bytesPerBlock;
|
|
773
|
+
const alignedRowBytes = Math.ceil(rowBytes / 256) * 256;
|
|
774
|
+
const output = bytesByHandle.get(item.request.handleId);
|
|
775
|
+
if (output === undefined) {
|
|
776
|
+
cleanup();
|
|
777
|
+
return fail(item.request.handleId, 'map', 'texture batch returned an unknown handle');
|
|
778
|
+
}
|
|
779
|
+
for (let y = 0; y < blockCountY; y++) {
|
|
780
|
+
const srcOffset = y * alignedRowBytes;
|
|
781
|
+
const dstOffset = item.slice.byteOffset + y * rowBytes;
|
|
782
|
+
for (let x = 0; x < rowBytes; x++) output[dstOffset + x] = fullBytes[srcOffset + x] ?? 0;
|
|
783
|
+
}
|
|
784
|
+
mappedBuffer.unmap();
|
|
785
|
+
mapped.delete(item.buffer);
|
|
786
|
+
device.destroyBuffer(item.buffer);
|
|
787
|
+
cleaned.add(item.buffer);
|
|
788
|
+
}
|
|
789
|
+
const result = new Map<string, ArrayBuffer>();
|
|
790
|
+
for (const request of requests) {
|
|
791
|
+
const bytes = bytesByHandle.get(request.handleId);
|
|
792
|
+
if (bytes === undefined) {
|
|
793
|
+
cleanup();
|
|
794
|
+
return fail(request.handleId, 'map', 'texture batch returned no bytes for a live texture');
|
|
795
|
+
}
|
|
796
|
+
result.set(request.handleId, bytes.buffer as ArrayBuffer);
|
|
797
|
+
callbacks.onResourceComplete?.(request.handleId);
|
|
798
|
+
}
|
|
799
|
+
return ok(result);
|
|
800
|
+
} catch (error) {
|
|
801
|
+
cleanup();
|
|
802
|
+
return fail(firstRequest.handleId, 'map', `texture batch readback failed: ${String(error)}`);
|
|
803
|
+
}
|
|
804
|
+
}
|