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