@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.
Files changed (33) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +133 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/__mocks__/gpu-device.d.ts +226 -0
  5. package/dist/__tests__/__mocks__/gpu-device.d.ts.map +1 -0
  6. package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts +2 -0
  7. package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts.map +1 -0
  8. package/dist/__tests__/rhi-webgpu.unit.test.d.ts +2 -0
  9. package/dist/__tests__/rhi-webgpu.unit.test.d.ts.map +1 -0
  10. package/dist/device.d.ts +62 -0
  11. package/dist/device.d.ts.map +1 -0
  12. package/dist/errors.d.ts +49 -0
  13. package/dist/errors.d.ts.map +1 -0
  14. package/dist/index.d.ts +183 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.mjs +1745 -0
  17. package/dist/index.mjs.map +1 -0
  18. package/dist/internal/__tests__/timestamp-query.unit.test.d.ts +2 -0
  19. package/dist/internal/__tests__/timestamp-query.unit.test.d.ts.map +1 -0
  20. package/dist/internal/error-translation.d.ts +16 -0
  21. package/dist/internal/error-translation.d.ts.map +1 -0
  22. package/dist/internal/timestamp-query.d.ts +15 -0
  23. package/dist/internal/timestamp-query.d.ts.map +1 -0
  24. package/package.json +58 -0
  25. package/src/__tests__/__mocks__/gpu-device.ts +555 -0
  26. package/src/__tests__/dawn-real-gpu.dawn.test.ts +1445 -0
  27. package/src/__tests__/rhi-webgpu.unit.test.ts +2398 -0
  28. package/src/device.ts +2102 -0
  29. package/src/errors.ts +183 -0
  30. package/src/index.ts +597 -0
  31. package/src/internal/__tests__/timestamp-query.unit.test.ts +88 -0
  32. package/src/internal/error-translation.ts +187 -0
  33. 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
+ }