@forgeax/engine-rhi-webgpu 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 +133 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/__mocks__/gpu-device.d.ts +226 -0
- package/dist/__tests__/__mocks__/gpu-device.d.ts.map +1 -0
- package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts +2 -0
- package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-webgpu.unit.test.d.ts +2 -0
- package/dist/__tests__/rhi-webgpu.unit.test.d.ts.map +1 -0
- package/dist/device.d.ts +62 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/errors.d.ts +49 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +183 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1745 -0
- package/dist/index.mjs.map +1 -0
- package/dist/internal/__tests__/timestamp-query.unit.test.d.ts +2 -0
- package/dist/internal/__tests__/timestamp-query.unit.test.d.ts.map +1 -0
- package/dist/internal/error-translation.d.ts +16 -0
- package/dist/internal/error-translation.d.ts.map +1 -0
- package/dist/internal/timestamp-query.d.ts +15 -0
- package/dist/internal/timestamp-query.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/__tests__/__mocks__/gpu-device.ts +555 -0
- package/src/__tests__/dawn-real-gpu.dawn.test.ts +1445 -0
- package/src/__tests__/rhi-webgpu.unit.test.ts +2398 -0
- package/src/device.ts +2102 -0
- package/src/errors.ts +183 -0
- package/src/index.ts +597 -0
- package/src/internal/__tests__/timestamp-query.unit.test.ts +88 -0
- package/src/internal/error-translation.ts +187 -0
- package/src/internal/timestamp-query.ts +59 -0
package/src/device.ts
ADDED
|
@@ -0,0 +1,2102 @@
|
|
|
1
|
+
// @forgeax/engine-rhi-webgpu/src/device - WebGPU GPUDevice -> RhiDevice thin shim.
|
|
2
|
+
//
|
|
3
|
+
// Iron laws (README + AGENTS.md "## RHI / WebGPU"):
|
|
4
|
+
// - spec-aligned: 5 + 4 descriptor field names match GPUDevice.createX byte-for-byte.
|
|
5
|
+
// - `?: T | undefined` + `'x' in src` guard distinguishes "missing" vs
|
|
6
|
+
// "explicit undefined" (research F-3 anti-pattern 2 / hard-constraint 10).
|
|
7
|
+
// - opaque handle: RhiDevice.createX returns Result.ok(handle); the handle
|
|
8
|
+
// internally references the GPU resource but exposes only a brand-only
|
|
9
|
+
// opaque shape (research R5).
|
|
10
|
+
// - capability-gated: caps / features / limits exposed independently as
|
|
11
|
+
// readonly layers (charter proposition 5).
|
|
12
|
+
// - device.lost two-track: spec Promise passthrough + engine-layer
|
|
13
|
+
// LostListenerRegistry (R2 mitigation / research F-4); this package only
|
|
14
|
+
// exposes the spec Promise without a second cache.
|
|
15
|
+
//
|
|
16
|
+
// w3 / w5 / w6 (feat-20260508-rhi-surface-completion, co-commit):
|
|
17
|
+
// - createCommandEncoder + 12 RhiCommandEncoder methods + 3 mixin (w3)
|
|
18
|
+
// - 17 RhiRenderPassEncoder spec stable methods + 1 setBindGroup overload +
|
|
19
|
+
// 3 placeholders (executeBundles / beginOcclusionQuery / endOcclusionQuery) (w5)
|
|
20
|
+
// - Queue.submit / writeBuffer real implementation + bounds validation (w6)
|
|
21
|
+
//
|
|
22
|
+
// co-commit reason: w3, w5, w6 each modify both `@forgeax/engine-rhi/src/index.ts`
|
|
23
|
+
// and `@forgeax/engine-rhi-webgpu/src/device.ts`; the encoder lifecycle (D-S3
|
|
24
|
+
// templates 1 + 2) couples render-pass end() to command-encoder finish(),
|
|
25
|
+
// so independent commits would leave the interface and shim inconsistent
|
|
26
|
+
// between commits.
|
|
27
|
+
|
|
28
|
+
/// <reference types="@webgpu/types" />
|
|
29
|
+
|
|
30
|
+
import type {
|
|
31
|
+
BindGroup,
|
|
32
|
+
BindGroupDescriptor,
|
|
33
|
+
BindGroupLayout,
|
|
34
|
+
BindGroupLayoutDescriptor,
|
|
35
|
+
Buffer,
|
|
36
|
+
BufferDescriptor,
|
|
37
|
+
CanvasConfiguration,
|
|
38
|
+
CommandBuffer,
|
|
39
|
+
CommandEncoderDescriptor,
|
|
40
|
+
ComputePassDescriptor,
|
|
41
|
+
ComputePipeline,
|
|
42
|
+
ComputePipelineDescriptor,
|
|
43
|
+
ExternalImageTextureDestination,
|
|
44
|
+
MappedBuffer,
|
|
45
|
+
PipelineLayout,
|
|
46
|
+
PipelineLayoutDescriptor,
|
|
47
|
+
QuerySet,
|
|
48
|
+
QuerySetDescriptor,
|
|
49
|
+
RenderPassDescriptor,
|
|
50
|
+
RenderPipeline,
|
|
51
|
+
RenderPipelineDescriptor,
|
|
52
|
+
Result,
|
|
53
|
+
RhiCanvasContext,
|
|
54
|
+
RhiCaps,
|
|
55
|
+
RhiCommandEncoder,
|
|
56
|
+
RhiComputePassEncoder,
|
|
57
|
+
RhiDevice,
|
|
58
|
+
RhiError,
|
|
59
|
+
RhiFeatures,
|
|
60
|
+
RhiLimits,
|
|
61
|
+
RhiQueue,
|
|
62
|
+
RhiRenderPassEncoder,
|
|
63
|
+
Sampler,
|
|
64
|
+
SamplerDescriptor,
|
|
65
|
+
Texture,
|
|
66
|
+
TextureDescriptor,
|
|
67
|
+
TextureView,
|
|
68
|
+
TextureViewDescriptor,
|
|
69
|
+
TextureWriteDestination,
|
|
70
|
+
} from '@forgeax/engine-rhi';
|
|
71
|
+
import { err, ok, RhiError as RhiErrorClass } from '@forgeax/engine-rhi';
|
|
72
|
+
import {
|
|
73
|
+
commandEncoderFinished,
|
|
74
|
+
queueSubmitFailed,
|
|
75
|
+
queueWriteBufferOutOfBounds,
|
|
76
|
+
renderPassNotEnded,
|
|
77
|
+
} from './errors';
|
|
78
|
+
import { resolveTimestampQueries, writeTimestamp } from './internal/timestamp-query';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Mirror forgeax `?: T | undefined` descriptor onto the spec GPUXxxDescriptor
|
|
82
|
+
* shape with `'x' in src` guards distinguishing missing vs explicit-undefined:
|
|
83
|
+
*
|
|
84
|
+
* - missing key -> out has no key (preserves the spec `?: T` simplified form)
|
|
85
|
+
* - present + undefined -> out has the key set to undefined (technically out
|
|
86
|
+
* of bounds for spec `?: T` but the shim preserves writer intent for
|
|
87
|
+
* downstream test assertions; research F-3 trade-off)
|
|
88
|
+
* - present + value -> out has the key with the value
|
|
89
|
+
*
|
|
90
|
+
* Equivalent shape: under exactOptionalPropertyTypes:true we must spread
|
|
91
|
+
* rather than `dst[k] = src[k]` because the latter maps both missing and
|
|
92
|
+
* explicit-undefined to "present + undefined".
|
|
93
|
+
*/
|
|
94
|
+
type MirrorOut = Record<string, unknown>;
|
|
95
|
+
|
|
96
|
+
function mirror<TIn extends Record<string, unknown>>(
|
|
97
|
+
src: TIn,
|
|
98
|
+
keys: readonly (keyof TIn & string)[],
|
|
99
|
+
): MirrorOut {
|
|
100
|
+
const out: MirrorOut = {};
|
|
101
|
+
for (const k of keys) {
|
|
102
|
+
if (k in src) {
|
|
103
|
+
out[k] = src[k];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const BUFFER_KEYS = ['label', 'size', 'usage', 'mappedAtCreation'] as const;
|
|
110
|
+
const TEXTURE_KEYS = [
|
|
111
|
+
'label',
|
|
112
|
+
'size',
|
|
113
|
+
'mipLevelCount',
|
|
114
|
+
'sampleCount',
|
|
115
|
+
'dimension',
|
|
116
|
+
'format',
|
|
117
|
+
'usage',
|
|
118
|
+
'viewFormats',
|
|
119
|
+
'textureBindingViewDimension',
|
|
120
|
+
] as const;
|
|
121
|
+
const SAMPLER_KEYS = [
|
|
122
|
+
'label',
|
|
123
|
+
'addressModeU',
|
|
124
|
+
'addressModeV',
|
|
125
|
+
'addressModeW',
|
|
126
|
+
'magFilter',
|
|
127
|
+
'minFilter',
|
|
128
|
+
'mipmapFilter',
|
|
129
|
+
'lodMinClamp',
|
|
130
|
+
'lodMaxClamp',
|
|
131
|
+
'compare',
|
|
132
|
+
'maxAnisotropy',
|
|
133
|
+
] as const;
|
|
134
|
+
const BGL_KEYS = ['label', 'entries'] as const;
|
|
135
|
+
// BG_KEYS removed in w20 — createBindGroup now uses tagged-union dispatch
|
|
136
|
+
// over RhiBindingResource 4 kinds (not field-mirror passthrough).
|
|
137
|
+
const PL_KEYS = ['label', 'bindGroupLayouts'] as const;
|
|
138
|
+
const ENC_KEYS = ['label'] as const;
|
|
139
|
+
const TEXTURE_VIEW_KEYS = [
|
|
140
|
+
'label',
|
|
141
|
+
'format',
|
|
142
|
+
'dimension',
|
|
143
|
+
'usage',
|
|
144
|
+
'aspect',
|
|
145
|
+
'baseMipLevel',
|
|
146
|
+
'mipLevelCount',
|
|
147
|
+
'baseArrayLayer',
|
|
148
|
+
'arrayLayerCount',
|
|
149
|
+
] as const;
|
|
150
|
+
const CP_KEYS = ['label', 'layout', 'compute'] as const;
|
|
151
|
+
const QS_KEYS = ['label', 'type', 'count'] as const;
|
|
152
|
+
/** Spec normative upper bound on QuerySet.count (research §1.3 device timeline step 1). */
|
|
153
|
+
const QUERY_SET_COUNT_LIMIT = 4096;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* RhiDevice -> raw GPUDevice reverse lookup table (fix-f3).
|
|
157
|
+
*
|
|
158
|
+
* After RhiDevice.createShaderModule was removed, the top-level async
|
|
159
|
+
* `createShaderModule(device, desc)` entry point needs to reach the
|
|
160
|
+
* underlying GPUDevice. The WeakMap keeps the public RhiDevice surface clean
|
|
161
|
+
* (no internal field) and clears automatically when the device is GC'd
|
|
162
|
+
* (charter proposition 5 consistent abstraction).
|
|
163
|
+
*
|
|
164
|
+
* Buffer<->raw GPUBuffer / Encoder<->raw GPUCommandEncoder etc. WeakMaps
|
|
165
|
+
* follow the same pattern; they enable the queue.writeBuffer real-path to
|
|
166
|
+
* resolve buffer.size for bounds validation, and the encoder shim to
|
|
167
|
+
* forward recording calls to the underlying GPUCommandEncoder.
|
|
168
|
+
*/
|
|
169
|
+
const RAW_DEVICE_MAP: WeakMap<RhiDevice, GPUDevice> = new WeakMap();
|
|
170
|
+
const BUFFER_RAW_MAP: WeakMap<Buffer, GPUBuffer> = new WeakMap();
|
|
171
|
+
const TEXTURE_VIEW_RAW_MAP: WeakMap<TextureView, GPUTextureView> = new WeakMap();
|
|
172
|
+
const ENCODER_STATE: WeakMap<RhiCommandEncoder, EncoderState> = new WeakMap();
|
|
173
|
+
const PASS_STATE: WeakMap<RhiRenderPassEncoder, PassState> = new WeakMap();
|
|
174
|
+
const COMMAND_BUFFER_RAW_MAP: WeakMap<CommandBuffer, GPUCommandBuffer> = new WeakMap();
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Texture metadata tracked by the shim for createTextureView fast-path
|
|
178
|
+
* cross-resource validation (research §1.1):
|
|
179
|
+
* - format / viewFormats: format must be in (format ∪ viewFormats).
|
|
180
|
+
* - usage: createTextureView usage must be a subset of source usage.
|
|
181
|
+
*
|
|
182
|
+
* The shim records these at createTexture time; createTextureView reads them
|
|
183
|
+
* before forwarding to the raw GPU. spec rationale: GPUTexture exposes
|
|
184
|
+
* `.format` / `.usage` etc. as readonly fields, but `.viewFormats` is not
|
|
185
|
+
* surfaced as a runtime field on GPUTexture in @webgpu/types v0.1.69 — the
|
|
186
|
+
* shim must remember it from the descriptor.
|
|
187
|
+
*/
|
|
188
|
+
interface TextureMeta {
|
|
189
|
+
readonly format: GPUTextureFormat;
|
|
190
|
+
readonly usage: GPUTextureUsageFlags;
|
|
191
|
+
readonly viewFormats: readonly GPUTextureFormat[];
|
|
192
|
+
/**
|
|
193
|
+
* Per-handle lifecycle marker for `RhiDevice.destroyTexture` fail-fast
|
|
194
|
+
* (feat-20260612 D-7). Mutated to `true` by the first successful
|
|
195
|
+
* `destroyTexture(tex)` call; a second destroy on the same handle reads
|
|
196
|
+
* the flag and surfaces `Result.err({ code: 'destroy-after-destroy' })`
|
|
197
|
+
* rather than forwarding to the underlying spec idempotent void.
|
|
198
|
+
*/
|
|
199
|
+
destroyed: boolean;
|
|
200
|
+
}
|
|
201
|
+
const TEXTURE_META_MAP: WeakMap<Texture, TextureMeta> = new WeakMap();
|
|
202
|
+
|
|
203
|
+
interface EncoderState {
|
|
204
|
+
raw: GPUCommandEncoder;
|
|
205
|
+
finished: boolean;
|
|
206
|
+
activePass: RhiRenderPassEncoder | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface PassState {
|
|
210
|
+
raw: GPURenderPassEncoder;
|
|
211
|
+
ended: boolean;
|
|
212
|
+
encoder: RhiCommandEncoder;
|
|
213
|
+
// [[occlusion_query_set]] (research §2.1): the QuerySet injected via
|
|
214
|
+
// GPURenderPassDescriptor.occlusionQuerySet at beginRenderPass time.
|
|
215
|
+
// null means occlusion queries are unavailable in this pass.
|
|
216
|
+
occlusionQuerySet: QuerySet | null;
|
|
217
|
+
// [[occlusion_query_active]] (research §2.1): true when a beginOcclusionQuery
|
|
218
|
+
// has been issued without a matching endOcclusionQuery yet. Spec normative:
|
|
219
|
+
// pairs cannot nest.
|
|
220
|
+
occlusionQueryActive: boolean;
|
|
221
|
+
// queryIndex written by a previous beginOcclusionQuery in this pass; spec
|
|
222
|
+
// normative: a queryIndex written previously in this pass cannot be reused
|
|
223
|
+
// (cross-pass reuse on the same querySet is legal per dawn `Rewrite` mode).
|
|
224
|
+
occlusionQueryWritten: Set<number>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** WeakMap that tracks raw GPUQuerySet for createQuerySet handles so the shim
|
|
228
|
+
* can read `.count` for queryIndex bounds checking (research §2.1 step 2). */
|
|
229
|
+
const QUERY_SET_RAW_MAP: WeakMap<QuerySet, GPUQuerySet> = new WeakMap();
|
|
230
|
+
const QUERY_SET_DESTROYED_MAP: WeakMap<QuerySet, { destroyed: boolean }> = new WeakMap();
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* WeakMap that tracks Buffer metadata (size + usage) so resolveQuerySet (and
|
|
234
|
+
* other shim entry points) can validate spec preconditions before forwarding
|
|
235
|
+
* to the raw GPU (research §2.3 destination.usage / destinationOffset bounds).
|
|
236
|
+
*
|
|
237
|
+
* usage is the bitmask passed at createBuffer time; size is `desc.size` (the
|
|
238
|
+
* underlying GPUBuffer.size is also readable but the shim records the
|
|
239
|
+
* descriptor-level size for parity with TextureMeta).
|
|
240
|
+
*/
|
|
241
|
+
interface BufferMeta {
|
|
242
|
+
readonly size: number;
|
|
243
|
+
readonly usage: GPUBufferUsageFlags;
|
|
244
|
+
/**
|
|
245
|
+
* Per-handle lifecycle marker for `RhiDevice.destroyBuffer` fail-fast
|
|
246
|
+
* (feat-20260612 D-7). Mutated to `true` by the first successful
|
|
247
|
+
* `destroyBuffer(buf)` call; a second destroy on the same handle reads
|
|
248
|
+
* the flag and surfaces `Result.err({ code: 'destroy-after-destroy' })`
|
|
249
|
+
* rather than forwarding to the underlying spec idempotent void.
|
|
250
|
+
*/
|
|
251
|
+
destroyed: boolean;
|
|
252
|
+
}
|
|
253
|
+
const BUFFER_META_MAP: WeakMap<Buffer, BufferMeta> = new WeakMap();
|
|
254
|
+
/** GPUBufferUsage.QUERY_RESOLVE bit (W3C WebGPU §3.5.2 GPUBufferUsage). */
|
|
255
|
+
const BUFFER_USAGE_QUERY_RESOLVE = 0x200;
|
|
256
|
+
/** Spec normative resolve alignment (research §2.3 step 6 +
|
|
257
|
+
* kQueryResolveAlignment in dawn). */
|
|
258
|
+
const QUERY_RESOLVE_ALIGNMENT = 256;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get the underlying GPUDevice associated with a RhiDevice.
|
|
262
|
+
*
|
|
263
|
+
* @internal
|
|
264
|
+
*
|
|
265
|
+
* Single-point escape hatch (D-S1 / feat-20260508-rhi-surface-completion).
|
|
266
|
+
* The `_internal_` prefix + `@internal` JSDoc tag mark this as engine-internal
|
|
267
|
+
* plumbing; the only sanctioned consumer is
|
|
268
|
+
* `apps/hello/triangle/src/main.ts:96` which threads the raw GPUDevice into
|
|
269
|
+
* the host's internal canvas-device configuration slot so the canvas
|
|
270
|
+
* `GPUCanvasContext.configure({device})` slot keeps working
|
|
271
|
+
* (GPUCanvasContext is outside the RHI surface). Every other engine path
|
|
272
|
+
* goes through the RHI interface.
|
|
273
|
+
*
|
|
274
|
+
* Future: deprecated once `feat-future-rhi-adapter-surface` lands a
|
|
275
|
+
* `RhiCanvasContext` abstraction; this function will be removed at that
|
|
276
|
+
* closure. AC-08 grep gate keeps further callers out via word-boundary
|
|
277
|
+
* `\bgetRawDevice\b` allow-list (see apps/hello/triangle/scripts/ac-08-grep-gate.mjs).
|
|
278
|
+
*/
|
|
279
|
+
export function _internal_getRawDevice(device: RhiDevice): GPUDevice | undefined {
|
|
280
|
+
return RAW_DEVICE_MAP.get(device);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Caps probe — split by spec-feature gate vs mandatory-but-spec-noncompliant
|
|
284
|
+
// fallback (m1-1-b scope-amend, plan §6 D-2 gap):
|
|
285
|
+
//
|
|
286
|
+
// * Optional spec features (`rg11b10ufloat-renderable`,
|
|
287
|
+
// `float32-filterable`) carry an authoritative `device.features.has(...)`
|
|
288
|
+
// answer. A `createTexture` probe on the unsupported format triggers a
|
|
289
|
+
// WebGPU validation error which the dawn / Chrome implementations
|
|
290
|
+
// fan out via `device.onuncapturederror` even when caught by JS try /
|
|
291
|
+
// catch — the SUT-level `onerror-gate` (apps/shared/src/onerror-gate.ts)
|
|
292
|
+
// observes that channel and turns a green-on-paper probe into a CI red
|
|
293
|
+
// `limit-exceeded` error. Solution: gate the probe by `features.has(...)`
|
|
294
|
+
// first and skip the destructive `createTexture` call entirely when the
|
|
295
|
+
// feature is absent.
|
|
296
|
+
//
|
|
297
|
+
// * `rgba16float` is mandatory `RENDER_ATTACHMENT` per spec but observed
|
|
298
|
+
// unreliable on WebKit — exactly the AC-02 motivation. Keep the real
|
|
299
|
+
// `createTexture` probe here: any browser declaring it but rejecting at
|
|
300
|
+
// `createTexture` time deserves a `false` cap, and a violation under
|
|
301
|
+
// `rgba16float` would ALREADY break HDR / IBL paths so the
|
|
302
|
+
// onuncapturederror fan-out is correctly diagnostic, not noise.
|
|
303
|
+
|
|
304
|
+
function probeRgba16floatRenderable(device: GPUDevice): boolean {
|
|
305
|
+
let tex: GPUTexture | undefined;
|
|
306
|
+
try {
|
|
307
|
+
tex = device.createTexture({
|
|
308
|
+
label: 'forgeax-caps-probe-rgba16float-renderable',
|
|
309
|
+
format: 'rgba16float',
|
|
310
|
+
usage: 16, // GPUTextureUsage.RENDER_ATTACHMENT
|
|
311
|
+
size: [1, 1, 1],
|
|
312
|
+
});
|
|
313
|
+
return true;
|
|
314
|
+
} catch {
|
|
315
|
+
return false;
|
|
316
|
+
} finally {
|
|
317
|
+
tex?.destroy?.();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function probeRg11b10ufloatRenderable(device: GPUDevice, features: GPUSupportedFeatures): boolean {
|
|
322
|
+
// Feature-gated: `rg11b10ufloat-renderable` is an optional feature per spec.
|
|
323
|
+
// Skip the destructive createTexture probe when the feature is absent so the
|
|
324
|
+
// dawn / Chrome backend's onuncapturederror channel does not fire (CI gate).
|
|
325
|
+
if (!features.has('rg11b10ufloat-renderable' as GPUFeatureName)) return false;
|
|
326
|
+
let tex: GPUTexture | undefined;
|
|
327
|
+
try {
|
|
328
|
+
tex = device.createTexture({
|
|
329
|
+
label: 'forgeax-caps-probe-rg11b10ufloat-renderable',
|
|
330
|
+
format: 'rg11b10ufloat',
|
|
331
|
+
usage: 16, // GPUTextureUsage.RENDER_ATTACHMENT
|
|
332
|
+
size: [1, 1, 1],
|
|
333
|
+
});
|
|
334
|
+
return true;
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
} finally {
|
|
338
|
+
tex?.destroy?.();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function probeFloat32Filterable(device: GPUDevice, features: GPUSupportedFeatures): boolean {
|
|
343
|
+
// Feature-gated: `float32-filterable` is an optional feature per spec.
|
|
344
|
+
// The bind-group-layout validation below would trigger
|
|
345
|
+
// onuncapturederror when the feature is absent.
|
|
346
|
+
if (!features.has('float32-filterable' as GPUFeatureName)) return false;
|
|
347
|
+
try {
|
|
348
|
+
device.createBindGroupLayout({
|
|
349
|
+
entries: [
|
|
350
|
+
{ binding: 0, visibility: 2, sampler: { type: 'filtering' } }, // GPUShaderStage.FRAGMENT = 2
|
|
351
|
+
{ binding: 1, visibility: 2, texture: { sampleType: 'float' } },
|
|
352
|
+
],
|
|
353
|
+
});
|
|
354
|
+
device.createSampler({ minFilter: 'linear', magFilter: 'linear' });
|
|
355
|
+
return true;
|
|
356
|
+
} catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Probe the 11 caps fields from GPUDevice.features + GPUDevice.limits
|
|
362
|
+
* (charter proposition 5). 4 new fields (samplerAliasing /
|
|
363
|
+
* firstInstanceIndirect / storageBuffer / storageTexture) added by
|
|
364
|
+
* feat-20260511-rhi-spec-realign-aggressive w19 per D-P3 + research R-03
|
|
365
|
+
* §3.1 mapping matrix. 3 new HDR / filterable caps fields
|
|
366
|
+
* (rgba16floatRenderable / rg11b10ufloatRenderable / float32Filterable)
|
|
367
|
+
* added by feat-20260608-rhi-hdr-renderable-caps-and-warn-once M1 per
|
|
368
|
+
* D-1 + D-2 + D-2.1. */
|
|
369
|
+
function deriveCaps(
|
|
370
|
+
rawDevice: GPUDevice,
|
|
371
|
+
features: GPUSupportedFeatures,
|
|
372
|
+
limits: GPUSupportedLimits,
|
|
373
|
+
): RhiCaps {
|
|
374
|
+
const has = (name: string): boolean => features.has(name as GPUFeatureName);
|
|
375
|
+
// Texture-compression three-way caps (M4 w26): derived from adapter.features
|
|
376
|
+
// per-compression-format, replacing the single rolled-up boolean.
|
|
377
|
+
// D-8: no hand-assigned true/false literals; all derived via has(...) from
|
|
378
|
+
// the features Set.
|
|
379
|
+
const textureCompressionBc = has('texture-compression-bc');
|
|
380
|
+
const textureCompressionEtc2 = has('texture-compression-etc2');
|
|
381
|
+
const textureCompressionAstc = has('texture-compression-astc');
|
|
382
|
+
// HDR renderable + filterable caps (m1-1-b: split probe by spec-feature gate
|
|
383
|
+
// vs mandatory-but-noncompliant fallback). `rgba16float` is mandatory
|
|
384
|
+
// `RENDER_ATTACHMENT` per spec but unreliable on WebKit — keep the real
|
|
385
|
+
// probe (AC-02 motivation). `rg11b10ufloat-renderable` and `float32-
|
|
386
|
+
// filterable` carry authoritative `features.has(...)` answers; gate by
|
|
387
|
+
// feature first to avoid fan-out via `device.onuncapturederror` (apps/
|
|
388
|
+
// shared/src/onerror-gate.ts) when the optional feature is absent.
|
|
389
|
+
const hdrCaps = {
|
|
390
|
+
rgba16floatRenderable: probeRgba16floatRenderable(rawDevice),
|
|
391
|
+
rg11b10ufloatRenderable: probeRg11b10ufloatRenderable(rawDevice, features),
|
|
392
|
+
float32Filterable: probeFloat32Filterable(rawDevice, features),
|
|
393
|
+
};
|
|
394
|
+
return {
|
|
395
|
+
backendKind: 'webgpu' as const,
|
|
396
|
+
compute: true, // WebGPU spec mandates compute-pipeline support.
|
|
397
|
+
timestampQuery: has('timestamp-query'),
|
|
398
|
+
timestampPeriodNanoseconds: has('timestamp-query') ? 1 : null,
|
|
399
|
+
indirectDrawing: true, // WebGPU spec mandates drawIndirect / drawIndexedIndirect.
|
|
400
|
+
textureCompressionBc,
|
|
401
|
+
textureCompressionEtc2,
|
|
402
|
+
textureCompressionAstc,
|
|
403
|
+
multiDrawIndirect: false, // wgpu native extension; unavailable on WebGPU browser path.
|
|
404
|
+
pushConstants: false, // wgpu native extension; unavailable on WebGPU browser path.
|
|
405
|
+
textureBindingArray: false, // wgpu native extension; unavailable on WebGPU browser path.
|
|
406
|
+
// 4 new fields (D-P3 / R-03 §3.1):
|
|
407
|
+
samplerAliasing: true, // spec mandatory on browser backends.
|
|
408
|
+
firstInstanceIndirect: has('indirect-first-instance'),
|
|
409
|
+
storageBuffer: (limits.maxStorageBuffersPerShaderStage ?? 0) > 0,
|
|
410
|
+
storageTexture: (limits.maxStorageTexturesPerShaderStage ?? 0) > 0,
|
|
411
|
+
// HDR / filterable caps (feat-20260608 M1):
|
|
412
|
+
...hdrCaps,
|
|
413
|
+
maxColorAttachments: limits.maxColorAttachments ?? 4,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Build a RhiRenderPassEncoder around a raw GPURenderPassEncoder (w5).
|
|
419
|
+
*
|
|
420
|
+
* - 14 real-path methods forward to GPURenderPassEncoder.
|
|
421
|
+
* - 3 placeholders (executeBundles / beginOcclusionQuery / endOcclusionQuery)
|
|
422
|
+
* return Result.err({ code: 'rhi-not-available' }) per D-S4.
|
|
423
|
+
* - end() flips PassState.ended so the encoder finish() can detect a
|
|
424
|
+
* render-pass-not-ended condition (D-S3 template 2).
|
|
425
|
+
*/
|
|
426
|
+
function makeRenderPassEncoder(
|
|
427
|
+
rawPass: GPURenderPassEncoder,
|
|
428
|
+
encoder: RhiCommandEncoder,
|
|
429
|
+
occlusionQuerySet: QuerySet | null,
|
|
430
|
+
): RhiRenderPassEncoder {
|
|
431
|
+
const pass: RhiRenderPassEncoder = {
|
|
432
|
+
setPipeline(pipeline: RenderPipeline): void {
|
|
433
|
+
rawPass.setPipeline(pipeline as unknown as GPURenderPipeline);
|
|
434
|
+
},
|
|
435
|
+
setVertexBuffer(
|
|
436
|
+
slot: number,
|
|
437
|
+
buffer: Buffer,
|
|
438
|
+
offset?: number | undefined,
|
|
439
|
+
size?: number | undefined,
|
|
440
|
+
): void {
|
|
441
|
+
// M5 / w35: resolve forgeax Buffer wrapper to raw GPUBuffer.
|
|
442
|
+
const rawBuf = BUFFER_RAW_MAP.get(buffer) ?? (buffer as unknown as GPUBuffer);
|
|
443
|
+
rawPass.setVertexBuffer(slot, rawBuf, offset, size);
|
|
444
|
+
},
|
|
445
|
+
setIndexBuffer(
|
|
446
|
+
buffer: Buffer,
|
|
447
|
+
format: 'uint16' | 'uint32',
|
|
448
|
+
offset?: number | undefined,
|
|
449
|
+
size?: number | undefined,
|
|
450
|
+
): void {
|
|
451
|
+
// M5 / w35: resolve forgeax Buffer wrapper to raw GPUBuffer.
|
|
452
|
+
const rawBuf = BUFFER_RAW_MAP.get(buffer) ?? (buffer as unknown as GPUBuffer);
|
|
453
|
+
rawPass.setIndexBuffer(rawBuf, format, offset, size);
|
|
454
|
+
},
|
|
455
|
+
setBindGroup(
|
|
456
|
+
index: number,
|
|
457
|
+
bindGroup: BindGroup,
|
|
458
|
+
arg3?: readonly number[] | Uint32Array | undefined,
|
|
459
|
+
arg4?: number | undefined,
|
|
460
|
+
arg5?: number | undefined,
|
|
461
|
+
): void {
|
|
462
|
+
// Two overload forms (D-S4 setBindGroup):
|
|
463
|
+
// (a) (index, bindGroup, dynamicOffsets?: readonly number[])
|
|
464
|
+
// (b) (index, bindGroup, dynamicOffsetsData: Uint32Array,
|
|
465
|
+
// dynamicOffsetsDataStart, dynamicOffsetsDataLength)
|
|
466
|
+
if (arg3 instanceof Uint32Array) {
|
|
467
|
+
rawPass.setBindGroup(
|
|
468
|
+
index,
|
|
469
|
+
bindGroup as unknown as GPUBindGroup,
|
|
470
|
+
arg3,
|
|
471
|
+
arg4 ?? 0,
|
|
472
|
+
arg5 ?? arg3.length,
|
|
473
|
+
);
|
|
474
|
+
} else if (arg3 === undefined) {
|
|
475
|
+
rawPass.setBindGroup(index, bindGroup as unknown as GPUBindGroup);
|
|
476
|
+
} else {
|
|
477
|
+
rawPass.setBindGroup(index, bindGroup as unknown as GPUBindGroup, arg3);
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
draw(
|
|
481
|
+
vertexCount: number,
|
|
482
|
+
instanceCount?: number | undefined,
|
|
483
|
+
firstVertex?: number | undefined,
|
|
484
|
+
firstInstance?: number | undefined,
|
|
485
|
+
): void {
|
|
486
|
+
rawPass.draw(vertexCount, instanceCount, firstVertex, firstInstance);
|
|
487
|
+
},
|
|
488
|
+
drawIndexed(
|
|
489
|
+
indexCount: number,
|
|
490
|
+
instanceCount?: number | undefined,
|
|
491
|
+
firstIndex?: number | undefined,
|
|
492
|
+
baseVertex?: number | undefined,
|
|
493
|
+
firstInstance?: number | undefined,
|
|
494
|
+
): void {
|
|
495
|
+
rawPass.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance);
|
|
496
|
+
},
|
|
497
|
+
setViewport(
|
|
498
|
+
x: number,
|
|
499
|
+
y: number,
|
|
500
|
+
w: number,
|
|
501
|
+
h: number,
|
|
502
|
+
minDepth: number,
|
|
503
|
+
maxDepth: number,
|
|
504
|
+
): void {
|
|
505
|
+
rawPass.setViewport(x, y, w, h, minDepth, maxDepth);
|
|
506
|
+
},
|
|
507
|
+
setScissorRect(x: number, y: number, w: number, h: number): void {
|
|
508
|
+
rawPass.setScissorRect(x, y, w, h);
|
|
509
|
+
},
|
|
510
|
+
setBlendConstant(color: GPUColor): void {
|
|
511
|
+
rawPass.setBlendConstant(color);
|
|
512
|
+
},
|
|
513
|
+
setStencilReference(reference: number): void {
|
|
514
|
+
rawPass.setStencilReference(reference);
|
|
515
|
+
},
|
|
516
|
+
drawIndirect(indirectBuffer: Buffer, indirectOffset: number): void {
|
|
517
|
+
const rawBuf = BUFFER_RAW_MAP.get(indirectBuffer) ?? (indirectBuffer as unknown as GPUBuffer);
|
|
518
|
+
rawPass.drawIndirect(rawBuf, indirectOffset);
|
|
519
|
+
},
|
|
520
|
+
drawIndexedIndirect(indirectBuffer: Buffer, indirectOffset: number): void {
|
|
521
|
+
const rawBuf = BUFFER_RAW_MAP.get(indirectBuffer) ?? (indirectBuffer as unknown as GPUBuffer);
|
|
522
|
+
rawPass.drawIndexedIndirect(rawBuf, indirectOffset);
|
|
523
|
+
},
|
|
524
|
+
pushDebugGroup(groupLabel: string): void {
|
|
525
|
+
rawPass.pushDebugGroup(groupLabel);
|
|
526
|
+
},
|
|
527
|
+
popDebugGroup(): void {
|
|
528
|
+
rawPass.popDebugGroup();
|
|
529
|
+
},
|
|
530
|
+
insertDebugMarker(markerLabel: string): void {
|
|
531
|
+
rawPass.insertDebugMarker(markerLabel);
|
|
532
|
+
},
|
|
533
|
+
executeBundles(_bundles: Iterable<unknown>): Result<void, RhiError> {
|
|
534
|
+
return err(
|
|
535
|
+
new RhiErrorClass({
|
|
536
|
+
code: 'rhi-not-available',
|
|
537
|
+
expected: 'render bundle creation requires future closed loop',
|
|
538
|
+
hint: 'see feat-future-rhi-render-bundle',
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
},
|
|
542
|
+
beginOcclusionQuery(queryIndex: number): Result<void, RhiError> {
|
|
543
|
+
const state = PASS_STATE.get(pass);
|
|
544
|
+
if (state === undefined) {
|
|
545
|
+
return err(
|
|
546
|
+
new RhiErrorClass({
|
|
547
|
+
code: 'webgpu-runtime-error',
|
|
548
|
+
expected: 'render pass state must exist',
|
|
549
|
+
hint: 'beginOcclusionQuery called on an untracked render pass',
|
|
550
|
+
}),
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
// Step 1 (research §2.1): [[occlusion_query_set]] != null.
|
|
554
|
+
if (state.occlusionQuerySet === null) {
|
|
555
|
+
return err(
|
|
556
|
+
new RhiErrorClass({
|
|
557
|
+
code: 'webgpu-runtime-error',
|
|
558
|
+
expected: 'GPURenderPassDescriptor.occlusionQuerySet must be set',
|
|
559
|
+
hint: 'pass occlusionQuerySet in RenderPassDescriptor before beginOcclusionQuery',
|
|
560
|
+
}),
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
// Step 4 (research §2.1): [[occlusion_query_active]] == false (no nesting).
|
|
564
|
+
// K-2: nested begin maps to webgpu-runtime-error (NOT a new code).
|
|
565
|
+
if (state.occlusionQueryActive) {
|
|
566
|
+
return err(
|
|
567
|
+
new RhiErrorClass({
|
|
568
|
+
code: 'webgpu-runtime-error',
|
|
569
|
+
expected:
|
|
570
|
+
'[[occlusion_query_active]] == false; pair beginOcclusionQuery / endOcclusionQuery',
|
|
571
|
+
hint: 'call endOcclusionQuery() before beginOcclusionQuery() again; occlusion queries cannot nest (spec §render-passes)',
|
|
572
|
+
}),
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
// Step 2 (research §2.1): queryIndex < querySet.count.
|
|
576
|
+
const rawQs = QUERY_SET_RAW_MAP.get(state.occlusionQuerySet);
|
|
577
|
+
const qsCount =
|
|
578
|
+
rawQs !== undefined && typeof rawQs.count === 'number'
|
|
579
|
+
? rawQs.count
|
|
580
|
+
: Number.MAX_SAFE_INTEGER;
|
|
581
|
+
if (queryIndex < 0 || queryIndex >= qsCount) {
|
|
582
|
+
return err(
|
|
583
|
+
new RhiErrorClass({
|
|
584
|
+
code: 'webgpu-runtime-error',
|
|
585
|
+
expected: 'queryIndex < querySet.count',
|
|
586
|
+
hint: `got queryIndex=${queryIndex}; querySet.count=${qsCount}`,
|
|
587
|
+
}),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
// Step 3 (research §2.1): queryIndex not yet written in this pass.
|
|
591
|
+
// Cross-pass reuse on the same querySet is legal (dawn `Rewrite` mode).
|
|
592
|
+
if (state.occlusionQueryWritten.has(queryIndex)) {
|
|
593
|
+
return err(
|
|
594
|
+
new RhiErrorClass({
|
|
595
|
+
code: 'webgpu-runtime-error',
|
|
596
|
+
expected: 'queryIndex must not have been written in this pass',
|
|
597
|
+
hint: `queryIndex=${queryIndex} was already written; cross-pass reuse on the same querySet is legal but in-pass reuse is not (spec §queries)`,
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
rawPass.beginOcclusionQuery(queryIndex);
|
|
603
|
+
state.occlusionQueryActive = true;
|
|
604
|
+
state.occlusionQueryWritten.add(queryIndex);
|
|
605
|
+
return ok(undefined);
|
|
606
|
+
} catch (e) {
|
|
607
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
608
|
+
return err(
|
|
609
|
+
new RhiErrorClass({
|
|
610
|
+
code: 'webgpu-runtime-error',
|
|
611
|
+
expected: 'underlying GPURenderPassEncoder.beginOcclusionQuery to succeed',
|
|
612
|
+
hint: `beginOcclusionQuery raised: ${message}`,
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
endOcclusionQuery(): Result<void, RhiError> {
|
|
618
|
+
const state = PASS_STATE.get(pass);
|
|
619
|
+
if (state === undefined) {
|
|
620
|
+
return err(
|
|
621
|
+
new RhiErrorClass({
|
|
622
|
+
code: 'webgpu-runtime-error',
|
|
623
|
+
expected: 'render pass state must exist',
|
|
624
|
+
hint: 'endOcclusionQuery called on an untracked render pass',
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
// Spec normative (research §2.1): [[occlusion_query_active]] must be true.
|
|
629
|
+
// K-2: end without active begin maps to render-pass-not-ended (existing
|
|
630
|
+
// 14-member union).
|
|
631
|
+
if (!state.occlusionQueryActive) {
|
|
632
|
+
return renderPassNotEnded();
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
rawPass.endOcclusionQuery();
|
|
636
|
+
state.occlusionQueryActive = false;
|
|
637
|
+
return ok(undefined);
|
|
638
|
+
} catch (e) {
|
|
639
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
640
|
+
return err(
|
|
641
|
+
new RhiErrorClass({
|
|
642
|
+
code: 'webgpu-runtime-error',
|
|
643
|
+
expected: 'underlying GPURenderPassEncoder.endOcclusionQuery to succeed',
|
|
644
|
+
hint: `endOcclusionQuery raised: ${message}`,
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
end(): void {
|
|
650
|
+
const state = PASS_STATE.get(pass);
|
|
651
|
+
if (state !== undefined) {
|
|
652
|
+
state.ended = true;
|
|
653
|
+
}
|
|
654
|
+
rawPass.end();
|
|
655
|
+
// Clear active-pass tracking on the owning encoder.
|
|
656
|
+
const encState = ENCODER_STATE.get(encoder);
|
|
657
|
+
if (encState !== undefined && encState.activePass === pass) {
|
|
658
|
+
encState.activePass = null;
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
PASS_STATE.set(pass, {
|
|
663
|
+
raw: rawPass,
|
|
664
|
+
ended: false,
|
|
665
|
+
encoder,
|
|
666
|
+
occlusionQuerySet,
|
|
667
|
+
occlusionQueryActive: false,
|
|
668
|
+
occlusionQueryWritten: new Set<number>(),
|
|
669
|
+
});
|
|
670
|
+
return pass;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const ENCODER_FINISHED_ERROR_ARGS = {
|
|
674
|
+
code: 'command-encoder-finished' as const,
|
|
675
|
+
expected: 'command encoder must not be finished before recording new commands',
|
|
676
|
+
hint: 'create a new command encoder via device.createCommandEncoder() for each frame; do not reuse a finished encoder',
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
function throwIfFinished(state: EncoderState | undefined): void {
|
|
680
|
+
if (state?.finished) {
|
|
681
|
+
throw new RhiErrorClass(ENCODER_FINISHED_ERROR_ARGS);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function rawTextureView(view: TextureView): GPUTextureView {
|
|
686
|
+
return TEXTURE_VIEW_RAW_MAP.get(view) ?? (view as unknown as GPUTextureView);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function mirrorRenderPassDescriptor(desc: RenderPassDescriptor): GPURenderPassDescriptor {
|
|
690
|
+
const colorAttachments: Array<GPURenderPassColorAttachment | null> = [];
|
|
691
|
+
for (const attachment of desc.colorAttachments) {
|
|
692
|
+
if (attachment === null || attachment === undefined) {
|
|
693
|
+
colorAttachments.push(null);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (attachment.loadOp === undefined || attachment.storeOp === undefined) {
|
|
697
|
+
throw new TypeError('RHI render-pass color attachments require loadOp and storeOp');
|
|
698
|
+
}
|
|
699
|
+
colorAttachments.push({
|
|
700
|
+
view: rawTextureView(attachment.view),
|
|
701
|
+
...(attachment.depthSlice === undefined ? {} : { depthSlice: attachment.depthSlice }),
|
|
702
|
+
...(attachment.resolveTarget === undefined
|
|
703
|
+
? {}
|
|
704
|
+
: { resolveTarget: rawTextureView(attachment.resolveTarget) }),
|
|
705
|
+
...(attachment.clearValue === undefined ? {} : { clearValue: attachment.clearValue }),
|
|
706
|
+
loadOp: attachment.loadOp,
|
|
707
|
+
storeOp: attachment.storeOp,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
...(desc.label === undefined ? {} : { label: desc.label }),
|
|
713
|
+
colorAttachments,
|
|
714
|
+
...(desc.depthStencilAttachment === undefined
|
|
715
|
+
? {}
|
|
716
|
+
: {
|
|
717
|
+
depthStencilAttachment: {
|
|
718
|
+
view: rawTextureView(desc.depthStencilAttachment.view),
|
|
719
|
+
...(desc.depthStencilAttachment.depthClearValue === undefined
|
|
720
|
+
? {}
|
|
721
|
+
: { depthClearValue: desc.depthStencilAttachment.depthClearValue }),
|
|
722
|
+
...(desc.depthStencilAttachment.depthLoadOp === undefined
|
|
723
|
+
? {}
|
|
724
|
+
: { depthLoadOp: desc.depthStencilAttachment.depthLoadOp }),
|
|
725
|
+
...(desc.depthStencilAttachment.depthStoreOp === undefined
|
|
726
|
+
? {}
|
|
727
|
+
: { depthStoreOp: desc.depthStencilAttachment.depthStoreOp }),
|
|
728
|
+
...(desc.depthStencilAttachment.depthReadOnly === undefined
|
|
729
|
+
? {}
|
|
730
|
+
: { depthReadOnly: desc.depthStencilAttachment.depthReadOnly }),
|
|
731
|
+
...(desc.depthStencilAttachment.stencilClearValue === undefined
|
|
732
|
+
? {}
|
|
733
|
+
: { stencilClearValue: desc.depthStencilAttachment.stencilClearValue }),
|
|
734
|
+
...(desc.depthStencilAttachment.stencilLoadOp === undefined
|
|
735
|
+
? {}
|
|
736
|
+
: { stencilLoadOp: desc.depthStencilAttachment.stencilLoadOp }),
|
|
737
|
+
...(desc.depthStencilAttachment.stencilStoreOp === undefined
|
|
738
|
+
? {}
|
|
739
|
+
: { stencilStoreOp: desc.depthStencilAttachment.stencilStoreOp }),
|
|
740
|
+
...(desc.depthStencilAttachment.stencilReadOnly === undefined
|
|
741
|
+
? {}
|
|
742
|
+
: { stencilReadOnly: desc.depthStencilAttachment.stencilReadOnly }),
|
|
743
|
+
},
|
|
744
|
+
}),
|
|
745
|
+
...(desc.occlusionQuerySet === undefined
|
|
746
|
+
? {}
|
|
747
|
+
: {
|
|
748
|
+
occlusionQuerySet:
|
|
749
|
+
QUERY_SET_RAW_MAP.get(desc.occlusionQuerySet) ??
|
|
750
|
+
(desc.occlusionQuerySet as unknown as GPUQuerySet),
|
|
751
|
+
}),
|
|
752
|
+
...(desc.timestampWrites === undefined
|
|
753
|
+
? {}
|
|
754
|
+
: {
|
|
755
|
+
timestampWrites: {
|
|
756
|
+
querySet:
|
|
757
|
+
QUERY_SET_RAW_MAP.get(desc.timestampWrites.querySet) ??
|
|
758
|
+
(desc.timestampWrites.querySet as unknown as GPUQuerySet),
|
|
759
|
+
...(desc.timestampWrites.beginningOfPassWriteIndex === undefined
|
|
760
|
+
? {}
|
|
761
|
+
: { beginningOfPassWriteIndex: desc.timestampWrites.beginningOfPassWriteIndex }),
|
|
762
|
+
...(desc.timestampWrites.endOfPassWriteIndex === undefined
|
|
763
|
+
? {}
|
|
764
|
+
: { endOfPassWriteIndex: desc.timestampWrites.endOfPassWriteIndex }),
|
|
765
|
+
},
|
|
766
|
+
}),
|
|
767
|
+
...(desc.maxDrawCount === undefined ? {} : { maxDrawCount: desc.maxDrawCount }),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function mirrorRenderPipelineDescriptor(
|
|
772
|
+
desc: RenderPipelineDescriptor,
|
|
773
|
+
): GPURenderPipelineDescriptor {
|
|
774
|
+
const vertex: GPUVertexState = {
|
|
775
|
+
module: desc.vertex.module as unknown as GPUShaderModule,
|
|
776
|
+
...(desc.vertex.buffers === undefined ? {} : { buffers: Array.from(desc.vertex.buffers) }),
|
|
777
|
+
...(desc.vertex.entryPoint === undefined ? {} : { entryPoint: desc.vertex.entryPoint }),
|
|
778
|
+
...(desc.vertex.constants === undefined ? {} : { constants: desc.vertex.constants }),
|
|
779
|
+
};
|
|
780
|
+
const fragment: GPUFragmentState | undefined =
|
|
781
|
+
desc.fragment === undefined
|
|
782
|
+
? undefined
|
|
783
|
+
: {
|
|
784
|
+
module: desc.fragment.module as unknown as GPUShaderModule,
|
|
785
|
+
targets: Array.from(desc.fragment.targets),
|
|
786
|
+
...(desc.fragment.entryPoint === undefined
|
|
787
|
+
? {}
|
|
788
|
+
: { entryPoint: desc.fragment.entryPoint }),
|
|
789
|
+
...(desc.fragment.constants === undefined ? {} : { constants: desc.fragment.constants }),
|
|
790
|
+
};
|
|
791
|
+
return {
|
|
792
|
+
...(desc.label === undefined ? {} : { label: desc.label }),
|
|
793
|
+
layout: desc.layout === 'auto' ? 'auto' : (desc.layout as unknown as GPUPipelineLayout),
|
|
794
|
+
vertex,
|
|
795
|
+
...(desc.primitive === undefined ? {} : { primitive: desc.primitive }),
|
|
796
|
+
...(desc.depthStencil === undefined ? {} : { depthStencil: desc.depthStencil }),
|
|
797
|
+
...(desc.multisample === undefined ? {} : { multisample: desc.multisample }),
|
|
798
|
+
...(fragment === undefined ? {} : { fragment }),
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Build a RhiCommandEncoder around a raw GPUCommandEncoder (w3).
|
|
804
|
+
*
|
|
805
|
+
* Lifecycle:
|
|
806
|
+
* - finish() flips finished=true and returns CommandBuffer once.
|
|
807
|
+
* - subsequent recording calls (beginRenderPass / copyXxx / clearBuffer /
|
|
808
|
+
* finish itself) return Result.err({ code: 'command-encoder-finished' })
|
|
809
|
+
* for those methods that return Result; the void-returning methods throw
|
|
810
|
+
* the structured error so AI users observe the failure (charter
|
|
811
|
+
* proposition 4 explicit failure).
|
|
812
|
+
* - render-pass-not-ended is detected by tracking activePass; finish()
|
|
813
|
+
* while a pass has not been end()-ed returns the structured error.
|
|
814
|
+
*/
|
|
815
|
+
function makeCommandEncoder(
|
|
816
|
+
rawEncoder: GPUCommandEncoder,
|
|
817
|
+
caps: { readonly timestampQuery: boolean },
|
|
818
|
+
fireFeatureNotEnabled: (featureName: string, hint: string) => void,
|
|
819
|
+
): RhiCommandEncoder {
|
|
820
|
+
function mirrorComputePassDescriptor(
|
|
821
|
+
desc: ComputePassDescriptor | undefined,
|
|
822
|
+
): GPUComputePassDescriptor | undefined {
|
|
823
|
+
if (desc === undefined) return undefined;
|
|
824
|
+
const out = mirror(desc, ['label']);
|
|
825
|
+
if ('timestampWrites' in desc) {
|
|
826
|
+
const writes = desc.timestampWrites;
|
|
827
|
+
out.timestampWrites =
|
|
828
|
+
writes === undefined
|
|
829
|
+
? undefined
|
|
830
|
+
: {
|
|
831
|
+
querySet:
|
|
832
|
+
QUERY_SET_RAW_MAP.get(writes.querySet) ??
|
|
833
|
+
(writes.querySet as unknown as GPUQuerySet),
|
|
834
|
+
...(writes.beginningOfPassWriteIndex === undefined
|
|
835
|
+
? {}
|
|
836
|
+
: { beginningOfPassWriteIndex: writes.beginningOfPassWriteIndex }),
|
|
837
|
+
...(writes.endOfPassWriteIndex === undefined
|
|
838
|
+
? {}
|
|
839
|
+
: { endOfPassWriteIndex: writes.endOfPassWriteIndex }),
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
return out as unknown as GPUComputePassDescriptor;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const enc: RhiCommandEncoder = {
|
|
846
|
+
beginRenderPass(desc: RenderPassDescriptor): RhiRenderPassEncoder {
|
|
847
|
+
const state = ENCODER_STATE.get(enc);
|
|
848
|
+
throwIfFinished(state);
|
|
849
|
+
const rawPass = rawEncoder.beginRenderPass(mirrorRenderPassDescriptor(desc));
|
|
850
|
+
// Extract occlusionQuerySet from the descriptor (research §2.2:
|
|
851
|
+
// RPDesc.occlusionQuerySet is the injection point; the render pass
|
|
852
|
+
// PassState.[[occlusion_query_set]] mirrors it). The forgeax
|
|
853
|
+
// RenderPassDescriptor declares occlusionQuerySet as `QuerySet |
|
|
854
|
+
// undefined`; the raw GPU descriptor is structurally compatible.
|
|
855
|
+
const occlusionQuerySet = desc.occlusionQuerySet ?? null;
|
|
856
|
+
const pass = makeRenderPassEncoder(rawPass, enc, occlusionQuerySet);
|
|
857
|
+
if (state !== undefined) state.activePass = pass;
|
|
858
|
+
return pass;
|
|
859
|
+
},
|
|
860
|
+
beginComputePass(desc?: ComputePassDescriptor | undefined): RhiComputePassEncoder {
|
|
861
|
+
const state = ENCODER_STATE.get(enc);
|
|
862
|
+
throwIfFinished(state);
|
|
863
|
+
const rawDescriptor = mirrorComputePassDescriptor(desc);
|
|
864
|
+
const rawPass =
|
|
865
|
+
rawDescriptor === undefined
|
|
866
|
+
? rawEncoder.beginComputePass()
|
|
867
|
+
: rawEncoder.beginComputePass(rawDescriptor);
|
|
868
|
+
const pass: RhiComputePassEncoder = {
|
|
869
|
+
setPipeline(pipeline) {
|
|
870
|
+
rawPass.setPipeline(pipeline as unknown as GPUComputePipeline);
|
|
871
|
+
},
|
|
872
|
+
setBindGroup(index, bindGroup, dynamicOffsets) {
|
|
873
|
+
if (dynamicOffsets === undefined) {
|
|
874
|
+
rawPass.setBindGroup(index, bindGroup as unknown as GPUBindGroup);
|
|
875
|
+
} else {
|
|
876
|
+
rawPass.setBindGroup(index, bindGroup as unknown as GPUBindGroup, dynamicOffsets);
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
dispatchWorkgroups(x, y, z) {
|
|
880
|
+
rawPass.dispatchWorkgroups(x, y, z);
|
|
881
|
+
},
|
|
882
|
+
dispatchWorkgroupsIndirect(indirectBuffer, indirectOffset) {
|
|
883
|
+
const rawBuffer =
|
|
884
|
+
BUFFER_RAW_MAP.get(indirectBuffer) ?? (indirectBuffer as unknown as GPUBuffer);
|
|
885
|
+
rawPass.dispatchWorkgroupsIndirect(rawBuffer, indirectOffset);
|
|
886
|
+
},
|
|
887
|
+
end() {
|
|
888
|
+
rawPass.end();
|
|
889
|
+
},
|
|
890
|
+
};
|
|
891
|
+
return pass;
|
|
892
|
+
},
|
|
893
|
+
copyBufferToBuffer(
|
|
894
|
+
source: Buffer,
|
|
895
|
+
arg2: number | Buffer,
|
|
896
|
+
arg3?: Buffer | number | undefined,
|
|
897
|
+
arg4?: number | undefined,
|
|
898
|
+
arg5?: number | undefined,
|
|
899
|
+
): void {
|
|
900
|
+
const state = ENCODER_STATE.get(enc);
|
|
901
|
+
throwIfFinished(state);
|
|
902
|
+
// M5 / w35: the forgeax Buffer is a wrapper (not the raw GPUBuffer);
|
|
903
|
+
// resolve through BUFFER_RAW_MAP before delegating.
|
|
904
|
+
const rawSource = BUFFER_RAW_MAP.get(source) ?? (source as unknown as GPUBuffer);
|
|
905
|
+
// Two overloads (research F-1):
|
|
906
|
+
// (a) (src, dst, size?) - 3-arg shorthand
|
|
907
|
+
// (b) (src, srcOffset, dst, dstOffset, size) - 5-arg full form
|
|
908
|
+
if (typeof arg2 === 'number') {
|
|
909
|
+
// 5-arg form
|
|
910
|
+
const dst = arg3 as Buffer;
|
|
911
|
+
const rawDst = BUFFER_RAW_MAP.get(dst) ?? (dst as unknown as GPUBuffer);
|
|
912
|
+
rawEncoder.copyBufferToBuffer(rawSource, arg2, rawDst, arg4 ?? 0, arg5 ?? 0);
|
|
913
|
+
} else {
|
|
914
|
+
// 3-arg shorthand
|
|
915
|
+
const dst = arg2 as Buffer;
|
|
916
|
+
const rawDst = BUFFER_RAW_MAP.get(dst) ?? (dst as unknown as GPUBuffer);
|
|
917
|
+
rawEncoder.copyBufferToBuffer(rawSource, rawDst, arg3 as number | undefined);
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
copyBufferToTexture(
|
|
921
|
+
source: GPUTexelCopyBufferInfo,
|
|
922
|
+
destination: GPUTexelCopyTextureInfo,
|
|
923
|
+
copySize: GPUExtent3DStrict,
|
|
924
|
+
): void {
|
|
925
|
+
const state = ENCODER_STATE.get(enc);
|
|
926
|
+
throwIfFinished(state);
|
|
927
|
+
const rawSrc = {
|
|
928
|
+
...source,
|
|
929
|
+
buffer:
|
|
930
|
+
BUFFER_RAW_MAP.get(source.buffer as unknown as Buffer) ??
|
|
931
|
+
(source.buffer as unknown as GPUBuffer),
|
|
932
|
+
};
|
|
933
|
+
rawEncoder.copyBufferToTexture(rawSrc, destination, copySize);
|
|
934
|
+
},
|
|
935
|
+
copyTextureToBuffer(
|
|
936
|
+
source: GPUTexelCopyTextureInfo,
|
|
937
|
+
destination: GPUTexelCopyBufferInfo,
|
|
938
|
+
copySize: GPUExtent3DStrict,
|
|
939
|
+
): void {
|
|
940
|
+
const state = ENCODER_STATE.get(enc);
|
|
941
|
+
throwIfFinished(state);
|
|
942
|
+
const rawDst = {
|
|
943
|
+
...destination,
|
|
944
|
+
buffer:
|
|
945
|
+
BUFFER_RAW_MAP.get(destination.buffer as unknown as Buffer) ??
|
|
946
|
+
(destination.buffer as unknown as GPUBuffer),
|
|
947
|
+
};
|
|
948
|
+
rawEncoder.copyTextureToBuffer(source, rawDst, copySize);
|
|
949
|
+
},
|
|
950
|
+
copyTextureToTexture(
|
|
951
|
+
source: GPUTexelCopyTextureInfo,
|
|
952
|
+
destination: GPUTexelCopyTextureInfo,
|
|
953
|
+
copySize: GPUExtent3DStrict,
|
|
954
|
+
): void {
|
|
955
|
+
const state = ENCODER_STATE.get(enc);
|
|
956
|
+
throwIfFinished(state);
|
|
957
|
+
rawEncoder.copyTextureToTexture(source, destination, copySize);
|
|
958
|
+
},
|
|
959
|
+
clearBuffer(buffer: Buffer, offset?: number | undefined, size?: number | undefined): void {
|
|
960
|
+
const state = ENCODER_STATE.get(enc);
|
|
961
|
+
throwIfFinished(state);
|
|
962
|
+
// M5 / w35: resolve forgeax Buffer wrapper to raw GPUBuffer.
|
|
963
|
+
const rawBuf = BUFFER_RAW_MAP.get(buffer) ?? (buffer as unknown as GPUBuffer);
|
|
964
|
+
rawEncoder.clearBuffer(rawBuf, offset, size);
|
|
965
|
+
},
|
|
966
|
+
resolveQuerySet(
|
|
967
|
+
querySet: QuerySet,
|
|
968
|
+
firstQuery: number,
|
|
969
|
+
queryCount: number,
|
|
970
|
+
destination: Buffer,
|
|
971
|
+
destinationOffset: number,
|
|
972
|
+
): Result<void, RhiError> {
|
|
973
|
+
const state = ENCODER_STATE.get(enc);
|
|
974
|
+
if (state?.finished) {
|
|
975
|
+
return commandEncoderFinished();
|
|
976
|
+
}
|
|
977
|
+
// Step 6 (research §2.3): destinationOffset is a multiple of 256.
|
|
978
|
+
// K-2: alignment violation maps to webgpu-runtime-error.
|
|
979
|
+
if (destinationOffset % QUERY_RESOLVE_ALIGNMENT !== 0) {
|
|
980
|
+
return err(
|
|
981
|
+
new RhiErrorClass({
|
|
982
|
+
code: 'webgpu-runtime-error',
|
|
983
|
+
expected: 'destinationOffset % 256 == 0 (spec normative)',
|
|
984
|
+
hint: `got destinationOffset=${destinationOffset}; align to a multiple of 256 bytes (kQueryResolveAlignment)`,
|
|
985
|
+
}),
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
// Step 3 (research §2.3): destination.usage contains QUERY_RESOLVE.
|
|
989
|
+
const dstMeta = BUFFER_META_MAP.get(destination);
|
|
990
|
+
if (dstMeta !== undefined && (dstMeta.usage & BUFFER_USAGE_QUERY_RESOLVE) === 0) {
|
|
991
|
+
return err(
|
|
992
|
+
new RhiErrorClass({
|
|
993
|
+
code: 'webgpu-runtime-error',
|
|
994
|
+
expected: 'destination.usage must contain QUERY_RESOLVE',
|
|
995
|
+
hint: `got destination.usage=0x${dstMeta.usage.toString(16)}; create the buffer with GPUBufferUsage.QUERY_RESOLVE (0x200)`,
|
|
996
|
+
}),
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
// Steps 4 + 5 (research §2.3): firstQuery + queryCount <= querySet.count
|
|
1000
|
+
// (which subsumes firstQuery < count as a derived constraint).
|
|
1001
|
+
const rawQs = QUERY_SET_RAW_MAP.get(querySet);
|
|
1002
|
+
const qsCount =
|
|
1003
|
+
rawQs !== undefined && typeof rawQs.count === 'number'
|
|
1004
|
+
? rawQs.count
|
|
1005
|
+
: Number.MAX_SAFE_INTEGER;
|
|
1006
|
+
if (firstQuery < 0 || firstQuery + queryCount > qsCount) {
|
|
1007
|
+
return err(
|
|
1008
|
+
new RhiErrorClass({
|
|
1009
|
+
code: 'webgpu-runtime-error',
|
|
1010
|
+
expected: 'firstQuery + queryCount <= querySet.count',
|
|
1011
|
+
hint: `got firstQuery=${firstQuery}, queryCount=${queryCount}; querySet.count=${qsCount}`,
|
|
1012
|
+
}),
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
// Step 7 (research §2.3): destinationOffset + 8 * queryCount <= dst.size.
|
|
1016
|
+
if (dstMeta !== undefined) {
|
|
1017
|
+
const requiredBytes = destinationOffset + 8 * queryCount;
|
|
1018
|
+
if (requiredBytes > dstMeta.size) {
|
|
1019
|
+
return err(
|
|
1020
|
+
new RhiErrorClass({
|
|
1021
|
+
code: 'webgpu-runtime-error',
|
|
1022
|
+
expected: 'destinationOffset + 8 * queryCount <= destination.size',
|
|
1023
|
+
hint: `got destinationOffset=${destinationOffset}, queryCount=${queryCount} (8 * queryCount = ${8 * queryCount}); destination.size=${dstMeta.size}`,
|
|
1024
|
+
}),
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
const rawQsHandle = QUERY_SET_RAW_MAP.get(querySet) ?? (querySet as unknown as GPUQuerySet);
|
|
1029
|
+
const rawDstHandle = BUFFER_RAW_MAP.get(destination) ?? (destination as unknown as GPUBuffer);
|
|
1030
|
+
return resolveTimestampQueries({
|
|
1031
|
+
rawEncoder,
|
|
1032
|
+
rawQuerySet: rawQsHandle,
|
|
1033
|
+
firstQuery,
|
|
1034
|
+
queryCount,
|
|
1035
|
+
rawDestination: rawDstHandle,
|
|
1036
|
+
destinationOffset,
|
|
1037
|
+
});
|
|
1038
|
+
},
|
|
1039
|
+
pushDebugGroup(groupLabel: string): void {
|
|
1040
|
+
rawEncoder.pushDebugGroup(groupLabel);
|
|
1041
|
+
},
|
|
1042
|
+
popDebugGroup(): void {
|
|
1043
|
+
rawEncoder.popDebugGroup();
|
|
1044
|
+
},
|
|
1045
|
+
insertDebugMarker(markerLabel: string): void {
|
|
1046
|
+
rawEncoder.insertDebugMarker(markerLabel);
|
|
1047
|
+
},
|
|
1048
|
+
writeTimestamp(querySet: QuerySet, queryIndex: number): void {
|
|
1049
|
+
// M5 / K-3 (research §2.4): timestamp-query feature gate. spec
|
|
1050
|
+
// writeTimestamp returns void; the forgeax form keeps the void shape
|
|
1051
|
+
// and fans out 'feature-not-enabled' through the engine onError
|
|
1052
|
+
// channel rather than wrapping in Result. When the capability is true,
|
|
1053
|
+
// a missing or throwing raw write is a structured runtime failure so a
|
|
1054
|
+
// Render capture cannot publish a fabricated interval.
|
|
1055
|
+
const state = ENCODER_STATE.get(enc);
|
|
1056
|
+
throwIfFinished(state);
|
|
1057
|
+
if (caps.timestampQuery !== true) {
|
|
1058
|
+
fireFeatureNotEnabled(
|
|
1059
|
+
'timestamp-query',
|
|
1060
|
+
'check device.caps.timestampQuery before calling writeTimestamp',
|
|
1061
|
+
);
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const rawQs = QUERY_SET_RAW_MAP.get(querySet) ?? (querySet as unknown as GPUQuerySet);
|
|
1065
|
+
writeTimestamp({ rawEncoder, rawQuerySet: rawQs, queryIndex });
|
|
1066
|
+
},
|
|
1067
|
+
finish(): Result<CommandBuffer, RhiError> {
|
|
1068
|
+
const state = ENCODER_STATE.get(enc);
|
|
1069
|
+
if (state === undefined) {
|
|
1070
|
+
// Should not happen in practice; treat as a finished encoder.
|
|
1071
|
+
return commandEncoderFinished();
|
|
1072
|
+
}
|
|
1073
|
+
if (state.finished) {
|
|
1074
|
+
return commandEncoderFinished();
|
|
1075
|
+
}
|
|
1076
|
+
if (state.activePass !== null) {
|
|
1077
|
+
const passState = PASS_STATE.get(state.activePass);
|
|
1078
|
+
if (passState !== undefined && !passState.ended) {
|
|
1079
|
+
return renderPassNotEnded();
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
const rawCommandBuffer = rawEncoder.finish();
|
|
1083
|
+
state.finished = true;
|
|
1084
|
+
const cb = rawCommandBuffer as unknown as CommandBuffer;
|
|
1085
|
+
COMMAND_BUFFER_RAW_MAP.set(cb, rawCommandBuffer);
|
|
1086
|
+
return ok(cb);
|
|
1087
|
+
},
|
|
1088
|
+
};
|
|
1089
|
+
ENCODER_STATE.set(enc, { raw: rawEncoder, finished: false, activePass: null });
|
|
1090
|
+
return enc;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ============================================================================
|
|
1094
|
+
// M5 / w35 - Buffer wrapper (mapAsync / getMappedRange / unmap / mapState).
|
|
1095
|
+
// ============================================================================
|
|
1096
|
+
//
|
|
1097
|
+
// research §4.1 mapState 3-state enum + §4.2 mapAsync 8-validation +
|
|
1098
|
+
// §4.4 unmap detach. K-1 decision: mode is the raw GPUMapMode bitmask.
|
|
1099
|
+
// K-2 decision: alignment / mode-usage / detach faults all ride
|
|
1100
|
+
// 'webgpu-runtime-error' with structured .expected / .hint per F-3
|
|
1101
|
+
// ai-user-review carry-over.
|
|
1102
|
+
|
|
1103
|
+
/** Spec normative GPUMapMode bits (research §4.2 step 7). */
|
|
1104
|
+
const MAP_MODE_READ = 0x1;
|
|
1105
|
+
const MAP_MODE_WRITE = 0x2;
|
|
1106
|
+
/** GPUBufferUsage MAP_READ / MAP_WRITE bits (research §4.2 step 9 / F-8 row 3). */
|
|
1107
|
+
const BUFFER_USAGE_MAP_READ = 0x0001;
|
|
1108
|
+
const BUFFER_USAGE_MAP_WRITE = 0x0002;
|
|
1109
|
+
|
|
1110
|
+
function rangeError(args: { expected: string; hint: string }): Result<never, RhiError> {
|
|
1111
|
+
return err(
|
|
1112
|
+
new RhiErrorClass({
|
|
1113
|
+
code: 'webgpu-runtime-error',
|
|
1114
|
+
expected: args.expected,
|
|
1115
|
+
hint: args.hint,
|
|
1116
|
+
}),
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Build a forgeax Buffer wrapper around a raw GPUBuffer (w35).
|
|
1122
|
+
*
|
|
1123
|
+
* The wrapper exposes the brand-only Buffer interface plus the M5 mapping
|
|
1124
|
+
* surface (mapAsync / getMappedRange / unmap + mapState getter).
|
|
1125
|
+
*
|
|
1126
|
+
* Validation policy (K-2 + research §4.2 + F-8 three rows):
|
|
1127
|
+
* - mapAsync rejects on 8 spec validation steps + F-8 row 1/3 with
|
|
1128
|
+
* 'webgpu-runtime-error' before delegating to raw mapAsync. The raw
|
|
1129
|
+
* mapAsync may still reject (driver-level validation); the shim wraps
|
|
1130
|
+
* such rejections via 'webgpu-runtime-error' carrying the underlying
|
|
1131
|
+
* message in .hint.
|
|
1132
|
+
* - getMappedRange rejects with 'webgpu-runtime-error' when mapState !==
|
|
1133
|
+
* 'mapped' (F-8 row 2 detach guard) before delegating.
|
|
1134
|
+
* - unmap is silent (spec normative; research §4.4) and resets the
|
|
1135
|
+
* internal mapState slot to 'unmapped'.
|
|
1136
|
+
*
|
|
1137
|
+
* The wrapper carries an internal mapState slot so the shim can detect F-8
|
|
1138
|
+
* row 1 / row 2 fast-path; the slot is initialized to 'mapped' when the
|
|
1139
|
+
* descriptor sets `mappedAtCreation:true` (research §4.3) and otherwise
|
|
1140
|
+
* starts at 'unmapped'.
|
|
1141
|
+
*/
|
|
1142
|
+
function makeBufferWrapper(raw: GPUBuffer, size: number, usage: GPUBufferUsageFlags): Buffer {
|
|
1143
|
+
// Track mapState locally so the shim does not need to query raw.mapState
|
|
1144
|
+
// (the spec exposes it but for symmetry with the F-8 guard semantics we
|
|
1145
|
+
// mirror the slot in the wrapper).
|
|
1146
|
+
const initialState =
|
|
1147
|
+
typeof (raw as { mapState?: string }).mapState === 'string'
|
|
1148
|
+
? (raw as { mapState: 'unmapped' | 'pending' | 'mapped' }).mapState
|
|
1149
|
+
: 'unmapped';
|
|
1150
|
+
let mapState: 'unmapped' | 'pending' | 'mapped' = initialState;
|
|
1151
|
+
const wrapper = {
|
|
1152
|
+
get mapState(): 'unmapped' | 'pending' | 'mapped' {
|
|
1153
|
+
// Prefer the raw slot if available so destroyed-buffer transitions
|
|
1154
|
+
// surface to AI users; fall back to the local slot for mocks that do
|
|
1155
|
+
// not expose mapState (mock buffers keep a logical mapState only).
|
|
1156
|
+
const rs = (raw as { mapState?: 'unmapped' | 'pending' | 'mapped' }).mapState;
|
|
1157
|
+
if (typeof rs === 'string') {
|
|
1158
|
+
mapState = rs;
|
|
1159
|
+
return rs;
|
|
1160
|
+
}
|
|
1161
|
+
return mapState;
|
|
1162
|
+
},
|
|
1163
|
+
async mapAsync(
|
|
1164
|
+
mode: GPUMapModeFlags,
|
|
1165
|
+
offset?: number | undefined,
|
|
1166
|
+
sizeArg?: number | undefined,
|
|
1167
|
+
): Promise<Result<MappedBuffer, RhiError>> {
|
|
1168
|
+
// F-8 row 1: mapState must be 'unmapped' (research §4.2 step 1).
|
|
1169
|
+
const cur = wrapper.mapState;
|
|
1170
|
+
if (cur !== 'unmapped') {
|
|
1171
|
+
return rangeError({
|
|
1172
|
+
expected: 'buffer.mapState === "unmapped" before mapAsync',
|
|
1173
|
+
hint: `got mapState=${cur}; call buffer.unmap() before mapAsync, or wait for the previous mapAsync to settle`,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
const off = offset ?? 0;
|
|
1177
|
+
const rangeSize = sizeArg === undefined ? Math.max(0, size - off) : sizeArg;
|
|
1178
|
+
// step 4: offset % 8 == 0
|
|
1179
|
+
if (off % 8 !== 0) {
|
|
1180
|
+
return rangeError({
|
|
1181
|
+
expected: 'mapAsync offset % 8 == 0 (spec normative)',
|
|
1182
|
+
hint: `got offset=${off}; align offset to 8 bytes`,
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
// step 5: rangeSize % 4 == 0
|
|
1186
|
+
if (rangeSize % 4 !== 0) {
|
|
1187
|
+
return rangeError({
|
|
1188
|
+
expected: 'mapAsync rangeSize % 4 == 0 (spec normative)',
|
|
1189
|
+
hint: `got rangeSize=${rangeSize}; align rangeSize to 4 bytes`,
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
// step 6: offset + rangeSize <= size
|
|
1193
|
+
if (off + rangeSize > size) {
|
|
1194
|
+
return rangeError({
|
|
1195
|
+
expected: 'mapAsync offset + rangeSize <= buffer.size',
|
|
1196
|
+
hint: `got offset=${off}, rangeSize=${rangeSize}; buffer.size=${size}`,
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
// step 7: mode contains only allowed bits
|
|
1200
|
+
const allowed = MAP_MODE_READ | MAP_MODE_WRITE;
|
|
1201
|
+
if ((mode & ~allowed) !== 0) {
|
|
1202
|
+
return rangeError({
|
|
1203
|
+
expected: 'mapAsync mode contains only READ or WRITE bits',
|
|
1204
|
+
hint: `got mode=0x${mode.toString(16)}; pass GPUMapMode.READ (0x1) or GPUMapMode.WRITE (0x2)`,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
// step 8: mode is exactly READ or WRITE (not both)
|
|
1208
|
+
if (mode !== MAP_MODE_READ && mode !== MAP_MODE_WRITE) {
|
|
1209
|
+
return rangeError({
|
|
1210
|
+
expected: 'mapAsync mode is exactly one of READ | WRITE (not both)',
|
|
1211
|
+
hint: `got mode=0x${mode.toString(16)}; pass GPUMapMode.READ (0x1) or GPUMapMode.WRITE (0x2), not the OR-combined mask`,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
// step 9: mode-usage cross-check (F-8 row 3)
|
|
1215
|
+
if ((mode & MAP_MODE_READ) !== 0 && (usage & BUFFER_USAGE_MAP_READ) === 0) {
|
|
1216
|
+
return rangeError({
|
|
1217
|
+
expected: 'mapAsync mode READ requires buffer.usage to contain MAP_READ',
|
|
1218
|
+
hint: `got mode=READ, buffer.usage=0x${usage.toString(16)}; create buffer with GPUBufferUsage.MAP_READ`,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
if ((mode & MAP_MODE_WRITE) !== 0 && (usage & BUFFER_USAGE_MAP_WRITE) === 0) {
|
|
1222
|
+
return rangeError({
|
|
1223
|
+
expected: 'mapAsync mode WRITE requires buffer.usage to contain MAP_WRITE',
|
|
1224
|
+
hint: `got mode=WRITE, buffer.usage=0x${usage.toString(16)}; create buffer with GPUBufferUsage.MAP_WRITE`,
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
// Delegate to raw GPUBuffer; raw rejection wraps as webgpu-runtime-error.
|
|
1228
|
+
mapState = 'pending';
|
|
1229
|
+
try {
|
|
1230
|
+
if (typeof raw.mapAsync === 'function') {
|
|
1231
|
+
if (sizeArg === undefined && offset === undefined) {
|
|
1232
|
+
await raw.mapAsync(mode);
|
|
1233
|
+
} else if (sizeArg === undefined) {
|
|
1234
|
+
await raw.mapAsync(mode, off);
|
|
1235
|
+
} else {
|
|
1236
|
+
await raw.mapAsync(mode, off, sizeArg);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
mapState = 'mapped';
|
|
1240
|
+
// D-P2 #6: the structural wrapper carries both Buffer + MappedBuffer
|
|
1241
|
+
// members; on the success path we surface the same JS object typed as
|
|
1242
|
+
// the branded MappedBuffer (charter proposition 5: brand is structural,
|
|
1243
|
+
// no runtime cost).
|
|
1244
|
+
return ok(wrapper as unknown as MappedBuffer);
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
mapState = 'unmapped';
|
|
1247
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1248
|
+
return rangeError({
|
|
1249
|
+
expected: 'underlying GPUBuffer.mapAsync to succeed',
|
|
1250
|
+
hint: `mapAsync raised: ${message}`,
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
},
|
|
1254
|
+
getMappedRange(
|
|
1255
|
+
offset?: number | undefined,
|
|
1256
|
+
sizeArg?: number | undefined,
|
|
1257
|
+
): Result<ArrayBuffer, RhiError> {
|
|
1258
|
+
// F-8 row 2 detach guard: mapState must be 'mapped'.
|
|
1259
|
+
const cur = wrapper.mapState;
|
|
1260
|
+
if (cur !== 'mapped') {
|
|
1261
|
+
return rangeError({
|
|
1262
|
+
expected: 'buffer.mapState === "mapped" before getMappedRange',
|
|
1263
|
+
hint: 'call buffer.mapAsync(MODE) and await it before getMappedRange',
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
try {
|
|
1267
|
+
if (typeof raw.getMappedRange !== 'function') {
|
|
1268
|
+
return rangeError({
|
|
1269
|
+
expected: 'underlying GPUBuffer.getMappedRange to be available',
|
|
1270
|
+
hint: 'mock or driver does not expose getMappedRange; use a real GPUBuffer',
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
const view =
|
|
1274
|
+
sizeArg === undefined
|
|
1275
|
+
? offset === undefined
|
|
1276
|
+
? raw.getMappedRange()
|
|
1277
|
+
: raw.getMappedRange(offset)
|
|
1278
|
+
: raw.getMappedRange(offset ?? 0, sizeArg);
|
|
1279
|
+
return ok(view);
|
|
1280
|
+
} catch (e) {
|
|
1281
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1282
|
+
return rangeError({
|
|
1283
|
+
expected: 'underlying GPUBuffer.getMappedRange to succeed',
|
|
1284
|
+
hint: `getMappedRange raised: ${message}`,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
},
|
|
1288
|
+
unmap(): void {
|
|
1289
|
+
// Spec normative silent no-op (research §4.4): unmap on a destroyed or
|
|
1290
|
+
// already-unmapped buffer must NOT throw / return an error.
|
|
1291
|
+
try {
|
|
1292
|
+
if (typeof raw.unmap === 'function') {
|
|
1293
|
+
raw.unmap();
|
|
1294
|
+
}
|
|
1295
|
+
} catch {
|
|
1296
|
+
// swallow per spec; the forgeax form does not surface unmap failures.
|
|
1297
|
+
}
|
|
1298
|
+
mapState = 'unmapped';
|
|
1299
|
+
},
|
|
1300
|
+
} as unknown as Buffer;
|
|
1301
|
+
return wrapper;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Build a RhiQueue around a raw GPUQueue (w6).
|
|
1306
|
+
*
|
|
1307
|
+
* Real-path implementation:
|
|
1308
|
+
* - submit forwards to rawQueue.submit; failures (validation / destroyed
|
|
1309
|
+
* resource references) wrap to 'queue-submit-failed' (D-S3 #3).
|
|
1310
|
+
* - writeBuffer validates offset alignment + bounds before forwarding;
|
|
1311
|
+
* out-of-bounds writes return 'queue-write-buffer-out-of-bounds' (D-S3 #4).
|
|
1312
|
+
*/
|
|
1313
|
+
function makeQueue(rawQueue: GPUQueue): RhiQueue {
|
|
1314
|
+
return {
|
|
1315
|
+
writeBuffer(
|
|
1316
|
+
buffer: Buffer,
|
|
1317
|
+
bufferOffset: number,
|
|
1318
|
+
data: ArrayBufferView | ArrayBuffer,
|
|
1319
|
+
dataOffset?: number | undefined,
|
|
1320
|
+
size?: number | undefined,
|
|
1321
|
+
): Result<void, RhiError> {
|
|
1322
|
+
const rawBuffer = BUFFER_RAW_MAP.get(buffer) ?? (buffer as unknown as GPUBuffer);
|
|
1323
|
+
const bufferSize =
|
|
1324
|
+
typeof (rawBuffer as { size?: number }).size === 'number'
|
|
1325
|
+
? (rawBuffer as { size: number }).size
|
|
1326
|
+
: Number.MAX_SAFE_INTEGER;
|
|
1327
|
+
// 4-byte alignment validation (W3C WebGPU 23.2 writeBuffer).
|
|
1328
|
+
if (bufferOffset % 4 !== 0) {
|
|
1329
|
+
return queueWriteBufferOutOfBounds({
|
|
1330
|
+
offset: bufferOffset,
|
|
1331
|
+
byteLength:
|
|
1332
|
+
data instanceof ArrayBuffer ? data.byteLength : (data as ArrayBufferView).byteLength,
|
|
1333
|
+
bufferSize,
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
const dataByteLength =
|
|
1337
|
+
data instanceof ArrayBuffer ? data.byteLength : (data as ArrayBufferView).byteLength;
|
|
1338
|
+
const writeStart = dataOffset ?? 0;
|
|
1339
|
+
const writeSize = size ?? dataByteLength - writeStart;
|
|
1340
|
+
// Bounds validation: bufferOffset + writeSize <= bufferSize.
|
|
1341
|
+
if (bufferOffset + writeSize > bufferSize) {
|
|
1342
|
+
return queueWriteBufferOutOfBounds({
|
|
1343
|
+
offset: bufferOffset,
|
|
1344
|
+
byteLength: writeSize,
|
|
1345
|
+
bufferSize,
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
try {
|
|
1349
|
+
if (size !== undefined) {
|
|
1350
|
+
rawQueue.writeBuffer(
|
|
1351
|
+
rawBuffer,
|
|
1352
|
+
bufferOffset,
|
|
1353
|
+
data as GPUAllowSharedBufferSource,
|
|
1354
|
+
writeStart,
|
|
1355
|
+
size,
|
|
1356
|
+
);
|
|
1357
|
+
} else if (dataOffset !== undefined) {
|
|
1358
|
+
rawQueue.writeBuffer(
|
|
1359
|
+
rawBuffer,
|
|
1360
|
+
bufferOffset,
|
|
1361
|
+
data as GPUAllowSharedBufferSource,
|
|
1362
|
+
writeStart,
|
|
1363
|
+
);
|
|
1364
|
+
} else {
|
|
1365
|
+
rawQueue.writeBuffer(rawBuffer, bufferOffset, data as GPUAllowSharedBufferSource);
|
|
1366
|
+
}
|
|
1367
|
+
return ok(undefined);
|
|
1368
|
+
} catch (e) {
|
|
1369
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1370
|
+
// GPU validation may surface bounds errors; wrap as out-of-bounds for
|
|
1371
|
+
// AI-user routing parity (charter proposition 4 explicit failure).
|
|
1372
|
+
if (/out of (bounds|range)|exceed/i.test(message)) {
|
|
1373
|
+
return queueWriteBufferOutOfBounds({
|
|
1374
|
+
offset: bufferOffset,
|
|
1375
|
+
byteLength: writeSize,
|
|
1376
|
+
bufferSize,
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
return queueSubmitFailed(message);
|
|
1380
|
+
}
|
|
1381
|
+
},
|
|
1382
|
+
submit(commandBuffers: readonly CommandBuffer[]): Result<void, RhiError> {
|
|
1383
|
+
const rawList: GPUCommandBuffer[] = [];
|
|
1384
|
+
for (const cb of commandBuffers) {
|
|
1385
|
+
const raw = COMMAND_BUFFER_RAW_MAP.get(cb);
|
|
1386
|
+
if (raw !== undefined) {
|
|
1387
|
+
rawList.push(raw);
|
|
1388
|
+
} else {
|
|
1389
|
+
rawList.push(cb as unknown as GPUCommandBuffer);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
try {
|
|
1393
|
+
rawQueue.submit(rawList);
|
|
1394
|
+
return ok(undefined);
|
|
1395
|
+
} catch (e) {
|
|
1396
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1397
|
+
return queueSubmitFailed(message);
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
writeTexture(
|
|
1401
|
+
destination: TextureWriteDestination,
|
|
1402
|
+
data: ArrayBufferView | ArrayBuffer,
|
|
1403
|
+
dataLayout: GPUTexelCopyBufferLayout,
|
|
1404
|
+
size: GPUExtent3DStrict,
|
|
1405
|
+
): Result<void, RhiError> {
|
|
1406
|
+
// writeTexture calls validating texture buffer copy(..., aligned=false)
|
|
1407
|
+
// per WebGPU spec §19.2 GPUQueue.writeTexture, which explicitly notes
|
|
1408
|
+
// "unlike copyBufferToTexture, there is no alignment requirement on
|
|
1409
|
+
// either dataLayout.bytesPerRow or dataLayout.offset."
|
|
1410
|
+
// The 256-byte alignment lives in §11.2.2 validating GPUTexelCopyBufferInfo,
|
|
1411
|
+
// called only by copyBufferToTexture / copyTextureToBuffer where the source
|
|
1412
|
+
// is a GPUBuffer. The lower bound bytesPerRow >= widthInBlocks * blockSize
|
|
1413
|
+
// is enforced by dawn / WebGPU via §11.2.6 validating linear texture data
|
|
1414
|
+
// and surfaces as webgpu-runtime-error through the try/catch below.
|
|
1415
|
+
try {
|
|
1416
|
+
// The destination.texture is a forgeax Texture brand; the shim stored
|
|
1417
|
+
// the raw GPUTexture as the brand carrier in createTexture. Spec
|
|
1418
|
+
// structural compatibility lets us forward verbatim with a cast.
|
|
1419
|
+
// The data + size casts cover @webgpu/types polymorphism that the
|
|
1420
|
+
// forgeax form normalises to the strict spec subset.
|
|
1421
|
+
const rawDestination: GPUTexelCopyTextureInfo = {
|
|
1422
|
+
texture: destination.texture as unknown as GPUTexture,
|
|
1423
|
+
};
|
|
1424
|
+
if (destination.mipLevel !== undefined) rawDestination.mipLevel = destination.mipLevel;
|
|
1425
|
+
if (destination.origin !== undefined) rawDestination.origin = destination.origin;
|
|
1426
|
+
if (destination.aspect !== undefined) rawDestination.aspect = destination.aspect;
|
|
1427
|
+
rawQueue.writeTexture(
|
|
1428
|
+
rawDestination,
|
|
1429
|
+
data as unknown as GPUAllowSharedBufferSource,
|
|
1430
|
+
dataLayout,
|
|
1431
|
+
size as unknown as GPUExtent3D,
|
|
1432
|
+
);
|
|
1433
|
+
return ok(undefined);
|
|
1434
|
+
} catch (e) {
|
|
1435
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1436
|
+
return err(
|
|
1437
|
+
new RhiErrorClass({
|
|
1438
|
+
code: 'webgpu-runtime-error',
|
|
1439
|
+
expected: 'underlying GPUQueue.writeTexture to succeed',
|
|
1440
|
+
hint: `writeTexture raised: ${message}`,
|
|
1441
|
+
}),
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
},
|
|
1445
|
+
copyExternalImageToTexture(
|
|
1446
|
+
source: GPUCopyExternalImageSourceInfo,
|
|
1447
|
+
destination: ExternalImageTextureDestination,
|
|
1448
|
+
copySize: GPUExtent3DStrict,
|
|
1449
|
+
): Result<void, RhiError> {
|
|
1450
|
+
try {
|
|
1451
|
+
rawQueue.copyExternalImageToTexture(
|
|
1452
|
+
source,
|
|
1453
|
+
{
|
|
1454
|
+
texture: destination.texture as unknown as GPUTexture,
|
|
1455
|
+
...(destination.mipLevel === undefined ? {} : { mipLevel: destination.mipLevel }),
|
|
1456
|
+
...(destination.origin === undefined ? {} : { origin: destination.origin }),
|
|
1457
|
+
...(destination.aspect === undefined ? {} : { aspect: destination.aspect }),
|
|
1458
|
+
...(destination.colorSpace === undefined ? {} : { colorSpace: destination.colorSpace }),
|
|
1459
|
+
...(destination.premultipliedAlpha === undefined
|
|
1460
|
+
? {}
|
|
1461
|
+
: { premultipliedAlpha: destination.premultipliedAlpha }),
|
|
1462
|
+
},
|
|
1463
|
+
copySize,
|
|
1464
|
+
);
|
|
1465
|
+
return ok(undefined);
|
|
1466
|
+
} catch (e) {
|
|
1467
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1468
|
+
return err(
|
|
1469
|
+
new RhiErrorClass({
|
|
1470
|
+
code: 'webgpu-runtime-error',
|
|
1471
|
+
expected: 'underlying GPUQueue.copyExternalImageToTexture to succeed',
|
|
1472
|
+
hint: `copyExternalImageToTexture raised: ${message}`,
|
|
1473
|
+
}),
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
},
|
|
1477
|
+
// forgeax-async-whitelist: dom-native — spec `GPUQueue.onSubmittedWorkDone()` Promise passthrough
|
|
1478
|
+
onSubmittedWorkDone(): Promise<undefined> {
|
|
1479
|
+
// research §5.1 spec normative: no reject path. Forward the raw
|
|
1480
|
+
// Promise verbatim; the forgeax Promise<undefined> matches the spec
|
|
1481
|
+
// shape (no Result wrapping; device-lost flows through RhiDevice.lost).
|
|
1482
|
+
return rawQueue.onSubmittedWorkDone();
|
|
1483
|
+
},
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* Construct a RhiDevice shim wrapping GPUDevice.
|
|
1489
|
+
*
|
|
1490
|
+
* Does not cache or reclassify device.lost (single-source subscription +
|
|
1491
|
+
* dual-form fan-out is the engine layer's job; this package only exposes the
|
|
1492
|
+
* spec Promise). See plan-strategy 3 R2 mitigation.
|
|
1493
|
+
*/
|
|
1494
|
+
export function makeRhiDevice(rawDevice: GPUDevice): {
|
|
1495
|
+
device: RhiDevice;
|
|
1496
|
+
/** Exposed for createShaderModule and other shim entry points that need to
|
|
1497
|
+
* bypass the createX Result wrapper. */
|
|
1498
|
+
raw: GPUDevice;
|
|
1499
|
+
} {
|
|
1500
|
+
const caps = deriveCaps(rawDevice, rawDevice.features, rawDevice.limits);
|
|
1501
|
+
const features = rawDevice.features as unknown as RhiFeatures;
|
|
1502
|
+
const limits = rawDevice.limits as RhiLimits;
|
|
1503
|
+
|
|
1504
|
+
const queue: RhiQueue = makeQueue(rawDevice.queue);
|
|
1505
|
+
|
|
1506
|
+
const device: RhiDevice = {
|
|
1507
|
+
caps,
|
|
1508
|
+
features,
|
|
1509
|
+
limits,
|
|
1510
|
+
queue,
|
|
1511
|
+
lost: rawDevice.lost as unknown as Promise<{
|
|
1512
|
+
readonly reason: 'destroyed' | 'unknown';
|
|
1513
|
+
readonly message: string;
|
|
1514
|
+
}>,
|
|
1515
|
+
createBuffer(desc: BufferDescriptor): Result<Buffer, RhiError> {
|
|
1516
|
+
// M5 / K-7 / OQ-7 / D-R3: mappedAtCreation passthrough is delivered by
|
|
1517
|
+
// BUFFER_KEYS containing 'mappedAtCreation'; mirror() ships the field to
|
|
1518
|
+
// the raw GPUBufferDescriptor when present (`'mappedAtCreation' in desc`
|
|
1519
|
+
// semantics), so createBuffer({mappedAtCreation:true}) yields a buffer
|
|
1520
|
+
// in `'mapped'` state per spec §buffer-creation step 6 (research §4.3).
|
|
1521
|
+
// w31 (M5) dawn-real-gpu Pattern B is the regression guard - it asserts
|
|
1522
|
+
// mapState === 'mapped' + getMappedRange().byteLength === 16 + the init
|
|
1523
|
+
// data round-trips through copyBufferToBuffer + mapAsync(READ).
|
|
1524
|
+
const out = rawDevice.createBuffer(
|
|
1525
|
+
mirror(desc, BUFFER_KEYS) as unknown as GPUBufferDescriptor,
|
|
1526
|
+
);
|
|
1527
|
+
// Record metadata for downstream shim validation (resolveQuerySet
|
|
1528
|
+
// destination.usage / destinationOffset bounds, research §2.3 +
|
|
1529
|
+
// M5 mapAsync mode-usage cross-check, research §4.2 step 9 / F-8 row 3).
|
|
1530
|
+
const sizeField = typeof desc.size === 'number' ? desc.size : 0;
|
|
1531
|
+
const usageField = (desc.usage as GPUBufferUsageFlags | undefined) ?? 0;
|
|
1532
|
+
// M5 / w35: wrap the raw GPUBuffer in a forgeax Buffer wrapper that
|
|
1533
|
+
// exposes the mapping surface (mapAsync / getMappedRange / unmap /
|
|
1534
|
+
// mapState getter). The wrapper validates BEFORE delegating to the raw
|
|
1535
|
+
// GPUBuffer; failures ride 'webgpu-runtime-error' with structured
|
|
1536
|
+
// .expected / .hint (K-2 + research §4.2 / F-8 three rows).
|
|
1537
|
+
const handle = makeBufferWrapper(out, sizeField, usageField);
|
|
1538
|
+
BUFFER_RAW_MAP.set(handle, out);
|
|
1539
|
+
BUFFER_META_MAP.set(handle, {
|
|
1540
|
+
size: sizeField,
|
|
1541
|
+
usage: usageField,
|
|
1542
|
+
destroyed: false,
|
|
1543
|
+
});
|
|
1544
|
+
return ok(handle);
|
|
1545
|
+
},
|
|
1546
|
+
createTexture(desc: TextureDescriptor): Result<Texture, RhiError> {
|
|
1547
|
+
const out = rawDevice.createTexture(
|
|
1548
|
+
mirror(desc, TEXTURE_KEYS) as unknown as GPUTextureDescriptor,
|
|
1549
|
+
);
|
|
1550
|
+
const handle = out as unknown as Texture;
|
|
1551
|
+
// Record metadata for createTextureView cross-resource validation.
|
|
1552
|
+
const viewFormats =
|
|
1553
|
+
desc.viewFormats === undefined
|
|
1554
|
+
? []
|
|
1555
|
+
: Array.from(desc.viewFormats as Iterable<GPUTextureFormat>);
|
|
1556
|
+
TEXTURE_META_MAP.set(handle, {
|
|
1557
|
+
format: desc.format as GPUTextureFormat,
|
|
1558
|
+
usage: desc.usage as GPUTextureUsageFlags,
|
|
1559
|
+
viewFormats,
|
|
1560
|
+
destroyed: false,
|
|
1561
|
+
});
|
|
1562
|
+
return ok(handle);
|
|
1563
|
+
},
|
|
1564
|
+
destroyBuffer(buf: Buffer): Result<void, RhiError> {
|
|
1565
|
+
// feat-20260612 M1 / w4 — fail-fast over the spec idempotent-void
|
|
1566
|
+
// contract (plan-strategy D-7). The shim layer tracks
|
|
1567
|
+
// `destroyed: boolean` on per-handle BufferMeta and routes the second
|
|
1568
|
+
// destroy to 'destroy-after-destroy' rather than forwarding to the
|
|
1569
|
+
// underlying GPUBuffer.destroy(). Charter proposition 4 explicit
|
|
1570
|
+
// failure + architecture-principles §5 Fail Fast.
|
|
1571
|
+
const meta = BUFFER_META_MAP.get(buf);
|
|
1572
|
+
if (meta?.destroyed) {
|
|
1573
|
+
return err(
|
|
1574
|
+
new RhiErrorClass({
|
|
1575
|
+
code: 'destroy-after-destroy',
|
|
1576
|
+
expected: 'GPU buffer handle has not been destroyed yet',
|
|
1577
|
+
hint: 'object already destroyed; track lifecycle in caller or check isDestroyed before re-destroy',
|
|
1578
|
+
}),
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
const rawBuf = BUFFER_RAW_MAP.get(buf);
|
|
1582
|
+
try {
|
|
1583
|
+
if (rawBuf !== undefined && typeof rawBuf.destroy === 'function') {
|
|
1584
|
+
rawBuf.destroy();
|
|
1585
|
+
}
|
|
1586
|
+
} catch (e) {
|
|
1587
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1588
|
+
return err(
|
|
1589
|
+
new RhiErrorClass({
|
|
1590
|
+
code: 'webgpu-runtime-error',
|
|
1591
|
+
expected: 'underlying GPUBuffer.destroy() to succeed',
|
|
1592
|
+
hint: `destroy raised: ${message}`,
|
|
1593
|
+
}),
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
if (meta !== undefined) meta.destroyed = true;
|
|
1597
|
+
return ok(undefined);
|
|
1598
|
+
},
|
|
1599
|
+
destroyTexture(tex: Texture): Result<void, RhiError> {
|
|
1600
|
+
// feat-20260612 M1 / w4 — fail-fast on second destroy (D-7); shape
|
|
1601
|
+
// mirrors destroyBuffer above. The shim tracks `destroyed: boolean`
|
|
1602
|
+
// on per-handle TextureMeta; the underlying GPUTexture.destroy()
|
|
1603
|
+
// is invoked exactly once per handle.
|
|
1604
|
+
const meta = TEXTURE_META_MAP.get(tex);
|
|
1605
|
+
if (meta?.destroyed) {
|
|
1606
|
+
return err(
|
|
1607
|
+
new RhiErrorClass({
|
|
1608
|
+
code: 'destroy-after-destroy',
|
|
1609
|
+
expected: 'GPU texture handle has not been destroyed yet',
|
|
1610
|
+
hint: 'object already destroyed; track lifecycle in caller or check isDestroyed before re-destroy',
|
|
1611
|
+
}),
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
const rawTex = tex as unknown as { destroy?: () => void };
|
|
1615
|
+
try {
|
|
1616
|
+
if (typeof rawTex.destroy === 'function') {
|
|
1617
|
+
rawTex.destroy();
|
|
1618
|
+
}
|
|
1619
|
+
} catch (e) {
|
|
1620
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1621
|
+
return err(
|
|
1622
|
+
new RhiErrorClass({
|
|
1623
|
+
code: 'webgpu-runtime-error',
|
|
1624
|
+
expected: 'underlying GPUTexture.destroy() to succeed',
|
|
1625
|
+
hint: `destroy raised: ${message}`,
|
|
1626
|
+
}),
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
if (meta !== undefined) meta.destroyed = true;
|
|
1630
|
+
return ok(undefined);
|
|
1631
|
+
},
|
|
1632
|
+
createTextureView(
|
|
1633
|
+
texture: Texture,
|
|
1634
|
+
desc: TextureViewDescriptor,
|
|
1635
|
+
): Result<TextureView, RhiError> {
|
|
1636
|
+
// Cross-resource validation fast-path (research §1.1). When metadata is
|
|
1637
|
+
// available, validate format ∈ source.format ∪ source.viewFormats and
|
|
1638
|
+
// usage ⊆ source.usage; both violations map to 'webgpu-runtime-error'
|
|
1639
|
+
// (charter proposition 4 explicit failure).
|
|
1640
|
+
const meta = TEXTURE_META_MAP.get(texture);
|
|
1641
|
+
if (meta !== undefined) {
|
|
1642
|
+
const fmt = desc.format as GPUTextureFormat | undefined;
|
|
1643
|
+
if (fmt !== undefined && fmt !== meta.format && !meta.viewFormats.includes(fmt)) {
|
|
1644
|
+
return err(
|
|
1645
|
+
new RhiErrorClass({
|
|
1646
|
+
code: 'webgpu-runtime-error',
|
|
1647
|
+
expected:
|
|
1648
|
+
'createTextureView format must be the source texture format or one of source.viewFormats',
|
|
1649
|
+
hint: `got format='${fmt}'; source.format='${meta.format}'; source.viewFormats=[${meta.viewFormats.join(', ')}]`,
|
|
1650
|
+
}),
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
const reqUsage = desc.usage as GPUTextureUsageFlags | undefined;
|
|
1654
|
+
if (reqUsage !== undefined && reqUsage !== 0 && (reqUsage & ~meta.usage) !== 0) {
|
|
1655
|
+
return err(
|
|
1656
|
+
new RhiErrorClass({
|
|
1657
|
+
code: 'webgpu-runtime-error',
|
|
1658
|
+
expected: 'createTextureView usage must be a subset of source.usage',
|
|
1659
|
+
hint: `got usage=0x${reqUsage.toString(16)}; source.usage=0x${meta.usage.toString(16)}`,
|
|
1660
|
+
}),
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
const rawTexture = texture as unknown as GPUTexture;
|
|
1665
|
+
try {
|
|
1666
|
+
const rawView = rawTexture.createView(
|
|
1667
|
+
mirror(desc, TEXTURE_VIEW_KEYS) as unknown as GPUTextureViewDescriptor,
|
|
1668
|
+
);
|
|
1669
|
+
const handle = rawView as unknown as TextureView;
|
|
1670
|
+
TEXTURE_VIEW_RAW_MAP.set(handle, rawView);
|
|
1671
|
+
return ok(handle);
|
|
1672
|
+
} catch (e) {
|
|
1673
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1674
|
+
return err(
|
|
1675
|
+
new RhiErrorClass({
|
|
1676
|
+
code: 'webgpu-runtime-error',
|
|
1677
|
+
expected: 'underlying GPUTexture.createView to succeed',
|
|
1678
|
+
hint: `createView raised: ${message}`,
|
|
1679
|
+
}),
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
},
|
|
1683
|
+
createSampler(desc?: SamplerDescriptor | undefined): Result<Sampler, RhiError> {
|
|
1684
|
+
// Sampler descriptor is fully optional; if undefined call createSampler() with no arg.
|
|
1685
|
+
if (desc === undefined) {
|
|
1686
|
+
const out = rawDevice.createSampler();
|
|
1687
|
+
return ok(out as unknown as Sampler);
|
|
1688
|
+
}
|
|
1689
|
+
const out = rawDevice.createSampler(
|
|
1690
|
+
mirror(desc, SAMPLER_KEYS) as unknown as GPUSamplerDescriptor,
|
|
1691
|
+
);
|
|
1692
|
+
return ok(out as unknown as Sampler);
|
|
1693
|
+
},
|
|
1694
|
+
createBindGroupLayout(desc: BindGroupLayoutDescriptor): Result<BindGroupLayout, RhiError> {
|
|
1695
|
+
const out = rawDevice.createBindGroupLayout(
|
|
1696
|
+
mirror(desc, BGL_KEYS) as unknown as GPUBindGroupLayoutDescriptor,
|
|
1697
|
+
);
|
|
1698
|
+
return ok(out as unknown as BindGroupLayout);
|
|
1699
|
+
},
|
|
1700
|
+
createBindGroup(desc: BindGroupDescriptor): Result<BindGroup, RhiError> {
|
|
1701
|
+
// w20 — tagged-union RhiBindingResource 4-kind adapter. Each forgeax
|
|
1702
|
+
// entry carries `{ binding, resource: { kind, value } }` (the 4
|
|
1703
|
+
// discriminator values are 'sampler' / 'buffer' / 'textureView' /
|
|
1704
|
+
// 'externalTexture' per @forgeax/engine-rhi RhiBindingResource). The shim
|
|
1705
|
+
// dispatches on `resource.kind` and emits the spec-verbatim
|
|
1706
|
+
// GPUBindGroupEntry shape per kind. The default branch trips
|
|
1707
|
+
// `assertNever` so a future RhiBindingResource extension forces a
|
|
1708
|
+
// TS2367 here at compile time (charter proposition 4 explicit failure
|
|
1709
|
+
// + proposition 5 consistent abstraction over duck-typing).
|
|
1710
|
+
const mirrored: {
|
|
1711
|
+
label?: string | undefined;
|
|
1712
|
+
layout: GPUBindGroupLayout;
|
|
1713
|
+
entries: GPUBindGroupEntry[];
|
|
1714
|
+
} = {
|
|
1715
|
+
layout: desc.layout as unknown as GPUBindGroupLayout,
|
|
1716
|
+
entries: [],
|
|
1717
|
+
};
|
|
1718
|
+
if ('label' in desc && desc.label !== undefined) mirrored.label = desc.label;
|
|
1719
|
+
for (const entry of desc.entries) {
|
|
1720
|
+
const resource = entry.resource;
|
|
1721
|
+
switch (resource.kind) {
|
|
1722
|
+
case 'sampler': {
|
|
1723
|
+
mirrored.entries.push({
|
|
1724
|
+
binding: entry.binding,
|
|
1725
|
+
resource: resource.value as unknown as GPUSampler,
|
|
1726
|
+
});
|
|
1727
|
+
break;
|
|
1728
|
+
}
|
|
1729
|
+
case 'buffer': {
|
|
1730
|
+
const { buffer, offset, size } = resource.value;
|
|
1731
|
+
const rawBuf = BUFFER_RAW_MAP.get(buffer) ?? (buffer as unknown as GPUBuffer);
|
|
1732
|
+
const bufferBinding: GPUBufferBinding = { buffer: rawBuf };
|
|
1733
|
+
if (offset !== undefined) bufferBinding.offset = offset;
|
|
1734
|
+
if (size !== undefined) bufferBinding.size = size;
|
|
1735
|
+
mirrored.entries.push({ binding: entry.binding, resource: bufferBinding });
|
|
1736
|
+
break;
|
|
1737
|
+
}
|
|
1738
|
+
case 'textureView': {
|
|
1739
|
+
mirrored.entries.push({
|
|
1740
|
+
binding: entry.binding,
|
|
1741
|
+
resource: resource.value as unknown as GPUTextureView,
|
|
1742
|
+
});
|
|
1743
|
+
break;
|
|
1744
|
+
}
|
|
1745
|
+
case 'externalTexture': {
|
|
1746
|
+
mirrored.entries.push({
|
|
1747
|
+
binding: entry.binding,
|
|
1748
|
+
resource: resource.value as unknown as GPUExternalTexture,
|
|
1749
|
+
});
|
|
1750
|
+
break;
|
|
1751
|
+
}
|
|
1752
|
+
default: {
|
|
1753
|
+
// assertNever — adding a fifth kind would trip TS2367 here.
|
|
1754
|
+
const _exhaustive: never = resource;
|
|
1755
|
+
void _exhaustive;
|
|
1756
|
+
throw new Error(`rhi-webgpu: unreachable RhiBindingResource kind in createBindGroup`);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
const out = rawDevice.createBindGroup(mirrored as unknown as GPUBindGroupDescriptor);
|
|
1761
|
+
return ok(out as unknown as BindGroup);
|
|
1762
|
+
},
|
|
1763
|
+
createPipelineLayout(desc: PipelineLayoutDescriptor): Result<PipelineLayout, RhiError> {
|
|
1764
|
+
const out = rawDevice.createPipelineLayout(
|
|
1765
|
+
mirror(desc, PL_KEYS) as unknown as GPUPipelineLayoutDescriptor,
|
|
1766
|
+
);
|
|
1767
|
+
return ok(out as unknown as PipelineLayout);
|
|
1768
|
+
},
|
|
1769
|
+
createRenderPipeline(desc: RenderPipelineDescriptor): Result<RenderPipeline, RhiError> {
|
|
1770
|
+
try {
|
|
1771
|
+
const out = rawDevice.createRenderPipeline(mirrorRenderPipelineDescriptor(desc));
|
|
1772
|
+
return ok(out as unknown as RenderPipeline);
|
|
1773
|
+
} catch (e) {
|
|
1774
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1775
|
+
// Keep synchronous render-pipeline creation on the same structured
|
|
1776
|
+
// Result boundary as compute-pipeline creation. A failed PSO must not
|
|
1777
|
+
// escape as an exception and abort the caller's whole frame.
|
|
1778
|
+
if (/compile|shader|wgsl/i.test(message)) {
|
|
1779
|
+
return err(
|
|
1780
|
+
new RhiErrorClass({
|
|
1781
|
+
code: 'shader-compile-failed',
|
|
1782
|
+
expected: 'render shader modules + entry points to be valid',
|
|
1783
|
+
hint: `compile error: ${message}`,
|
|
1784
|
+
}),
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
return err(
|
|
1788
|
+
new RhiErrorClass({
|
|
1789
|
+
code: 'webgpu-runtime-error',
|
|
1790
|
+
expected: 'underlying GPUDevice.createRenderPipeline to succeed',
|
|
1791
|
+
hint: `createRenderPipeline raised: ${message}`,
|
|
1792
|
+
}),
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
},
|
|
1796
|
+
createComputePipeline(desc: ComputePipelineDescriptor): Result<ComputePipeline, RhiError> {
|
|
1797
|
+
// Capability gate (research §1.2 NOTE; plan-strategy §4.3 boundary
|
|
1798
|
+
// case row 1). MVP WebGPU path always has caps.compute=true; the gate
|
|
1799
|
+
// exists for potential future backends that lack compute.
|
|
1800
|
+
if (caps.compute === false) {
|
|
1801
|
+
return err(
|
|
1802
|
+
new RhiErrorClass({
|
|
1803
|
+
code: 'feature-not-enabled',
|
|
1804
|
+
expected: 'caps.compute === true',
|
|
1805
|
+
hint: 'check device.caps.compute before calling createComputePipeline',
|
|
1806
|
+
}),
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
try {
|
|
1810
|
+
const out = rawDevice.createComputePipeline(
|
|
1811
|
+
mirror(desc, CP_KEYS) as unknown as GPUComputePipelineDescriptor,
|
|
1812
|
+
);
|
|
1813
|
+
return ok(out as unknown as ComputePipeline);
|
|
1814
|
+
} catch (e) {
|
|
1815
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1816
|
+
// Shader / module compilation issues surface as 'shader-compile-failed'
|
|
1817
|
+
// with the underlying message; everything else maps to a generic
|
|
1818
|
+
// webgpu runtime error (charter proposition 4 explicit failure).
|
|
1819
|
+
if (/compile|shader|wgsl/i.test(message)) {
|
|
1820
|
+
return err(
|
|
1821
|
+
new RhiErrorClass({
|
|
1822
|
+
code: 'shader-compile-failed',
|
|
1823
|
+
expected: 'compute shader module + entry point to be valid',
|
|
1824
|
+
hint: `compile error: ${message}`,
|
|
1825
|
+
}),
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
return err(
|
|
1829
|
+
new RhiErrorClass({
|
|
1830
|
+
code: 'webgpu-runtime-error',
|
|
1831
|
+
expected: 'underlying GPUDevice.createComputePipeline to succeed',
|
|
1832
|
+
hint: `createComputePipeline raised: ${message}`,
|
|
1833
|
+
}),
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
},
|
|
1837
|
+
createQuerySet(desc: QuerySetDescriptor): Result<QuerySet, RhiError> {
|
|
1838
|
+
// Hard constraint: count <= 4096 (research §1.3 device timeline step 1).
|
|
1839
|
+
const count = desc.count as number | undefined;
|
|
1840
|
+
if (typeof count === 'number' && count > QUERY_SET_COUNT_LIMIT) {
|
|
1841
|
+
return err(
|
|
1842
|
+
new RhiErrorClass({
|
|
1843
|
+
code: 'limit-exceeded',
|
|
1844
|
+
expected: 'count <= 4096 (spec normative)',
|
|
1845
|
+
hint: 'create multiple QuerySet instances if more than 4096 queries needed',
|
|
1846
|
+
}),
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
// Hard constraint: timestamp requires caps.timestampQuery.
|
|
1850
|
+
if (desc.type === 'timestamp' && caps.timestampQuery !== true) {
|
|
1851
|
+
return err(
|
|
1852
|
+
new RhiErrorClass({
|
|
1853
|
+
code: 'feature-not-enabled',
|
|
1854
|
+
expected: 'caps.timestampQuery === true (timestamp-query feature)',
|
|
1855
|
+
hint: 'request the timestamp-query feature at requestDevice and check device.caps.timestampQuery before creating timestamp QuerySets',
|
|
1856
|
+
}),
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
try {
|
|
1860
|
+
const out = rawDevice.createQuerySet(
|
|
1861
|
+
mirror(desc, QS_KEYS) as unknown as GPUQuerySetDescriptor,
|
|
1862
|
+
);
|
|
1863
|
+
const handle = out as unknown as QuerySet;
|
|
1864
|
+
// Register raw handle so the RPE / encoder paths can read `.count` /
|
|
1865
|
+
// `.type` for bounds + alignment checks (research §2.1 + §2.3).
|
|
1866
|
+
QUERY_SET_RAW_MAP.set(handle, out);
|
|
1867
|
+
QUERY_SET_DESTROYED_MAP.set(handle, { destroyed: false });
|
|
1868
|
+
return ok(handle);
|
|
1869
|
+
} catch (e) {
|
|
1870
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1871
|
+
return err(
|
|
1872
|
+
new RhiErrorClass({
|
|
1873
|
+
code: 'webgpu-runtime-error',
|
|
1874
|
+
expected: 'underlying GPUDevice.createQuerySet to succeed',
|
|
1875
|
+
hint: `createQuerySet raised: ${message}`,
|
|
1876
|
+
}),
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
},
|
|
1880
|
+
destroyQuerySet(querySet: QuerySet): Result<void, RhiError> {
|
|
1881
|
+
const marker = QUERY_SET_DESTROYED_MAP.get(querySet);
|
|
1882
|
+
if (marker?.destroyed) {
|
|
1883
|
+
return err(
|
|
1884
|
+
new RhiErrorClass({
|
|
1885
|
+
code: 'destroy-after-destroy',
|
|
1886
|
+
expected: 'GPU query-set handle has not been destroyed yet',
|
|
1887
|
+
hint: 'object already destroyed; release each timestamp QuerySet exactly once',
|
|
1888
|
+
}),
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
const rawQuery = QUERY_SET_RAW_MAP.get(querySet) as
|
|
1892
|
+
| (GPUQuerySet & { destroy?: () => void })
|
|
1893
|
+
| undefined;
|
|
1894
|
+
try {
|
|
1895
|
+
if (rawQuery !== undefined && typeof rawQuery.destroy === 'function') rawQuery.destroy();
|
|
1896
|
+
} catch (e) {
|
|
1897
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1898
|
+
return err(
|
|
1899
|
+
new RhiErrorClass({
|
|
1900
|
+
code: 'webgpu-runtime-error',
|
|
1901
|
+
expected: 'underlying GPUQuerySet.destroy() to succeed',
|
|
1902
|
+
hint: `destroy raised: ${message}`,
|
|
1903
|
+
}),
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
if (marker !== undefined) marker.destroyed = true;
|
|
1907
|
+
return ok(undefined);
|
|
1908
|
+
},
|
|
1909
|
+
createCommandEncoder(
|
|
1910
|
+
desc?: CommandEncoderDescriptor | undefined,
|
|
1911
|
+
): Result<RhiCommandEncoder, RhiError> {
|
|
1912
|
+
const rawEnc =
|
|
1913
|
+
desc === undefined
|
|
1914
|
+
? rawDevice.createCommandEncoder()
|
|
1915
|
+
: rawDevice.createCommandEncoder(
|
|
1916
|
+
mirror(desc, ENC_KEYS) as unknown as GPUCommandEncoderDescriptor,
|
|
1917
|
+
);
|
|
1918
|
+
// M5 / w39: pass caps.timestampQuery + an onError-style fan-out so
|
|
1919
|
+
// writeTimestamp can fire 'feature-not-enabled' through the engine
|
|
1920
|
+
// channel when caps.timestampQuery is false (K-3: spec writeTimestamp
|
|
1921
|
+
// returns void; the forgeax form keeps that shape).
|
|
1922
|
+
//
|
|
1923
|
+
// Round 3 fix-up F-P3-3: the forgeax RhiDevice
|
|
1924
|
+
// does not expose `onError` directly (charter proposition 5: keep
|
|
1925
|
+
// RHI math-free + listener-free), so the shim writes a structured
|
|
1926
|
+
// diagnostic to `console.error` matching the RhiError shape. The
|
|
1927
|
+
// engine layer subscribes through `Renderer.onError` and fans the
|
|
1928
|
+
// same RhiError out; this keeps the unsupported capability observable
|
|
1929
|
+
// for pure-RHI consumers (mock unit tests, dawn-real-gpu probes) that
|
|
1930
|
+
// never instantiate a Renderer.
|
|
1931
|
+
const fireFeatureNotEnabled = (featureName: string, hint: string): void => {
|
|
1932
|
+
// Diagnostic channel (a) of the K-9 double-channel pattern: default
|
|
1933
|
+
// console.error so AI consumers running headless / mock paths still
|
|
1934
|
+
// observe the unsupported capability without subscribing to a
|
|
1935
|
+
// listener. The capability-disabled entry remains non-throwing.
|
|
1936
|
+
console.error(
|
|
1937
|
+
`[RhiError feature-not-enabled] expected: device.features.has('${featureName}') === true; hint: ${hint}`,
|
|
1938
|
+
);
|
|
1939
|
+
};
|
|
1940
|
+
return ok(makeCommandEncoder(rawEnc, caps, fireFeatureNotEnabled));
|
|
1941
|
+
},
|
|
1942
|
+
// fix-f3: synchronous createShaderModule placeholder removed; the
|
|
1943
|
+
// shader-compile-failed path lives in the top-level async factory
|
|
1944
|
+
// (see ../index.ts).
|
|
1945
|
+
};
|
|
1946
|
+
RAW_DEVICE_MAP.set(device, rawDevice);
|
|
1947
|
+
return { device, raw: rawDevice };
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// ============================================================================
|
|
1951
|
+
// RhiCanvasContext shim (M3 / K-4 / w21)
|
|
1952
|
+
// ============================================================================
|
|
1953
|
+
//
|
|
1954
|
+
// Spec anchor: W3C WebGPU §3.3 GPUCanvasContext / GPUCanvasConfiguration.
|
|
1955
|
+
// 4 methods (research §3.1) + 7 fields (§3.2) + 4 method algorithms (§3.3).
|
|
1956
|
+
|
|
1957
|
+
/** Spec normative supported context formats (research §3.2 normative). */
|
|
1958
|
+
const SUPPORTED_CONTEXT_FORMATS: ReadonlySet<string> = new Set([
|
|
1959
|
+
'bgra8unorm',
|
|
1960
|
+
'rgba8unorm',
|
|
1961
|
+
'rgba16float',
|
|
1962
|
+
]);
|
|
1963
|
+
|
|
1964
|
+
const CANVAS_CONFIG_KEYS = [
|
|
1965
|
+
'device',
|
|
1966
|
+
'format',
|
|
1967
|
+
'usage',
|
|
1968
|
+
'viewFormats',
|
|
1969
|
+
'colorSpace',
|
|
1970
|
+
'toneMapping',
|
|
1971
|
+
'alphaMode',
|
|
1972
|
+
] as const;
|
|
1973
|
+
|
|
1974
|
+
/** Stripped-down GPUCanvasContext shape the shim consumes. Real
|
|
1975
|
+
* GPUCanvasContext satisfies this; the unit-test mock fixture only
|
|
1976
|
+
* implements what is needed (charter proposition 1: progressive disclosure). */
|
|
1977
|
+
export interface GpuCanvasContextLike {
|
|
1978
|
+
configure(configuration: GPUCanvasConfiguration): void;
|
|
1979
|
+
unconfigure(): void;
|
|
1980
|
+
getConfiguration(): GPUCanvasConfiguration | null;
|
|
1981
|
+
getCurrentTexture(): GPUTexture;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/**
|
|
1985
|
+
* Build a RhiCanvasContext shim around a raw GPUCanvasContext (M3 / K-4 /
|
|
1986
|
+
* w21).
|
|
1987
|
+
*
|
|
1988
|
+
* Pre-configure validation (research §3.3 mapping):
|
|
1989
|
+
* - format gate: format must be in SUPPORTED_CONTEXT_FORMATS; otherwise
|
|
1990
|
+
* fast-path returns `'webgpu-runtime-error'` with the spec-aligned
|
|
1991
|
+
* `.expected` literal `'one of bgra8unorm/rgba8unorm/rgba16float'`.
|
|
1992
|
+
*
|
|
1993
|
+
* Post-configure semantics:
|
|
1994
|
+
* - getCurrentTexture forwards each call to the raw context (NO cross-frame
|
|
1995
|
+
* caching, research §3.3 [[Expire the current texture]]); spec
|
|
1996
|
+
* InvalidStateError catches map to `'webgpu-runtime-error'`.
|
|
1997
|
+
* - getConfiguration projects the spec record verbatim; missing fields
|
|
1998
|
+
* remain missing (feature-detection idiom, research §3.2 toneMapping
|
|
1999
|
+
* NOTE).
|
|
2000
|
+
*/
|
|
2001
|
+
export function makeCanvasContext(rawContext: GpuCanvasContextLike): RhiCanvasContext {
|
|
2002
|
+
return {
|
|
2003
|
+
configure(desc: CanvasConfiguration): Result<void, RhiError> {
|
|
2004
|
+
const fmt = desc.format as GPUTextureFormat | undefined;
|
|
2005
|
+
if (typeof fmt === 'string' && !SUPPORTED_CONTEXT_FORMATS.has(fmt)) {
|
|
2006
|
+
return err(
|
|
2007
|
+
new RhiErrorClass({
|
|
2008
|
+
code: 'webgpu-runtime-error',
|
|
2009
|
+
expected: 'one of bgra8unorm/rgba8unorm/rgba16float',
|
|
2010
|
+
hint: `got format='${fmt}'; canvas configuration cannot use srgb formats — use the non-srgb form (e.g. 'bgra8unorm') and put the srgb format in viewFormats, then createView with the srgb format`,
|
|
2011
|
+
}),
|
|
2012
|
+
);
|
|
2013
|
+
}
|
|
2014
|
+
try {
|
|
2015
|
+
const mirrored = mirror(
|
|
2016
|
+
desc as unknown as Record<string, unknown>,
|
|
2017
|
+
CANVAS_CONFIG_KEYS,
|
|
2018
|
+
) as unknown as Record<string, unknown>;
|
|
2019
|
+
// CanvasConfiguration.device is a forgeax RhiDevice brand (D-S5);
|
|
2020
|
+
// the spec GPUCanvasContext.configure({ device }) slot needs the raw
|
|
2021
|
+
// GPUDevice. Translate via RAW_DEVICE_MAP so AI-user-facing code only
|
|
2022
|
+
// sees the forgeax abstraction while the underlying spec call still
|
|
2023
|
+
// receives a valid raw device (charter proposition 5 consistent
|
|
2024
|
+
// abstraction red line + feat-20260510-rhi-resource-creation M4
|
|
2025
|
+
// escape hatch tear-down: the translation is fully internal to
|
|
2026
|
+
// packages/rhi-webgpu/src and does not surface a reverse-lookup
|
|
2027
|
+
// entry across the package boundary).
|
|
2028
|
+
if ('device' in mirrored) {
|
|
2029
|
+
const forgeaxDevice = desc.device as RhiDevice;
|
|
2030
|
+
const rawDev = RAW_DEVICE_MAP.get(forgeaxDevice);
|
|
2031
|
+
if (rawDev === undefined) {
|
|
2032
|
+
return err(
|
|
2033
|
+
new RhiErrorClass({
|
|
2034
|
+
code: 'rhi-not-available',
|
|
2035
|
+
expected:
|
|
2036
|
+
'CanvasConfiguration.device must be a RhiDevice produced by rhi.requestAdapter().requestDevice() (or the deprecated rhi.requestDevice factory)',
|
|
2037
|
+
hint: 'pass the device returned by the forgeax rhi.requestAdapter() / rhi.requestDevice() entries; passing a foreign RhiDevice or a raw GPUDevice is rejected because the canvas-context spec requires the same raw GPUDevice that the forgeax shim wraps',
|
|
2038
|
+
}),
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
mirrored.device = rawDev;
|
|
2042
|
+
}
|
|
2043
|
+
rawContext.configure(mirrored as unknown as GPUCanvasConfiguration);
|
|
2044
|
+
return ok(undefined);
|
|
2045
|
+
} catch (e) {
|
|
2046
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2047
|
+
// Spec maps invalid / lost device to InvalidStateError; we map the
|
|
2048
|
+
// catch-all to webgpu-runtime-error so AI users get a structured
|
|
2049
|
+
// failure (charter proposition 4 explicit failure).
|
|
2050
|
+
if (e instanceof Error && (e.name === 'InvalidStateError' || /lost|destroyed/i.test(msg))) {
|
|
2051
|
+
return err(
|
|
2052
|
+
new RhiErrorClass({
|
|
2053
|
+
code: 'rhi-not-available',
|
|
2054
|
+
expected: 'CanvasConfiguration.device must be valid (not lost / destroyed)',
|
|
2055
|
+
hint: `configure raised: ${msg}`,
|
|
2056
|
+
}),
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
return err(
|
|
2060
|
+
new RhiErrorClass({
|
|
2061
|
+
code: 'webgpu-runtime-error',
|
|
2062
|
+
expected: 'underlying GPUCanvasContext.configure to succeed',
|
|
2063
|
+
hint: `configure raised: ${msg}`,
|
|
2064
|
+
}),
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
},
|
|
2068
|
+
unconfigure(): void {
|
|
2069
|
+
rawContext.unconfigure();
|
|
2070
|
+
},
|
|
2071
|
+
getConfiguration(): CanvasConfiguration | undefined {
|
|
2072
|
+
const conf = rawContext.getConfiguration();
|
|
2073
|
+
if (conf === null) return undefined;
|
|
2074
|
+
// Project spec fields onto the forgeax record verbatim; missing fields
|
|
2075
|
+
// remain missing (feature-detection idiom).
|
|
2076
|
+
const out: Record<string, unknown> = {};
|
|
2077
|
+
for (const k of CANVAS_CONFIG_KEYS) {
|
|
2078
|
+
if (k in (conf as unknown as Record<string, unknown>)) {
|
|
2079
|
+
out[k] = (conf as unknown as Record<string, unknown>)[k];
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
return out as unknown as CanvasConfiguration;
|
|
2083
|
+
},
|
|
2084
|
+
getCurrentTexture(): Result<Texture, RhiError> {
|
|
2085
|
+
try {
|
|
2086
|
+
const rawTex = rawContext.getCurrentTexture();
|
|
2087
|
+
return ok(rawTex as unknown as Texture);
|
|
2088
|
+
} catch (e) {
|
|
2089
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2090
|
+
// Spec InvalidStateError on unconfigured context maps to
|
|
2091
|
+
// 'webgpu-runtime-error' (charter proposition 4).
|
|
2092
|
+
return err(
|
|
2093
|
+
new RhiErrorClass({
|
|
2094
|
+
code: 'webgpu-runtime-error',
|
|
2095
|
+
expected: 'GPUCanvasContext.getCurrentTexture to succeed (context configured)',
|
|
2096
|
+
hint: `getCurrentTexture raised: ${msg}`,
|
|
2097
|
+
}),
|
|
2098
|
+
);
|
|
2099
|
+
}
|
|
2100
|
+
},
|
|
2101
|
+
};
|
|
2102
|
+
}
|