@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.
- package/LICENSE +202 -0
- package/README.md +124 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/__mocks__/gpu-device.d.ts +233 -0
- package/dist/__tests__/__mocks__/gpu-device.d.ts.map +1 -0
- package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts +2 -0
- package/dist/__tests__/dawn-real-gpu.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/queue-write-range.browser.test.d.ts +2 -0
- package/dist/__tests__/queue-write-range.browser.test.d.ts.map +1 -0
- package/dist/__tests__/rgba16float-live-probe.browser.test.d.ts +2 -0
- package/dist/__tests__/rgba16float-live-probe.browser.test.d.ts.map +1 -0
- package/dist/__tests__/rgba16float-live-probe.d.ts +12 -0
- package/dist/__tests__/rgba16float-live-probe.d.ts.map +1 -0
- package/dist/__tests__/rgba16float-live-probe.dawn.test.d.ts +2 -0
- package/dist/__tests__/rgba16float-live-probe.dawn.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-webgpu.unit.test.d.ts +2 -0
- package/dist/__tests__/rhi-webgpu.unit.test.d.ts.map +1 -0
- package/dist/device.d.ts +62 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +183 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1704 -0
- package/dist/index.mjs.map +1 -0
- package/dist/internal/__tests__/timestamp-query.unit.test.d.ts +2 -0
- package/dist/internal/__tests__/timestamp-query.unit.test.d.ts.map +1 -0
- package/dist/internal/error-translation.d.ts +16 -0
- package/dist/internal/error-translation.d.ts.map +1 -0
- package/dist/internal/timestamp-query.d.ts +39 -0
- package/dist/internal/timestamp-query.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/__tests__/__mocks__/gpu-device.ts +564 -0
- package/src/__tests__/dawn-real-gpu.dawn.test.ts +1488 -0
- package/src/__tests__/queue-write-range.browser.test.ts +60 -0
- package/src/__tests__/rgba16float-live-probe.browser.test.ts +13 -0
- package/src/__tests__/rgba16float-live-probe.dawn.test.ts +15 -0
- package/src/__tests__/rgba16float-live-probe.ts +44 -0
- package/src/__tests__/rhi-webgpu.unit.test.ts +2409 -0
- package/src/device.ts +1974 -0
- package/src/errors.ts +145 -0
- package/src/index.ts +599 -0
- package/src/internal/__tests__/timestamp-query.unit.test.ts +88 -0
- package/src/internal/error-translation.ts +187 -0
- package/src/internal/timestamp-query.ts +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
|
+
});
|