@forgeax/engine-rhi-webgpu 0.1.2

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 (45) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +124 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/__mocks__/gpu-device.d.ts +233 -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__/queue-write-range.browser.test.d.ts +2 -0
  9. package/dist/__tests__/queue-write-range.browser.test.d.ts.map +1 -0
  10. package/dist/__tests__/rgba16float-live-probe.browser.test.d.ts +2 -0
  11. package/dist/__tests__/rgba16float-live-probe.browser.test.d.ts.map +1 -0
  12. package/dist/__tests__/rgba16float-live-probe.d.ts +12 -0
  13. package/dist/__tests__/rgba16float-live-probe.d.ts.map +1 -0
  14. package/dist/__tests__/rgba16float-live-probe.dawn.test.d.ts +2 -0
  15. package/dist/__tests__/rgba16float-live-probe.dawn.test.d.ts.map +1 -0
  16. package/dist/__tests__/rhi-webgpu.unit.test.d.ts +2 -0
  17. package/dist/__tests__/rhi-webgpu.unit.test.d.ts.map +1 -0
  18. package/dist/device.d.ts +62 -0
  19. package/dist/device.d.ts.map +1 -0
  20. package/dist/errors.d.ts +47 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/index.d.ts +183 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.mjs +1704 -0
  25. package/dist/index.mjs.map +1 -0
  26. package/dist/internal/__tests__/timestamp-query.unit.test.d.ts +2 -0
  27. package/dist/internal/__tests__/timestamp-query.unit.test.d.ts.map +1 -0
  28. package/dist/internal/error-translation.d.ts +16 -0
  29. package/dist/internal/error-translation.d.ts.map +1 -0
  30. package/dist/internal/timestamp-query.d.ts +39 -0
  31. package/dist/internal/timestamp-query.d.ts.map +1 -0
  32. package/package.json +58 -0
  33. package/src/__tests__/__mocks__/gpu-device.ts +564 -0
  34. package/src/__tests__/dawn-real-gpu.dawn.test.ts +1488 -0
  35. package/src/__tests__/queue-write-range.browser.test.ts +60 -0
  36. package/src/__tests__/rgba16float-live-probe.browser.test.ts +13 -0
  37. package/src/__tests__/rgba16float-live-probe.dawn.test.ts +15 -0
  38. package/src/__tests__/rgba16float-live-probe.ts +44 -0
  39. package/src/__tests__/rhi-webgpu.unit.test.ts +2409 -0
  40. package/src/device.ts +1974 -0
  41. package/src/errors.ts +145 -0
  42. package/src/index.ts +599 -0
  43. package/src/internal/__tests__/timestamp-query.unit.test.ts +88 -0
  44. package/src/internal/error-translation.ts +187 -0
  45. package/src/internal/timestamp-query.ts +167 -0
package/src/index.ts ADDED
@@ -0,0 +1,599 @@
1
+ // @forgeax/engine-rhi-webgpu — thin WebGPU shim implementing the @forgeax/engine-rhi interface shape.
2
+ //
3
+ // Shape rules (shared between README + AGENTS.md '## RHI / WebGPU' section):
4
+ // - spec alignment — the 5 descriptor field names pass through verbatim to
5
+ // GPUDevice.createX.
6
+ // - `?: T | undefined` + `'x' in src` guard (decision S-7 / research §F-3).
7
+ // - opaque handle — `RhiDevice.createX` returns `Result.ok(handle)`; the handle
8
+ // internally references the GPU resource object but exposes only a brand-only
9
+ // opaque shape externally (research §R5).
10
+ // - capability-gated — caps / features / limits are exposed as three independent
11
+ // readonly layers (charter proposition 5).
12
+ // - device.lost dual-track — the spec Promise passes through (research §F-4 / R2
13
+ // countermeasure); the engine layer does the fan-out, this package does not
14
+ // introduce a secondary cache.
15
+ //
16
+ // Public entries (charter proposition 1: progressive disclosure / single entry that
17
+ // surfaces the full surface area at once):
18
+ // - requestAdapter(opts?) — entry 1: spec-aligned strict two-step
19
+ // path (M3 break-point #2 + M6 fix-up
20
+ // [w51]); returns Result<RhiAdapter, RhiError>.
21
+ // - createShaderModule(d, desc) — entry 2: async, returns
22
+ // Result<ShaderModule, RhiError>; the
23
+ // shader-compile-failed path includes
24
+ // detail.compilerMessages.
25
+ // - acquireCanvasContext(canvas) — entry 3: calls canvas.getContext('webgpu')
26
+ // + wraps as branded RhiCanvasContext (M3 / w15).
27
+ // - rhi — singleton entry (plan-strategy §7.4
28
+ // 'import { rhi } from @forgeax/engine-rhi-webgpu' +
29
+ // Engine.create({ rhi, canvas })).
30
+ //
31
+ // The legacy single-step `rhi.requestDevice(opts)` factory was retired in
32
+ // M6 fix-up [w51] (AGENTS.md break-point list 2026-05-10 #2). Callers go
33
+ // through `(await rhi.requestAdapter()).value.requestDevice(opts)`.
34
+ //
35
+ // Anchors: requirements §AC AC-05 + AC-10 + MVP-1.1 / MVP-1.2 / MVP-1.7 + edge cases
36
+ // + §hard constraint 10; plan-strategy §1 architecture + §2 S-7 + §6 M2 +
37
+ // §7.3 error-message strategy table + §7.4 discoverability;
38
+ // research §F-1 / §F-3 / §F-4 / §F-5 / §F-6.
39
+
40
+ /// <reference types="@webgpu/types" />
41
+
42
+ import type {
43
+ RequestDeviceOptions as ForgeaXRequestDeviceOptions,
44
+ RequestAdapterOptions,
45
+ Result,
46
+ RhiAdapter,
47
+ RhiCanvasContext,
48
+ RhiDevice,
49
+ RhiError,
50
+ RhiInstance,
51
+ ShaderModule,
52
+ } from '@forgeax/engine-rhi';
53
+ import { err, ok, RhiError as RhiErrorClass } from '@forgeax/engine-rhi';
54
+ import { _internal_getRawDevice, makeCanvasContext, makeRhiDevice } from './device';
55
+ import {
56
+ adapterUnavailable,
57
+ featureNotEnabled,
58
+ limitExceeded,
59
+ shaderCompileFailed,
60
+ } from './errors';
61
+
62
+ /**
63
+ * The `GPU` subset accepted at the provider seam (research §F-6 webgpu-utils +
64
+ * CTS consensus).
65
+ *
66
+ * The shim only calls the two entries `gpu.requestAdapter()` →
67
+ * `adapter.requestDevice()`; accepting a structural subset rather than requiring
68
+ * a full GPU interface implementation lets the mock fixture
69
+ * (src/__tests__/__mocks__/gpu-device.ts) skip implementing
70
+ * `wgslLanguageFeatures` / `getPreferredCanvasFormat` and other fields this loop
71
+ * does not consume (charter proposition 1: progressive disclosure).
72
+ */
73
+ export interface GpuLike {
74
+ // forgeax-async-whitelist: dom-native — spec `GPU.requestAdapter()` raw entry
75
+ requestAdapter(options?: GPURequestAdapterOptions | undefined): Promise<GpuAdapterLike | null>;
76
+ }
77
+
78
+ /** The `GPUAdapter` subset accepted at the provider seam — only the `requestDevice` entry is consumed. */
79
+ export interface GpuAdapterLike {
80
+ // forgeax-async-whitelist: dom-native — spec `GPUAdapter.requestDevice()` raw entry
81
+ requestDevice(descriptor?: GPUDeviceDescriptor | undefined): Promise<GpuDeviceLike>;
82
+ }
83
+
84
+ /**
85
+ * The `GPUDevice` subset accepted at the provider seam — exactly the fields the
86
+ * shim actually touches (the 5 descriptor `createX` calls + features / limits /
87
+ * lost + a `queue` placeholder).
88
+ *
89
+ * Differences from the full GPUDevice spec:
90
+ * - `onuncapturederror` / `pushErrorScope` / `createCommandEncoder` not required
91
+ * (only needed at M3).
92
+ * - `wgslLanguageFeatures` / `getPreferredCanvasFormat` not required (charter
93
+ * proposition 1: progressive disclosure).
94
+ *
95
+ * The coverage of `cast as GPUDevice` inside `makeRhiDevice` matches this
96
+ * interface exactly; both the mock and the real GPUDevice only need to satisfy
97
+ * this structural subset.
98
+ */
99
+ export interface GpuDeviceLike {
100
+ readonly features: GPUSupportedFeatures;
101
+ readonly limits: GPUSupportedLimits;
102
+ // forgeax-async-whitelist: dom-native — spec `GPUDevice.lost` Promise passthrough
103
+ readonly lost: Promise<GPUDeviceLostInfo>;
104
+ readonly queue: unknown;
105
+ createBuffer(descriptor: GPUBufferDescriptor): unknown;
106
+ createTexture(descriptor: GPUTextureDescriptor): unknown;
107
+ createSampler(descriptor?: GPUSamplerDescriptor | undefined): unknown;
108
+ createBindGroupLayout(descriptor: GPUBindGroupLayoutDescriptor): unknown;
109
+ createBindGroup(descriptor: GPUBindGroupDescriptor): unknown;
110
+ createPipelineLayout(descriptor: GPUPipelineLayoutDescriptor): unknown;
111
+ createRenderPipeline(descriptor: GPURenderPipelineDescriptor): unknown;
112
+ createShaderModule(descriptor: GPUShaderModuleDescriptor): unknown;
113
+ }
114
+
115
+ /**
116
+ * Options for the `requestDevice` entry.
117
+ *
118
+ * `gpu?: GpuLike` is the provider seam (research §F-6 webgpu-utils + CTS
119
+ * consensus):
120
+ * - omitted → falls back to `globalThis.navigator.gpu` (real-device path).
121
+ * - explicitly provided → uses the caller-injected mock / real GPU object (mock
122
+ * unit-test path).
123
+ *
124
+ * `adapterOptions` / `deviceDescriptor` pass through to the corresponding spec
125
+ * entries.
126
+ */
127
+ export interface RequestDeviceOptions {
128
+ gpu?: GpuLike | undefined;
129
+ adapterOptions?: GPURequestAdapterOptions | undefined;
130
+ deviceDescriptor?: GPUDeviceDescriptor | undefined;
131
+ }
132
+
133
+ /**
134
+ * Maps an OperationError that may occur during `requestDevice` back into a
135
+ * RhiError.
136
+ *
137
+ * Spec behavior (research §F-5 / W3C CR §"Adapter Selection"):
138
+ * - `requiredFeatures` exceeds the adapter → reject with `OperationError`.
139
+ * - `requiredLimits` exceeds the adapter → reject with `OperationError`.
140
+ *
141
+ * This package distinguishes feature vs limit by message keyword; the message
142
+ * format is not interoperable between the mock and real GPU implementations
143
+ * (research §F-4: ecosystem-wide message-field inconsistency), but the keywords
144
+ * 'feature' / 'limit' work as a heuristic on both sides. Detailed
145
+ * classification (including limit-name / feature-name extraction) is left to a
146
+ * follow-up loop (plan-strategy §3 R2 fallback).
147
+ */
148
+ function classifyRequestDeviceError(e: unknown): Result<never, RhiError> {
149
+ const msg = e instanceof Error ? e.message : String(e);
150
+ if (/feature/i.test(msg)) return featureNotEnabled();
151
+ if (/limit/i.test(msg)) return limitExceeded();
152
+ // Fallback: an unrecognized message keyword is classified as feature-not-enabled
153
+ // (charter proposition 4: explicit failure + structured errors over hidden
154
+ // branches). Subsequent loops can extend the classification.
155
+ return featureNotEnabled();
156
+ }
157
+
158
+ /**
159
+ * Internal `requestDevice` — single-step factory accepting an injected
160
+ * `gpu` mock provider. **Not part of the public RHI surface**: AI users go
161
+ * through the spec-aligned two-step path
162
+ * `rhi.requestAdapter() -> adapter.requestDevice()` (M3 break-point #2 + M6
163
+ * fix-up [w51]; AGENTS.md break-point list 2026-05-10 #2). This entry is
164
+ * retained as the unit-test seam (`packages/rhi-webgpu/src/__tests__/*`)
165
+ * for `gpu` mock injection; it is **not** re-exported through the `rhi`
166
+ * singleton and is **not** referenced by engine / apps / dawn paths
167
+ * (charter proposition 5 consistent abstraction red line + grep gate
168
+ * `m6-e: rhi.requestDevice( 0 hit`).
169
+ *
170
+ * Generates 3 of the 4 error paths here (research §F-5):
171
+ * - adapter null → `Result.err(RhiError { code: 'adapter-unavailable' })`
172
+ * - feature not enabled → `Result.err(RhiError { code: 'feature-not-enabled' })`
173
+ * - limit exceeded → `Result.err(RhiError { code: 'limit-exceeded' })`
174
+ *
175
+ * The 4th path (shader-compile-failed) is generated by the
176
+ * `createShaderModule` entry.
177
+ */
178
+ export async function requestDevice(
179
+ opts: RequestDeviceOptions = {},
180
+ ): Promise<Result<RhiDevice, RhiError>> {
181
+ const injected: GpuLike | undefined = opts.gpu;
182
+ const ambient: GPU | undefined =
183
+ typeof globalThis !== 'undefined'
184
+ ? (globalThis as { navigator?: Navigator }).navigator?.gpu
185
+ : undefined;
186
+ const gpu: GpuLike | undefined = injected ?? ambient;
187
+ if (gpu === undefined || gpu === null) {
188
+ return adapterUnavailable();
189
+ }
190
+
191
+ const adapter = await gpu.requestAdapter(opts.adapterOptions);
192
+ if (adapter === null) {
193
+ return adapterUnavailable();
194
+ }
195
+
196
+ let rawDevice: GpuDeviceLike;
197
+ try {
198
+ rawDevice = await adapter.requestDevice(opts.deviceDescriptor);
199
+ } catch (e) {
200
+ return classifyRequestDeviceError(e);
201
+ }
202
+
203
+ const { device } = makeRhiDevice(rawDevice as unknown as GPUDevice);
204
+ return ok(device);
205
+ }
206
+
207
+ /**
208
+ * Entry 2 - async `createShaderModule`. The shader-compile-failed path
209
+ * forwards every 6 fields of `GPUCompilationMessage` to
210
+ * `RhiError.detail.compilerMessages` (OQ-P2 / F-3 finding).
211
+ *
212
+ * Implementation (post fix-f3):
213
+ * 1) Look up the underlying `GPUDevice` via the in-package
214
+ * `_internal_getRawDevice` (RAW_DEVICE_MAP reverse lookup; same module).
215
+ * 2) `rawDevice.createShaderModule(desc)` calls the spec entry to obtain
216
+ * a `GPUShaderModule`.
217
+ * 3) `await module.getCompilationInfo()` retrieves compilation info.
218
+ * 4) If any message has `type === 'error'`, return
219
+ * `Result.err(RhiError { code: 'shader-compile-failed',
220
+ * detail: { compilerMessages } })`.
221
+ * 5) Otherwise return `Result.ok(module as ShaderModule)`.
222
+ *
223
+ * Note: this entry accepts a shim-wrapped RhiDevice (not a raw GPUDevice)
224
+ * to keep the public API single-source; the in-package
225
+ * `_internal_getRawDevice` is the only sanctioned reverse lookup.
226
+ *
227
+ * fix-f3: the synchronous `RhiDevice.createShaderModule` placeholder is
228
+ * removed; the shader-compile-failed path closes inside this async entry
229
+ * (charter proposition 5 consistent abstraction + proposition 4 explicit
230
+ * failure).
231
+ */
232
+ export async function createShaderModule(
233
+ device: RhiDevice,
234
+ desc: { label?: string | undefined; code: string },
235
+ ): Promise<Result<ShaderModule, RhiError>> {
236
+ // In-package reverse lookup of the underlying GPUDevice. After D-S1 the
237
+ // function is renamed to `_internal_getRawDevice`; this call is in the
238
+ // same package as the WeakMap registry so it is allowed by the AC-08
239
+ // grep gate (the gate only restricts cross-package callers).
240
+ const rawDevice = _internal_getRawDevice(device);
241
+ if (rawDevice === undefined) {
242
+ // Rare: the device was not created by makeRhiDevice (external mock, etc.);
243
+ // the degraded path returns shader-compile-failed as a fallback so an AI
244
+ // user's exhaustive switch still matches (proposition 9: graceful
245
+ // degradation).
246
+ return shaderCompileFailed([
247
+ {
248
+ type: 'error',
249
+ message: 'rhi-webgpu: createShaderModule called with unregistered RhiDevice',
250
+ lineNum: 0,
251
+ linePos: 0,
252
+ offset: 0,
253
+ length: 0,
254
+ } as GPUCompilationMessage,
255
+ ]);
256
+ }
257
+ const mirrored: { label?: string; code: string } = { code: desc.code };
258
+ if ('label' in desc && desc.label !== undefined) mirrored.label = desc.label;
259
+ let handle: GPUShaderModule;
260
+ try {
261
+ handle = rawDevice.createShaderModule(mirrored as GPUShaderModuleDescriptor);
262
+ } catch (e) {
263
+ // The synchronous part of a real-device createShaderModule rarely throws
264
+ // (spec: errors are surfaced asynchronously through getCompilationInfo);
265
+ // a few mock shapes might throw — fall back to the
266
+ // shader-compile-failed path.
267
+ const message = e instanceof Error ? e.message : String(e);
268
+ return shaderCompileFailed([
269
+ {
270
+ type: 'error',
271
+ message,
272
+ lineNum: 0,
273
+ linePos: 0,
274
+ offset: 0,
275
+ length: 0,
276
+ } as GPUCompilationMessage,
277
+ ]);
278
+ }
279
+ const handleWithInfo = handle as GPUShaderModule & {
280
+ // forgeax-async-whitelist: dom-native — spec `GPUShaderModule.getCompilationInfo()`
281
+ getCompilationInfo?: () => Promise<GPUCompilationInfo>;
282
+ };
283
+ if (typeof handleWithInfo.getCompilationInfo !== 'function') {
284
+ // A real GPUShaderModule always has getCompilationInfo (spec-mandated);
285
+ // its absence implies the shim path drifted from the spec — the degraded
286
+ // path returns ok (does not block the engine layer; charter
287
+ // proposition 9: graceful degradation).
288
+ return ok(handle as unknown as ShaderModule);
289
+ }
290
+ let info: GPUCompilationInfo;
291
+ try {
292
+ info = await handleWithInfo.getCompilationInfo();
293
+ } catch {
294
+ // getCompilationInfo() rejects when the underlying GPU instance is dropped
295
+ // mid-await — the device was destroyed (or the page is tearing down) while
296
+ // the async compilation-info query was in flight. The module handle was
297
+ // already created synchronously above, so there is nothing left to report;
298
+ // returning ok keeps an unobserved teardown rejection from escaping as an
299
+ // unhandled rejection (charter proposition 9: graceful degradation, same
300
+ // degraded path as the missing-getCompilationInfo branch above).
301
+ return ok(handle as unknown as ShaderModule);
302
+ }
303
+ const errors = info.messages.filter((m) => m.type === 'error');
304
+ if (errors.length > 0) {
305
+ return shaderCompileFailed(info.messages);
306
+ }
307
+ return ok(handle as unknown as ShaderModule);
308
+ }
309
+
310
+ /**
311
+ * Build a RhiAdapter shim around a raw GPUAdapter (M3 / break-point #2 / K-5 +
312
+ * K-6).
313
+ *
314
+ * Field projection:
315
+ * - `features` <- `new Set([...adapter.features])` (Round 3 fix-up F-P1-2:
316
+ * surfaces a `ReadonlySet<GPUFeatureName>` aligned with
317
+ * `RhiDevice.features`, so AI users use `.has(name)` uniformly across
318
+ * both abstraction layers; previously projected to `ReadonlyArray<string>`
319
+ * which split the cross-tier idiom).
320
+ * - `limits` <- structural copy of `adapter.limits` numeric fields. Spec
321
+ * `GPUSupportedLimits` is a typed object whose enumerable own-keys are
322
+ * exactly the limit fields (research §6.3 + spec normative); the shim
323
+ * surfaces them via `Readonly<Record<string, number>>` for ergonomic AI-
324
+ * user lookups (`adapter.limits.maxTextureDimension2D`).
325
+ * - `requestDevice` forwards to `adapter.requestDevice(opts)` and wraps the
326
+ * resulting `GPUDevice` via `makeRhiDevice` (research §F-5 error paths).
327
+ */
328
+ function makeRhiAdapter(rawAdapter: {
329
+ readonly features?: GPUSupportedFeatures | undefined;
330
+ readonly limits?: GPUSupportedLimits | undefined;
331
+ // forgeax-async-whitelist: dom-native — spec `GPUAdapter.requestDevice()` raw forward
332
+ requestDevice(descriptor?: GPUDeviceDescriptor | undefined): Promise<unknown>;
333
+ }): RhiAdapter {
334
+ // Defensive against minimal mock adapters that omit features / limits
335
+ // (the spec mandates them on real adapters, but unit-test mocks
336
+ // historically expose only `requestDevice`). Defaults to empty
337
+ // projections; real adapters always supply both fields.
338
+ const rawFeatures = rawAdapter.features as unknown as ReadonlySet<GPUFeatureName> | undefined;
339
+ const features: ReadonlySet<GPUFeatureName> =
340
+ rawFeatures !== undefined && rawFeatures !== null
341
+ ? new Set(rawFeatures)
342
+ : new Set<GPUFeatureName>();
343
+ const limitsRaw = (rawAdapter.limits as unknown as Record<string, unknown> | undefined) ?? {};
344
+ const limits: Record<string, number> = {};
345
+ for (const key in limitsRaw) {
346
+ const v = limitsRaw[key];
347
+ if (typeof v === 'number') {
348
+ limits[key] = v;
349
+ }
350
+ }
351
+ return {
352
+ features,
353
+ limits: limits as Readonly<Record<string, number>>,
354
+ async requestDevice(
355
+ opts?: ForgeaXRequestDeviceOptions | undefined,
356
+ ): Promise<Result<RhiDevice, RhiError>> {
357
+ let rawDevice: GpuDeviceLike;
358
+ try {
359
+ rawDevice = (await rawAdapter.requestDevice(
360
+ opts as GPUDeviceDescriptor | undefined,
361
+ )) as GpuDeviceLike;
362
+ } catch (e) {
363
+ return classifyRequestDeviceError(e);
364
+ }
365
+ const { device } = makeRhiDevice(rawDevice as unknown as GPUDevice);
366
+ return ok(device);
367
+ },
368
+ };
369
+ }
370
+
371
+ /**
372
+ * Entry — `requestAdapter` walks `navigator.gpu.requestAdapter(opts)` and
373
+ * wraps the result as a forgeax `RhiAdapter` (M3 break-point #2; K-5 + K-6).
374
+ *
375
+ * Strict two-step path mirrors wgpu / Dawn (research §6); the legacy
376
+ * `requestDevice(opts)` factory below is the deprecated single-step shortcut
377
+ * kept for backward compatibility while existing callers migrate.
378
+ *
379
+ * Optional `gpu` provider seam at the `RequestAdapterOptions`-side: this
380
+ * factory takes only the forgeax-spec `RequestAdapterOptions` (powerPreference
381
+ * / forceFallbackAdapter); when callers need the mock-injection seam they go
382
+ * through the legacy `requestDevice({ gpu })` form.
383
+ *
384
+ * @param opts — W3C-spec request adapter options.
385
+ * @param _compatibleSurface — accepted for dual-impl symmetry
386
+ * (plan-strategy D-5; AGENTS.md "Dual-impl ship-together" rule).
387
+ * The browser WebGPU backend does not use this parameter; it is
388
+ * ignored. rhi-wgpu routes it to `requestAdapterWithCanvas`.
389
+ */
390
+ export async function requestAdapter(
391
+ opts?: RequestAdapterOptions | undefined,
392
+ _compatibleSurface?: HTMLCanvasElement | OffscreenCanvas | undefined,
393
+ ): Promise<Result<RhiAdapter, RhiError>> {
394
+ const ambient: GPU | undefined =
395
+ typeof globalThis !== 'undefined'
396
+ ? (globalThis as { navigator?: Navigator }).navigator?.gpu
397
+ : undefined;
398
+ if (ambient === undefined || ambient === null) {
399
+ return adapterUnavailable();
400
+ }
401
+ // bug-20260610: `navigator.gpu.requestAdapter()` may throw rather than return
402
+ // null when WebGPU is disabled at the browser level (observed on Edge with
403
+ // WebGPU flag off — "Failed to create WebGPU Context Provider"). Without this
404
+ // try/catch, the throw escapes structurally — engine `tryCreateWebGPURenderer`
405
+ // catches it as outcome=throw with a raw Error (no `.code`), and the rhi-wgpu
406
+ // wasm GL fallback never gets a chance to run on its own merits because the
407
+ // engine sees an unstructured failure. Treat any throw as adapter-unavailable
408
+ // so the structured fallback path (Channel 2 -> Channel 3) stays intact.
409
+ let adapter: unknown;
410
+ try {
411
+ adapter = await ambient.requestAdapter(opts as GPURequestAdapterOptions | undefined);
412
+ } catch {
413
+ return adapterUnavailable();
414
+ }
415
+ if (adapter === null) {
416
+ return adapterUnavailable();
417
+ }
418
+ return ok(
419
+ makeRhiAdapter(
420
+ adapter as unknown as {
421
+ readonly features?: GPUSupportedFeatures | undefined;
422
+ readonly limits?: GPUSupportedLimits | undefined;
423
+ // forgeax-async-whitelist: dom-native — spec `GPUAdapter.requestDevice()` cast
424
+ requestDevice(descriptor?: GPUDeviceDescriptor | undefined): Promise<unknown>;
425
+ },
426
+ ),
427
+ );
428
+ }
429
+
430
+ /**
431
+ * Acquire a canvas rendering context from an HTMLCanvasElement (M3 / w15).
432
+ *
433
+ * Spec anchor: W3C WebGPU §3.3 GPUCanvasContext.
434
+ *
435
+ * Internally calls `canvas.getContext('webgpu')` and wraps the result as a
436
+ * branded RhiCanvasContext. Returns `Result<RhiCanvasContext, RhiError>` —
437
+ * canvas does not support WebGPU returns `RhiError { code: 'rhi-not-available' }`
438
+ * with a precise `.hint` so AI users can display a degradation banner (charter
439
+ * proposition 4 explicit failure).
440
+ *
441
+ * This replaces the legacy `createCanvasContext(rawCtx)` — AGENTS.md §Change stance
442
+ * authorizes the breaking rename (acquireCanvasContext, optimal > compatible).
443
+ *
444
+ * @example
445
+ * const ctxResult = acquireCanvasContext(canvas);
446
+ * if (!ctxResult.ok) {
447
+ * // canvas does not support WebGPU
448
+ * return;
449
+ * }
450
+ * const canvasContext = ctxResult.value;
451
+ * canvasContext.configure({ device, format: 'bgra8unorm', usage: 0x10 });
452
+ */
453
+ export function acquireCanvasContext(
454
+ canvas: HTMLCanvasElement | OffscreenCanvas,
455
+ ): Result<RhiCanvasContext, RhiError> {
456
+ let rawContext: GPUCanvasContext | null;
457
+ try {
458
+ rawContext = canvas.getContext('webgpu') as GPUCanvasContext | null;
459
+ } catch {
460
+ rawContext = null;
461
+ }
462
+ if (rawContext === null) {
463
+ return err(
464
+ new RhiErrorClass({
465
+ code: 'rhi-not-available',
466
+ expected: 'canvas.getContext("webgpu") to return a non-null GPUCanvasContext',
467
+ hint: 'canvas does not support WebGPU — pass an HTMLCanvasElement (or OffscreenCanvas) whose getContext("webgpu") returns a valid GPUCanvasContext',
468
+ }),
469
+ );
470
+ }
471
+ return ok(makeCanvasContext(rawContext));
472
+ }
473
+
474
+ /**
475
+ * The `rhi` singleton entry (charter proposition 1: progressive disclosure +
476
+ * plan-strategy §7.4 discoverability:
477
+ * 'import { rhi } from @forgeax/engine-rhi-webgpu' + 'Engine.create({ rhi, canvas })'
478
+ * injection shape).
479
+ *
480
+ * Strict two-step path (M3 break-point #2 + M6 fix-up [w51]; K-5 + K-6):
481
+ * `rhi.requestAdapter(opts) -> adapter.requestDevice(opts)`
482
+ *
483
+ * The legacy single-step `rhi.requestDevice(opts)` factory was retired here
484
+ * (AGENTS.md break-point list 2026-05-10 #2). AI users follow the spec
485
+ * idiom (charter proposition 5 consistent abstraction red line):
486
+ * const adapter = (await rhi.requestAdapter()).unwrap();
487
+ * const device = (await adapter.requestDevice(opts)).unwrap();
488
+ */
489
+ export const rhi: RhiInstance & {
490
+ createShaderModule: typeof createShaderModule;
491
+ acquireCanvasContext: typeof acquireCanvasContext;
492
+ } = {
493
+ requestAdapter,
494
+ createShaderModule,
495
+ acquireCanvasContext,
496
+ };
497
+
498
+ // Re-export public types so callers consume them through a single entry
499
+ // (charter proposition 1: progressive disclosure / single import surfaces
500
+ // the full RHI consumption chain). Round 3 fix-up F-P2-1: extended from
501
+ // 3 to the full set so AI users do not need a transitive
502
+ // `import { RhiAdapter, ... } from '@forgeax/engine-rhi'` alongside the
503
+ // rhi-webgpu singleton.
504
+ export type {
505
+ // 14 opaque handles (charter proposition 5 consistent abstraction:
506
+ // brand-only surface; AI users hold these for cross-call resource flow).
507
+ BindGroup,
508
+ // 14 descriptors (Pick<GPU*Descriptor, ...> mirrors; AI users compose
509
+ // these inline before calling device.createX).
510
+ BindGroupDescriptor,
511
+ BindGroupLayout,
512
+ BindGroupLayoutDescriptor,
513
+ Buffer,
514
+ BufferDescriptor,
515
+ CanvasConfiguration,
516
+ CommandBuffer,
517
+ CommandEncoderDescriptor,
518
+ ComputePassDescriptor,
519
+ ComputePassTimestampWrites,
520
+ ComputePipeline,
521
+ ComputePipelineDescriptor,
522
+ Fence,
523
+ PipelineLayout,
524
+ PipelineLayoutDescriptor,
525
+ QuerySet,
526
+ QuerySetDescriptor,
527
+ RenderPassColorAttachment,
528
+ RenderPassDepthStencilAttachment,
529
+ RenderPassDescriptor,
530
+ RenderPipeline,
531
+ RenderPipelineDescriptor,
532
+ RequestAdapterOptions,
533
+ RequestDeviceOptions as RhiRequestDeviceOptions,
534
+ // Result + error model (RhiError class is exported as a value below).
535
+ Result,
536
+ ResultErr,
537
+ ResultOk,
538
+ // Adapter / Device / Instance / surface (M2-M3 main interfaces).
539
+ RhiAdapter,
540
+ RhiAssetNotRegisteredDetail,
541
+ RhiCanvasContext,
542
+ RhiCaps,
543
+ // Command / pass encoder interfaces (M3-M4).
544
+ RhiCommandEncoder,
545
+ RhiComputePassEncoder,
546
+ RhiDevice,
547
+ RhiError,
548
+ RhiErrorCode,
549
+ RhiErrorDetail,
550
+ RhiFeatures,
551
+ RhiInstance,
552
+ RhiLimits,
553
+ RhiQueue,
554
+ RhiRenderPassEncoder,
555
+ RhiShaderCompileDetail,
556
+ RhiSurface,
557
+ RhiWebgpuRuntimeDetail,
558
+ Sampler,
559
+ SamplerDescriptor,
560
+ ShaderModule,
561
+ Texture,
562
+ TextureDescriptor,
563
+ TextureView,
564
+ TextureViewDescriptor,
565
+ } from '@forgeax/engine-rhi';
566
+
567
+ // Re-export Result factories + RhiError class as values (the rhi-webgpu
568
+ // top-level surface stays single-import for both type-only and runtime
569
+ // consumption).
570
+ export { err, ok, RhiError as RhiErrorClass } from '@forgeax/engine-rhi';
571
+ // feat-20260511-rhi-spec-realign-aggressive D-VD2 (Round 2) — re-export the
572
+ // `_internal_getRawDevice` reverse lookup. Re-introduces the M4-removed escape
573
+ // hatch for the sole engine consumer that needs to register the spec
574
+ // `onuncapturederror` listener on the raw GPUDevice:
575
+ // `packages/engine/src/createRenderer.ts`. The listener registration is the
576
+ // only browser-WebGPU path through which `Renderer.onError(err =>
577
+ // switch err.code { case 'oom' | 'internal-error' | 'shader-compile-failed':
578
+ // ... })` can fire (AGENTS.md break-point #4 promise; spec GPUDevice extends
579
+ // EventTarget and exposes `onuncapturederror` as a settable property).
580
+ //
581
+ // AC-08 grep gate (`apps/hello/triangle/scripts/ac-08-grep-gate.mjs`) allows
582
+ // engine-internal usage of `_internal_getRawDevice` through the existing
583
+ // `\bgetRawDevice\b` word-boundary allowlist; consumers outside the engine
584
+ // layer remain forbidden (charter proposition 5 consistent abstraction red
585
+ // line: the RHI surface stays brand-only for AI-user-facing code; the reverse
586
+ // lookup is the named single-point escape hatch the engine uses to satisfy
587
+ // spec event-target requirements).
588
+ export { _internal_getRawDevice } from './device';
589
+ // feat-20260511-rhi-spec-realign-aggressive D-VD2 wire-up: re-export the
590
+ // async-dispatch event translator at the top level so the engine layer
591
+ // (packages/engine/src/createRenderer.ts) can register the spec
592
+ // `device.onuncapturederror` listener + the dual-channel `device.lost`
593
+ // fan-out without reaching into `internal/`. AGENTS.md break-point #4 dispatch
594
+ // path promise: rhi-webgpu/src/internal/error-translation.ts translates
595
+ // GPUUncapturedErrorEvent / GPUDeviceLostInfo to the 17-member RhiErrorCode
596
+ // union; engine.onError captures the dispatch (charter proposition 4
597
+ // explicit failure + proposition 5 consistent abstraction across both
598
+ // browser-direct and rhi-wgpu wasm dual paths).
599
+ export { translateErrorEventToRhiError } from './internal/error-translation';
@@ -0,0 +1,88 @@
1
+ import { RhiError } from '@forgeax/engine-rhi';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createMockGpu } from '../../__tests__/__mocks__/gpu-device';
4
+ import { makeRhiDevice } from '../../device';
5
+
6
+ async function timestampEncoder(writeTimestamp?: (querySet: unknown, queryIndex: number) => void) {
7
+ const gpu = createMockGpu();
8
+ const adapter = await gpu.requestAdapter();
9
+ if (adapter === null) throw new Error('mock adapter should exist');
10
+ const raw = await adapter.requestDevice();
11
+ const features = raw.features as unknown as Set<GPUFeatureName>;
12
+ features.add('timestamp-query');
13
+ const originalCreateCommandEncoder = raw.createCommandEncoder.bind(raw);
14
+ raw.createCommandEncoder = (descriptor) => {
15
+ const encoder = originalCreateCommandEncoder(descriptor) as unknown as Record<string, unknown>;
16
+ if (writeTimestamp !== undefined) encoder.writeTimestamp = writeTimestamp;
17
+ return encoder as unknown as ReturnType<typeof raw.createCommandEncoder>;
18
+ };
19
+ const { device } = makeRhiDevice(raw as unknown as GPUDevice);
20
+ const querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
21
+ if (!querySet.ok) throw new Error('timestamp query set should be created');
22
+ const encoder = device.createCommandEncoder();
23
+ if (!encoder.ok) throw new Error('command encoder should be created');
24
+ return { encoder: encoder.value, querySet: querySet.value };
25
+ }
26
+
27
+ describe('timestamp query raw write seam', () => {
28
+ it('forwards a callable raw writeTimestamp exactly once', async () => {
29
+ const calls: Array<{ querySet: unknown; queryIndex: number }> = [];
30
+ const { encoder, querySet } = await timestampEncoder((rawQuerySet, queryIndex) => {
31
+ calls.push({ querySet: rawQuerySet, queryIndex });
32
+ });
33
+
34
+ encoder.writeTimestamp(querySet, 1);
35
+
36
+ expect(calls).toHaveLength(1);
37
+ expect(calls[0]?.querySet).toBeDefined();
38
+ expect(calls[0]?.queryIndex).toBe(1);
39
+ });
40
+
41
+ it('returns a structured refusal when capability-positive raw writeTimestamp is missing', async () => {
42
+ const { encoder, querySet } = await timestampEncoder();
43
+
44
+ expect(() => encoder.writeTimestamp(querySet, 0)).toThrow(RhiError);
45
+ try {
46
+ encoder.writeTimestamp(querySet, 0);
47
+ } catch (error) {
48
+ expect(error).toMatchObject({
49
+ code: 'webgpu-runtime-error',
50
+ expected: 'underlying GPUCommandEncoder.writeTimestamp to be callable',
51
+ });
52
+ expect((error as RhiError).hint).toContain('timestamp-query');
53
+ }
54
+ });
55
+
56
+ it('returns a structured refusal when raw writeTimestamp throws', async () => {
57
+ const { encoder, querySet } = await timestampEncoder(() => {
58
+ throw new Error('raw timestamp failure');
59
+ });
60
+
61
+ expect(() => encoder.writeTimestamp(querySet, 0)).toThrow(RhiError);
62
+ try {
63
+ encoder.writeTimestamp(querySet, 0);
64
+ } catch (error) {
65
+ expect(error).toMatchObject({
66
+ code: 'webgpu-runtime-error',
67
+ expected: 'underlying GPUCommandEncoder.writeTimestamp to succeed',
68
+ });
69
+ expect((error as RhiError).hint).toContain('raw timestamp failure');
70
+ }
71
+ });
72
+
73
+ it('refuses timestamp writes after encoder.finish with a lifecycle error', async () => {
74
+ const { encoder, querySet } = await timestampEncoder(() => {});
75
+ const finish = encoder.finish();
76
+ expect(finish.ok).toBe(true);
77
+
78
+ expect(() => encoder.writeTimestamp(querySet, 0)).toThrow(RhiError);
79
+ try {
80
+ encoder.writeTimestamp(querySet, 0);
81
+ } catch (error) {
82
+ expect(error).toMatchObject({
83
+ code: 'command-encoder-finished',
84
+ expected: 'command encoder must not be finished before recording new commands',
85
+ });
86
+ }
87
+ });
88
+ });