@forgeax/engine-rhi-webgpu 0.1.6 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-rhi-webgpu",
3
- "version": "0.1.6",
3
+ "version": "0.1.19",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,8 +22,8 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@forgeax/engine-rhi": "0.1.6",
26
- "@forgeax/engine-types": "0.1.6",
25
+ "@forgeax/engine-rhi": "0.1.19",
26
+ "@forgeax/engine-types": "0.1.19",
27
27
  "@webgpu/types": "^0.1.71"
28
28
  },
29
29
  "forgeax": {
@@ -61,6 +61,25 @@ async function requestRhiDevice(): Promise<RhiDevice | undefined> {
61
61
  return deviceResult.value;
62
62
  }
63
63
 
64
+ describe('dawn Environment backend admission', () => {
65
+ it('records unavailable instead of treating RhiNull as real GPU evidence', async () => {
66
+ const adapterResult = await rhi.requestAdapter();
67
+ if (!adapterResult.ok) {
68
+ expect(adapterResult.error.code).toBe('adapter-unavailable');
69
+ expect(adapterResult.error.expected.length).toBeGreaterThan(0);
70
+ expect(adapterResult.error.hint.length).toBeGreaterThan(0);
71
+ reportTimestampEvidence({
72
+ backend: 'webgpu-dawn',
73
+ status: 'unavailable',
74
+ code: adapterResult.error.code,
75
+ });
76
+ return;
77
+ }
78
+ expect(adapterResult.ok).toBe(true);
79
+ reportTimestampEvidence({ backend: 'webgpu-dawn', status: 'available' });
80
+ });
81
+ });
82
+
64
83
  function reportTimestampEvidence(evidence: Record<string, unknown>): void {
65
84
  // biome-ignore lint/suspicious/noConsole: the dawn admission record is the test evidence output
66
85
  console.log(JSON.stringify(evidence));
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ describe('TAA rgba16float browser admission', () => {
4
+ it('records capability as an explicit conjunction', () => {
5
+ const caps = { rgba16floatRender: true, rgba16floatSample: true, mrt: true };
6
+ expect(Object.values(caps).every(Boolean)).toBe(true);
7
+ });
8
+
9
+ it('requires the browser runner identity for a promoted probe', () => {
10
+ const evidence = {
11
+ backend: 'browser-webgpu',
12
+ runner: 'playwright',
13
+ frames: 300,
14
+ status: 'pass',
15
+ };
16
+ expect(evidence.runner).toBe('playwright');
17
+ expect(evidence.frames).toBeGreaterThanOrEqual(300);
18
+ });
19
+ });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ describe('TAA rgba16float Dawn admission', () => {
4
+ it('records unavailable backends instead of treating them as green', () => {
5
+ const evidence = { backend: 'dawn', status: 'unavailable' as const };
6
+ expect(['available', 'unavailable']).toContain(evidence.status);
7
+ expect(evidence.status).not.toBe('pass');
8
+ });
9
+
10
+ it('requires the dawn.node runner and frame budget when available', () => {
11
+ const evidence = {
12
+ backend: 'dawn-node',
13
+ runner: 'dawn.node',
14
+ frames: 300,
15
+ status: 'available',
16
+ };
17
+ expect(evidence.runner).toBe('dawn.node');
18
+ expect(evidence.frames).toBeGreaterThanOrEqual(300);
19
+ });
20
+ });
@@ -20,9 +20,15 @@ import type {
20
20
  RhiCanvasContext,
21
21
  } from '@forgeax/engine-rhi';
22
22
  import { type Result, RhiError, type RhiErrorCode } from '@forgeax/engine-rhi';
23
- import { describe, expect, it } from 'vitest';
23
+ import { afterEach, describe, expect, it, vi } from 'vitest';
24
24
  import { makeRhiDevice } from '../device';
25
- import { acquireCanvasContext, createShaderModule, requestDevice } from '../index';
25
+ import {
26
+ acquireCanvasContext,
27
+ createShaderModule,
28
+ createShaderModuleImmediate,
29
+ requestAdapter,
30
+ requestDevice,
31
+ } from '../index';
26
32
  import { createMockGpu, type MockCapture, makeShaderError } from './__mocks__/gpu-device';
27
33
 
28
34
  {
@@ -1384,6 +1390,8 @@ import { createMockGpu, type MockCapture, makeShaderError } from './__mocks__/gp
1384
1390
  }
1385
1391
 
1386
1392
  describe('AC-10 — 4 error paths .code / .expected / .hint three-field assertions', () => {
1393
+ afterEach(() => vi.unstubAllGlobals());
1394
+
1387
1395
  it('adapter null -> code=adapter-unavailable + three non-empty string fields', async () => {
1388
1396
  const gpu = createMockGpu({ adapterNull: true });
1389
1397
  const e = unwrapErr(await requestDevice({ gpu }));
@@ -1395,6 +1403,27 @@ import { createMockGpu, type MockCapture, makeShaderError } from './__mocks__/gp
1395
1403
  expect(e.hint.length).toBeGreaterThan(0);
1396
1404
  });
1397
1405
 
1406
+ it('requestAdapter throw keeps the original failure instead of claiming adapter-unavailable', async () => {
1407
+ vi.stubGlobal('navigator', {
1408
+ gpu: {
1409
+ requestAdapter: async () => {
1410
+ throw new DOMException('WebGPU permission policy denied access', 'SecurityError');
1411
+ },
1412
+ },
1413
+ });
1414
+
1415
+ const e = unwrapErr(await requestAdapter());
1416
+ expect(e.code).toBe('webgpu-runtime-error');
1417
+ expect(e.hint).toContain('wgpu/WebGL2 fallback');
1418
+ expect(e.detail).toEqual({
1419
+ error: {
1420
+ code: 'request-adapter-threw',
1421
+ name: 'SecurityError',
1422
+ message: 'WebGPU permission policy denied access',
1423
+ },
1424
+ });
1425
+ });
1426
+
1398
1427
  it('feature not enabled -> code=feature-not-enabled + three non-empty string fields', async () => {
1399
1428
  const gpu = createMockGpu({ requestDeviceFeatureNotEnabled: true });
1400
1429
  const e = unwrapErr(await requestDevice({ gpu }));
@@ -1512,6 +1541,24 @@ import { createMockGpu, type MockCapture, makeShaderError } from './__mocks__/gp
1512
1541
  const sr = await createShaderModule(r.value, { code: 'fn main() {}' });
1513
1542
  expect(sr.ok).toBe(true);
1514
1543
  });
1544
+
1545
+ it('immediate render-path entry returns the module without awaiting compilation info', async () => {
1546
+ const gpu = createMockGpu({ getCompilationInfoRejects: true });
1547
+ const r = await requestDevice({ gpu });
1548
+ if (!r.ok) throw new Error('mock requestDevice should not fail');
1549
+
1550
+ const sr = createShaderModuleImmediate(r.value, {
1551
+ label: 'render-fast-path',
1552
+ code: 'fn main() {}',
1553
+ });
1554
+ expect(sr.ok).toBe(true);
1555
+ expect(
1556
+ gpu.__captured.some(
1557
+ (entry) =>
1558
+ entry.kind === 'createShaderModule' && entry.descriptor.label === 'render-fast-path',
1559
+ ),
1560
+ ).toBe(true);
1561
+ });
1515
1562
  });
1516
1563
  }
1517
1564
 
package/src/errors.ts CHANGED
@@ -17,8 +17,46 @@ export function adapterUnavailable(): Result<never, RhiError> {
17
17
  return err(
18
18
  new RhiError({
19
19
  code: 'adapter-unavailable',
20
- expected: 'an available WebGPU adapter',
21
- hint: 'check whether the browser supports WebGPU or enable the relevant flag',
20
+ expected: 'an available browser-native WebGPU adapter',
21
+ hint: 'this only reports the browser-native WebGPU channel; ForgeaX may continue through its wgpu/WebGL2 fallback, so do not conclude that the browser or machine is unsupported unless both backend causes fail',
22
+ }),
23
+ );
24
+ }
25
+
26
+ /** `GPU.requestAdapter()` rejected instead of reporting adapter absence with `null`. */
27
+ export function requestAdapterFailed(cause: unknown): Result<never, RhiError> {
28
+ const record =
29
+ cause !== null && typeof cause === 'object'
30
+ ? (cause as { readonly name?: unknown; readonly message?: unknown })
31
+ : undefined;
32
+ const name = typeof record?.name === 'string' && record.name.length > 0 ? record.name : undefined;
33
+ let message =
34
+ typeof record?.message === 'string' && record.message.length > 0
35
+ ? record.message
36
+ : typeof cause === 'string'
37
+ ? cause
38
+ : '';
39
+ if (message.length === 0 && cause !== undefined) {
40
+ try {
41
+ const serialized = JSON.stringify(cause);
42
+ message = serialized && serialized !== '{}' ? serialized : 'unknown thrown object';
43
+ } catch {
44
+ message = 'unserializable thrown object';
45
+ }
46
+ }
47
+ if (message.length === 0) message = 'unknown requestAdapter failure';
48
+ return err(
49
+ new RhiError({
50
+ code: 'webgpu-runtime-error',
51
+ expected: 'navigator.gpu.requestAdapter() resolves with an adapter or null',
52
+ hint: 'inspect detail.error before assigning the failure to WebGPU capability; the ForgeaX runtime can still attempt its wgpu/WebGL2 fallback',
53
+ detail: {
54
+ error: {
55
+ code: 'request-adapter-threw',
56
+ ...(name === undefined ? {} : { name }),
57
+ message,
58
+ },
59
+ },
22
60
  }),
23
61
  );
24
62
  }
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  adapterUnavailable,
57
57
  featureNotEnabled,
58
58
  limitExceeded,
59
+ requestAdapterFailed,
59
60
  shaderCompileFailed,
60
61
  } from './errors';
61
62
 
@@ -204,45 +205,16 @@ export async function requestDevice(
204
205
  return ok(device);
205
206
  }
206
207
 
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(
208
+ function createRawShaderModule(
233
209
  device: RhiDevice,
234
210
  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).
211
+ ): Result<GPUShaderModule, RhiError> {
212
+ // In-package reverse lookup of the underlying GPUDevice. The raw handle is
213
+ // intentionally kept behind this package boundary; render assembly may use
214
+ // this synchronous step, while public callers use the diagnostic-rich async
215
+ // entry below.
240
216
  const rawDevice = _internal_getRawDevice(device);
241
217
  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
218
  return shaderCompileFailed([
247
219
  {
248
220
  type: 'error',
@@ -256,14 +228,12 @@ export async function createShaderModule(
256
228
  }
257
229
  const mirrored: { label?: string; code: string } = { code: desc.code };
258
230
  if ('label' in desc && desc.label !== undefined) mirrored.label = desc.label;
259
- let handle: GPUShaderModule;
260
231
  try {
261
- handle = rawDevice.createShaderModule(mirrored as GPUShaderModuleDescriptor);
232
+ return ok(rawDevice.createShaderModule(mirrored as GPUShaderModuleDescriptor));
262
233
  } catch (e) {
263
234
  // The synchronous part of a real-device createShaderModule rarely throws
264
235
  // (spec: errors are surfaced asynchronously through getCompilationInfo);
265
- // a few mock shapes might throw — fall back to the
266
- // shader-compile-failed path.
236
+ // a few mock shapes might throw — preserve the public structured error.
267
237
  const message = e instanceof Error ? e.message : String(e);
268
238
  return shaderCompileFailed([
269
239
  {
@@ -276,6 +246,40 @@ export async function createShaderModule(
276
246
  } as GPUCompilationMessage,
277
247
  ]);
278
248
  }
249
+ }
250
+
251
+ /**
252
+ * Entry 2 - async `createShaderModule`. The shader-compile-failed path
253
+ * forwards every 6 fields of `GPUCompilationMessage` to
254
+ * `RhiError.detail.compilerMessages` (OQ-P2 / F-3 finding).
255
+ *
256
+ * Implementation (post fix-f3):
257
+ * 1) Look up the underlying `GPUDevice` via the in-package
258
+ * `_internal_getRawDevice` (RAW_DEVICE_MAP reverse lookup; same module).
259
+ * 2) `rawDevice.createShaderModule(desc)` calls the spec entry to obtain
260
+ * a `GPUShaderModule`.
261
+ * 3) `await module.getCompilationInfo()` retrieves compilation info.
262
+ * 4) If any message has `type === 'error'`, return
263
+ * `Result.err(RhiError { code: 'shader-compile-failed',
264
+ * detail: { compilerMessages } })`.
265
+ * 5) Otherwise return `Result.ok(module as ShaderModule)`.
266
+ *
267
+ * Note: this entry accepts a shim-wrapped RhiDevice (not a raw GPUDevice)
268
+ * to keep the public API single-source; the in-package
269
+ * `_internal_getRawDevice` is the only sanctioned reverse lookup.
270
+ *
271
+ * fix-f3: the synchronous `RhiDevice.createShaderModule` placeholder is
272
+ * removed; the shader-compile-failed path closes inside this async entry
273
+ * (charter proposition 5 consistent abstraction + proposition 4 explicit
274
+ * failure).
275
+ */
276
+ export async function createShaderModule(
277
+ device: RhiDevice,
278
+ desc: { label?: string | undefined; code: string },
279
+ ): Promise<Result<ShaderModule, RhiError>> {
280
+ const rawResult = createRawShaderModule(device, desc);
281
+ if (!rawResult.ok) return rawResult;
282
+ const handle = rawResult.value;
279
283
  const handleWithInfo = handle as GPUShaderModule & {
280
284
  // forgeax-async-whitelist: dom-native — spec `GPUShaderModule.getCompilationInfo()`
281
285
  getCompilationInfo?: () => Promise<GPUCompilationInfo>;
@@ -302,11 +306,43 @@ export async function createShaderModule(
302
306
  }
303
307
  const errors = info.messages.filter((m) => m.type === 'error');
304
308
  if (errors.length > 0) {
305
- return shaderCompileFailed(info.messages);
309
+ // Keep the public six-field GPUCompilationMessage shape intact while
310
+ // making browser failures attributable to the shader producer. Dawn's
311
+ // aggregate output is otherwise indistinguishable when several modules
312
+ // are created during renderer bootstrap.
313
+ const sourcePrefix =
314
+ desc.label === undefined ? '' : ` source=${JSON.stringify(desc.code.slice(0, 24))}`;
315
+ const labeledMessages = info.messages.map((message) => ({
316
+ message:
317
+ desc.label === undefined
318
+ ? message.message
319
+ : `[${desc.label}]${sourcePrefix} ${message.message}`,
320
+ type: message.type,
321
+ lineNum: message.lineNum,
322
+ linePos: message.linePos,
323
+ offset: message.offset,
324
+ length: message.length,
325
+ })) as GPUCompilationMessage[];
326
+ return shaderCompileFailed(labeledMessages);
306
327
  }
307
328
  return ok(handle as unknown as ShaderModule);
308
329
  }
309
330
 
331
+ /**
332
+ * Internal render-path shader creation. WebGPU returns a module handle
333
+ * synchronously; compiler diagnostics remain owned by the public async
334
+ * `createShaderModule` entry. Render pipeline creation is the validation point
335
+ * for this path, so software adapters do not serialize their first frame on
336
+ * `getCompilationInfo()` for every material.
337
+ */
338
+ export function createShaderModuleImmediate(
339
+ device: RhiDevice,
340
+ desc: { label?: string | undefined; code: string },
341
+ ): Result<ShaderModule, RhiError> {
342
+ const result = createRawShaderModule(device, desc);
343
+ return result.ok ? ok(result.value as unknown as ShaderModule) : result;
344
+ }
345
+
310
346
  /**
311
347
  * Build a RhiAdapter shim around a raw GPUAdapter (M3 / break-point #2 / K-5 +
312
348
  * K-6).
@@ -398,19 +434,16 @@ export async function requestAdapter(
398
434
  if (ambient === undefined || ambient === null) {
399
435
  return adapterUnavailable();
400
436
  }
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.
437
+ // `null` is the spec adapter-absence channel. A rejection is a different
438
+ // failure (permission policy, insecure context, browser/runtime fault, etc.)
439
+ // and must retain its cause instead of being rewritten as unsupported WebGPU.
440
+ // Both outcomes remain structured, so Runtime can still try its wgpu/WebGL2
441
+ // fallback without erasing the browser-native channel diagnosis.
409
442
  let adapter: unknown;
410
443
  try {
411
444
  adapter = await ambient.requestAdapter(opts as GPURequestAdapterOptions | undefined);
412
- } catch {
413
- return adapterUnavailable();
445
+ } catch (cause) {
446
+ return requestAdapterFailed(cause);
414
447
  }
415
448
  if (adapter === null) {
416
449
  return adapterUnavailable();
@@ -488,10 +521,12 @@ export function acquireCanvasContext(
488
521
  */
489
522
  export const rhi: RhiInstance & {
490
523
  createShaderModule: typeof createShaderModule;
524
+ createShaderModuleImmediate: typeof createShaderModuleImmediate;
491
525
  acquireCanvasContext: typeof acquireCanvasContext;
492
526
  } = {
493
527
  requestAdapter,
494
528
  createShaderModule,
529
+ createShaderModuleImmediate,
495
530
  acquireCanvasContext,
496
531
  };
497
532