@forgeax/engine-rhi-webgpu 0.1.21 → 0.1.24
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/README.md +11 -4
- package/dist/__tests__/r32float-capability-generation.integration.test.d.ts +2 -0
- package/dist/__tests__/r32float-capability-generation.integration.test.d.ts.map +1 -0
- package/dist/__tests__/r32float-format-capability.browser.test.d.ts +2 -0
- package/dist/__tests__/r32float-format-capability.browser.test.d.ts.map +1 -0
- package/dist/__tests__/r32float-format-capability.dawn.test.d.ts +2 -0
- package/dist/__tests__/r32float-format-capability.dawn.test.d.ts.map +1 -0
- package/dist/device.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +171 -3
- package/dist/index.mjs.map +1 -1
- 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/r32float-capability.d.ts +6 -0
- package/dist/internal/r32float-capability.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/dawn-real-gpu.dawn.test.ts +168 -0
- package/src/__tests__/r32float-capability-generation.integration.test.ts +35 -0
- package/src/__tests__/r32float-format-capability.browser.test.ts +22 -0
- package/src/__tests__/r32float-format-capability.dawn.test.ts +31 -0
- package/src/__tests__/rhi-webgpu.unit.test.ts +135 -0
- package/src/device.ts +27 -0
- package/src/index.ts +44 -3
- package/src/internal/__tests__/timestamp-query.unit.test.ts +120 -0
- package/src/internal/r32float-capability.ts +153 -0
- package/dist/.tsbuildinfo +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timestamp-query.unit.test.d.ts","sourceRoot":"","sources":["../../../src/internal/__tests__/timestamp-query.unit.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Result, RhiError, type RhiTextureFormatCapabilityReceipt } from '@forgeax/engine-rhi';
|
|
2
|
+
type RawDevice = GPUDevice;
|
|
3
|
+
/** Execute the complete real-device r32float profile without exposing raw handles. */
|
|
4
|
+
export declare function probeR32FloatCapability(rawDevice: RawDevice, deviceGeneration: number): Promise<Result<RhiTextureFormatCapabilityReceipt, RhiError>>;
|
|
5
|
+
export {};
|
|
6
|
+
//# sourceMappingURL=r32float-capability.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r32float-capability.d.ts","sourceRoot":"","sources":["../../src/internal/r32float-capability.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,MAAM,EACX,QAAQ,EACR,KAAK,iCAAiC,EAGvC,MAAM,qBAAqB,CAAC;AAS7B,KAAK,SAAS,GAAG,SAAS,CAAC;AAE3B,sFAAsF;AACtF,wBAAsB,uBAAuB,CAC3C,SAAS,EAAE,SAAS,EACpB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,MAAM,CAAC,iCAAiC,EAAE,QAAQ,CAAC,CAAC,CA+H9D"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/engine-rhi-webgpu",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
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.
|
|
26
|
-
"@forgeax/engine-types": "0.1.
|
|
25
|
+
"@forgeax/engine-rhi": "0.1.24",
|
|
26
|
+
"@forgeax/engine-types": "0.1.24",
|
|
27
27
|
"@webgpu/types": "^0.1.71"
|
|
28
28
|
},
|
|
29
29
|
"forgeax": {
|
|
@@ -1276,6 +1276,20 @@ describe('w3 dawn-real-gpu timestamp admission path', () => {
|
|
|
1276
1276
|
if (!deviceResult.ok) return;
|
|
1277
1277
|
const device = deviceResult.value;
|
|
1278
1278
|
|
|
1279
|
+
const { createShaderModule } = await import('../index');
|
|
1280
|
+
const shaderResult = await createShaderModule(device, {
|
|
1281
|
+
code: '@compute @workgroup_size(1) fn timestamp_probe() {}',
|
|
1282
|
+
});
|
|
1283
|
+
expect(shaderResult.ok).toBe(true);
|
|
1284
|
+
if (!shaderResult.ok) return;
|
|
1285
|
+
const pipelineResult = device.createComputePipeline({
|
|
1286
|
+
label: 'w3-timestamp-compute-pipeline',
|
|
1287
|
+
layout: 'auto',
|
|
1288
|
+
compute: { module: shaderResult.value, entryPoint: 'timestamp_probe' },
|
|
1289
|
+
});
|
|
1290
|
+
expect(pipelineResult.ok).toBe(true);
|
|
1291
|
+
if (!pipelineResult.ok) return;
|
|
1292
|
+
|
|
1279
1293
|
const querySetResult = device.createQuerySet({ type: 'timestamp', count: 2 });
|
|
1280
1294
|
expect(querySetResult.ok).toBe(true);
|
|
1281
1295
|
if (!querySetResult.ok) return;
|
|
@@ -1310,6 +1324,8 @@ describe('w3 dawn-real-gpu timestamp admission path', () => {
|
|
|
1310
1324
|
endOfPassWriteIndex: 1,
|
|
1311
1325
|
},
|
|
1312
1326
|
});
|
|
1327
|
+
pass.setPipeline(pipelineResult.value);
|
|
1328
|
+
pass.dispatchWorkgroups(1);
|
|
1313
1329
|
pass.end();
|
|
1314
1330
|
} catch (error) {
|
|
1315
1331
|
const refusal = error as { code?: string; expected?: string; hint?: string };
|
|
@@ -1381,6 +1397,158 @@ describe('w3 dawn-real-gpu timestamp admission path', () => {
|
|
|
1381
1397
|
expect(end).toBeGreaterThan(begin);
|
|
1382
1398
|
mappedResult.value.unmap();
|
|
1383
1399
|
});
|
|
1400
|
+
|
|
1401
|
+
it('runs render-pass timestampWrites -> resolve -> submit -> readback or records a structured refusal', async () => {
|
|
1402
|
+
const selector = 'standard' as const;
|
|
1403
|
+
const source = 'packages/rhi-webgpu/src/__tests__/dawn-real-gpu.dawn.test.ts';
|
|
1404
|
+
const adapterResult = await rhi.requestAdapter();
|
|
1405
|
+
expect(adapterResult.ok).toBe(true);
|
|
1406
|
+
if (!adapterResult.ok) return;
|
|
1407
|
+
|
|
1408
|
+
if (!adapterResult.value.features.has('timestamp-query')) {
|
|
1409
|
+
reportTimestampEvidence({
|
|
1410
|
+
carrier: 'dawn-node',
|
|
1411
|
+
selector,
|
|
1412
|
+
source,
|
|
1413
|
+
acceptedGpu: 0,
|
|
1414
|
+
refusalCode: 'timestamp-query-unsupported',
|
|
1415
|
+
expected: "adapter.features.has('timestamp-query')",
|
|
1416
|
+
hint: 'request a real Dawn device with the timestamp-query feature enabled',
|
|
1417
|
+
});
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const deviceResult = await adapterResult.value.requestDevice({
|
|
1422
|
+
requiredFeatures: ['timestamp-query'],
|
|
1423
|
+
});
|
|
1424
|
+
expect(deviceResult.ok).toBe(true);
|
|
1425
|
+
if (!deviceResult.ok) return;
|
|
1426
|
+
const device = deviceResult.value;
|
|
1427
|
+
|
|
1428
|
+
const querySetResult = device.createQuerySet({ type: 'timestamp', count: 2 });
|
|
1429
|
+
expect(querySetResult.ok).toBe(true);
|
|
1430
|
+
if (!querySetResult.ok) return;
|
|
1431
|
+
|
|
1432
|
+
const targetResult = device.createTexture({
|
|
1433
|
+
label: 'w3-timestamp-raster-target',
|
|
1434
|
+
size: { width: 1, height: 1, depthOrArrayLayers: 1 },
|
|
1435
|
+
format: 'rgba8unorm',
|
|
1436
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1437
|
+
});
|
|
1438
|
+
expect(targetResult.ok).toBe(true);
|
|
1439
|
+
if (!targetResult.ok) return;
|
|
1440
|
+
const viewResult = device.createTextureView(targetResult.value, {});
|
|
1441
|
+
expect(viewResult.ok).toBe(true);
|
|
1442
|
+
if (!viewResult.ok) return;
|
|
1443
|
+
|
|
1444
|
+
const resolveResult = device.createBuffer({
|
|
1445
|
+
label: 'w3-timestamp-raster-resolve',
|
|
1446
|
+
size: 256,
|
|
1447
|
+
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
|
|
1448
|
+
});
|
|
1449
|
+
expect(resolveResult.ok).toBe(true);
|
|
1450
|
+
if (!resolveResult.ok) return;
|
|
1451
|
+
|
|
1452
|
+
const readbackResult = device.createBuffer({
|
|
1453
|
+
label: 'w3-timestamp-raster-readback',
|
|
1454
|
+
size: 256,
|
|
1455
|
+
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
|
1456
|
+
});
|
|
1457
|
+
expect(readbackResult.ok).toBe(true);
|
|
1458
|
+
if (!readbackResult.ok) return;
|
|
1459
|
+
|
|
1460
|
+
const encoderResult = device.createCommandEncoder({ label: 'w3-timestamp-raster-encoder' });
|
|
1461
|
+
expect(encoderResult.ok).toBe(true);
|
|
1462
|
+
if (!encoderResult.ok) return;
|
|
1463
|
+
const encoder = encoderResult.value;
|
|
1464
|
+
|
|
1465
|
+
try {
|
|
1466
|
+
const pass = encoder.beginRenderPass({
|
|
1467
|
+
label: 'timestamp-raster',
|
|
1468
|
+
colorAttachments: [
|
|
1469
|
+
{
|
|
1470
|
+
view: viewResult.value,
|
|
1471
|
+
loadOp: 'clear',
|
|
1472
|
+
storeOp: 'store',
|
|
1473
|
+
},
|
|
1474
|
+
],
|
|
1475
|
+
timestampWrites: {
|
|
1476
|
+
querySet: querySetResult.value,
|
|
1477
|
+
beginningOfPassWriteIndex: 0,
|
|
1478
|
+
endOfPassWriteIndex: 1,
|
|
1479
|
+
},
|
|
1480
|
+
});
|
|
1481
|
+
pass.end();
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
const refusal = error as { code?: string; expected?: string; hint?: string };
|
|
1484
|
+
reportTimestampEvidence({
|
|
1485
|
+
carrier: 'dawn-node',
|
|
1486
|
+
selector,
|
|
1487
|
+
source,
|
|
1488
|
+
acceptedGpu: 0,
|
|
1489
|
+
refusalCode: 'timestamp-write-unavailable',
|
|
1490
|
+
expected:
|
|
1491
|
+
refusal.expected ??
|
|
1492
|
+
'GPURenderPassDescriptor.timestampWrites to be accepted by the real render pass',
|
|
1493
|
+
hint: refusal.hint ?? String(error),
|
|
1494
|
+
});
|
|
1495
|
+
expect(refusal.code).toBe('webgpu-runtime-error');
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const resolveQueriesResult = encoder.resolveQuerySet(
|
|
1500
|
+
querySetResult.value,
|
|
1501
|
+
0,
|
|
1502
|
+
2,
|
|
1503
|
+
resolveResult.value,
|
|
1504
|
+
0,
|
|
1505
|
+
);
|
|
1506
|
+
expect(resolveQueriesResult.ok).toBe(true);
|
|
1507
|
+
if (!resolveQueriesResult.ok) return;
|
|
1508
|
+
encoder.copyBufferToBuffer(resolveResult.value, 0, readbackResult.value, 0, 16);
|
|
1509
|
+
|
|
1510
|
+
const finishResult = encoder.finish();
|
|
1511
|
+
expect(finishResult.ok).toBe(true);
|
|
1512
|
+
if (!finishResult.ok) return;
|
|
1513
|
+
const submitResult = device.queue.submit([finishResult.value]);
|
|
1514
|
+
expect(submitResult.ok).toBe(true);
|
|
1515
|
+
if (!submitResult.ok) return;
|
|
1516
|
+
await device.queue.onSubmittedWorkDone();
|
|
1517
|
+
|
|
1518
|
+
const mappedResult = await readbackResult.value.mapAsync(GPUMapMode.READ);
|
|
1519
|
+
expect(mappedResult.ok).toBe(true);
|
|
1520
|
+
if (!mappedResult.ok) return;
|
|
1521
|
+
const rangeResult = mappedResult.value.getMappedRange(0, 16);
|
|
1522
|
+
expect(rangeResult.ok).toBe(true);
|
|
1523
|
+
if (!rangeResult.ok) return;
|
|
1524
|
+
const ticks = new BigUint64Array(rangeResult.value);
|
|
1525
|
+
const begin = ticks[0];
|
|
1526
|
+
const end = ticks[1];
|
|
1527
|
+
if (begin === undefined || end === undefined || end <= begin) {
|
|
1528
|
+
reportTimestampEvidence({
|
|
1529
|
+
carrier: 'dawn-node',
|
|
1530
|
+
selector,
|
|
1531
|
+
source,
|
|
1532
|
+
acceptedGpu: 0,
|
|
1533
|
+
refusalCode: 'timestamp-write-unavailable',
|
|
1534
|
+
expected: 'end > begin',
|
|
1535
|
+
hint: 'timestamp readback did not produce a positive GPU interval for a render pass',
|
|
1536
|
+
observed: { begin: begin?.toString() ?? null, end: end?.toString() ?? null },
|
|
1537
|
+
});
|
|
1538
|
+
mappedResult.value.unmap();
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
reportTimestampEvidence({
|
|
1543
|
+
carrier: 'dawn-node',
|
|
1544
|
+
selector,
|
|
1545
|
+
source,
|
|
1546
|
+
acceptedGpu: 1,
|
|
1547
|
+
ticks: { begin: begin.toString(), end: end.toString() },
|
|
1548
|
+
});
|
|
1549
|
+
expect(end).toBeGreaterThan(begin);
|
|
1550
|
+
mappedResult.value.unmap();
|
|
1551
|
+
});
|
|
1384
1552
|
});
|
|
1385
1553
|
|
|
1386
1554
|
describe('w36 (M5) - dawn-real-gpu RhiQueue.writeTexture real-path', () => {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { rhi } from '../index';
|
|
3
|
+
|
|
4
|
+
const hasGpu = globalThis.navigator?.gpu !== undefined;
|
|
5
|
+
|
|
6
|
+
describe.skipIf(!hasGpu)('WebGPU r32float probe generation budget', () => {
|
|
7
|
+
it('reuses one receipt within a generation and probes a replacement', async () => {
|
|
8
|
+
const adapterResult = await rhi.requestAdapter();
|
|
9
|
+
expect(adapterResult.ok).toBe(true);
|
|
10
|
+
if (!adapterResult.ok) return;
|
|
11
|
+
const firstDeviceResult = await adapterResult.value.requestDevice();
|
|
12
|
+
expect(firstDeviceResult.ok).toBe(true);
|
|
13
|
+
if (!firstDeviceResult.ok) return;
|
|
14
|
+
|
|
15
|
+
const first = firstDeviceResult.value;
|
|
16
|
+
const firstProbe = first.probeTextureFormatCapability();
|
|
17
|
+
expect(first.probeTextureFormatCapability()).toBe(firstProbe);
|
|
18
|
+
const firstReceipt = await firstProbe;
|
|
19
|
+
expect(firstReceipt.ok).toBe(true);
|
|
20
|
+
if (!firstReceipt.ok) return;
|
|
21
|
+
expect(firstReceipt.value.probeExecutions).toBe(1);
|
|
22
|
+
|
|
23
|
+
const replacementAdapterResult = await rhi.requestAdapter();
|
|
24
|
+
expect(replacementAdapterResult.ok).toBe(true);
|
|
25
|
+
if (!replacementAdapterResult.ok) return;
|
|
26
|
+
const replacementResult = await replacementAdapterResult.value.requestDevice();
|
|
27
|
+
expect(replacementResult.ok).toBe(true);
|
|
28
|
+
if (!replacementResult.ok) return;
|
|
29
|
+
const replacementReceipt = await replacementResult.value.probeTextureFormatCapability();
|
|
30
|
+
expect(replacementReceipt.ok).toBe(true);
|
|
31
|
+
if (!replacementReceipt.ok) return;
|
|
32
|
+
expect(replacementReceipt.value.deviceGeneration).not.toBe(firstReceipt.value.deviceGeneration);
|
|
33
|
+
expect(replacementReceipt.value.probeExecutions).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { rhi } from '../index';
|
|
3
|
+
|
|
4
|
+
describe('WebGPU r32float format profile', () => {
|
|
5
|
+
it('executes every profile stage and returns readback evidence', async () => {
|
|
6
|
+
const adapterResult = await rhi.requestAdapter();
|
|
7
|
+
expect(adapterResult.ok).toBe(true);
|
|
8
|
+
if (!adapterResult.ok) return;
|
|
9
|
+
const deviceResult = await adapterResult.value.requestDevice();
|
|
10
|
+
expect(deviceResult.ok).toBe(true);
|
|
11
|
+
if (!deviceResult.ok) return;
|
|
12
|
+
|
|
13
|
+
const receiptResult = await deviceResult.value.probeTextureFormatCapability();
|
|
14
|
+
expect(receiptResult.ok).toBe(true);
|
|
15
|
+
if (!receiptResult.ok) return;
|
|
16
|
+
expect(receiptResult.value.profile).toBe('r32float-mip-sampled-storage');
|
|
17
|
+
expect(receiptResult.value.verdict).toBe('admitted');
|
|
18
|
+
expect(receiptResult.value.evidence).toBe('real');
|
|
19
|
+
expect(receiptResult.value.stages).toHaveLength(8);
|
|
20
|
+
expect(receiptResult.value.readback?.byteLength).toBeGreaterThan(0);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { rhi } from '../index';
|
|
3
|
+
|
|
4
|
+
describe('Dawn r32float format profile', () => {
|
|
5
|
+
it('executes the independent full chain through completion and readback', async () => {
|
|
6
|
+
const adapterResult = await rhi.requestAdapter();
|
|
7
|
+
expect(adapterResult.ok).toBe(true);
|
|
8
|
+
if (!adapterResult.ok) return;
|
|
9
|
+
const deviceResult = await adapterResult.value.requestDevice();
|
|
10
|
+
expect(deviceResult.ok).toBe(true);
|
|
11
|
+
if (!deviceResult.ok) return;
|
|
12
|
+
|
|
13
|
+
const receiptResult = await deviceResult.value.probeTextureFormatCapability();
|
|
14
|
+
expect(receiptResult.ok).toBe(true);
|
|
15
|
+
if (!receiptResult.ok) return;
|
|
16
|
+
expect(receiptResult.value.profile).toBe('r32float-mip-sampled-storage');
|
|
17
|
+
expect(receiptResult.value.verdict).toBe('admitted');
|
|
18
|
+
expect(receiptResult.value.evidence).toBe('real');
|
|
19
|
+
expect(receiptResult.value.stages.map((stage) => stage.stage)).toEqual([
|
|
20
|
+
'texture-create',
|
|
21
|
+
'mip-view',
|
|
22
|
+
'sampled-storage-bind-group',
|
|
23
|
+
'pipeline-bind',
|
|
24
|
+
'finish',
|
|
25
|
+
'submit',
|
|
26
|
+
'completion',
|
|
27
|
+
'readback',
|
|
28
|
+
]);
|
|
29
|
+
expect(receiptResult.value.readback?.values.length).toBeGreaterThan(0);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -452,6 +452,70 @@ import { createMockGpu, type MockCapture, makeShaderError } from './__mocks__/gp
|
|
|
452
452
|
expect(device.destroyBuffer(readbackResult.value).ok).toBe(true);
|
|
453
453
|
});
|
|
454
454
|
});
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// w38 (M5) - pass descriptor timestampWrites forwarding.
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Current Dawn rejects the obsolete command-encoder writeTimestamp method.
|
|
459
|
+
// Timestamp ownership therefore enters through the real render/compute pass
|
|
460
|
+
// descriptor, where the shim maps the opaque QuerySet to its raw handle.
|
|
461
|
+
describe('w38 (M5) - pass descriptor timestampWrites', () => {
|
|
462
|
+
it('maps an opaque QuerySet in compute-pass timestampWrites to the raw descriptor', async () => {
|
|
463
|
+
const gpu = createMockGpu();
|
|
464
|
+
const adapter = await gpu.requestAdapter();
|
|
465
|
+
if (adapter === null) throw new Error('mock adapter should exist');
|
|
466
|
+
const raw = await adapter.requestDevice();
|
|
467
|
+
(raw.features as unknown as Set<GPUFeatureName>).add('timestamp-query');
|
|
468
|
+
let rawQuerySet: unknown;
|
|
469
|
+
const originalCreateQuerySet = raw.createQuerySet.bind(raw);
|
|
470
|
+
raw.createQuerySet = (descriptor) => {
|
|
471
|
+
const result = originalCreateQuerySet(descriptor);
|
|
472
|
+
rawQuerySet = result;
|
|
473
|
+
return result;
|
|
474
|
+
};
|
|
475
|
+
let captured: GPUComputePassDescriptor | undefined;
|
|
476
|
+
const originalCreateCommandEncoder = raw.createCommandEncoder.bind(raw);
|
|
477
|
+
raw.createCommandEncoder = (descriptor) => {
|
|
478
|
+
const encoder = originalCreateCommandEncoder(descriptor) as unknown as Record<
|
|
479
|
+
string,
|
|
480
|
+
unknown
|
|
481
|
+
>;
|
|
482
|
+
const originalBegin = encoder.beginComputePass as (
|
|
483
|
+
passDescriptor?: GPUComputePassDescriptor,
|
|
484
|
+
) => unknown;
|
|
485
|
+
encoder.beginComputePass = (passDescriptor?: GPUComputePassDescriptor) => {
|
|
486
|
+
captured = passDescriptor;
|
|
487
|
+
return originalBegin.call(encoder, passDescriptor);
|
|
488
|
+
};
|
|
489
|
+
return encoder as unknown as ReturnType<typeof raw.createCommandEncoder>;
|
|
490
|
+
};
|
|
491
|
+
const { device } = makeRhiDevice(raw as unknown as GPUDevice);
|
|
492
|
+
const querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
|
|
493
|
+
expect(querySet.ok).toBe(true);
|
|
494
|
+
if (!querySet.ok) return;
|
|
495
|
+
const encoder = device.createCommandEncoder();
|
|
496
|
+
expect(encoder.ok).toBe(true);
|
|
497
|
+
if (!encoder.ok) return;
|
|
498
|
+
const pass = encoder.value.beginComputePass({
|
|
499
|
+
label: 'hdrp-cluster-membership',
|
|
500
|
+
timestampWrites: {
|
|
501
|
+
querySet: querySet.value,
|
|
502
|
+
beginningOfPassWriteIndex: 0,
|
|
503
|
+
endOfPassWriteIndex: 1,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
pass.end();
|
|
507
|
+
expect(captured).toMatchObject({
|
|
508
|
+
label: 'hdrp-cluster-membership',
|
|
509
|
+
timestampWrites: {
|
|
510
|
+
querySet: querySet.value,
|
|
511
|
+
beginningOfPassWriteIndex: 0,
|
|
512
|
+
endOfPassWriteIndex: 1,
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
expect(rawQuerySet).toBeDefined();
|
|
516
|
+
expect(captured?.timestampWrites?.querySet).toBe(rawQuerySet);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
455
519
|
}
|
|
456
520
|
|
|
457
521
|
{
|
|
@@ -499,6 +563,77 @@ import { createMockGpu, type MockCapture, makeShaderError } from './__mocks__/gp
|
|
|
499
563
|
}
|
|
500
564
|
|
|
501
565
|
describe('MVP-1.1 runtime — verbatim pass-through of the 5 descriptors', () => {
|
|
566
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
567
|
+
|
|
568
|
+
it('normalizes omitted dynamic Dawn limits from the adapter without mutating the descriptor', async () => {
|
|
569
|
+
const gpu = createMockGpu();
|
|
570
|
+
const descriptor: GPUDeviceDescriptor = { label: 'dynamic-limit-probe' };
|
|
571
|
+
const r = await requestDevice({ gpu, deviceDescriptor: descriptor });
|
|
572
|
+
expect(r.ok).toBe(true);
|
|
573
|
+
expect(descriptor).toEqual({ label: 'dynamic-limit-probe' });
|
|
574
|
+
|
|
575
|
+
const ev = lastOf(gpu.__captured, 'requestDevice');
|
|
576
|
+
expect(ev.options).toMatchObject({
|
|
577
|
+
label: 'dynamic-limit-probe',
|
|
578
|
+
requiredLimits: {
|
|
579
|
+
maxDynamicUniformBuffersPerPipelineLayout: 8,
|
|
580
|
+
maxDynamicStorageBuffersPerPipelineLayout: 4,
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('preserves explicit dynamic limits while filling only the omitted adapter-backed limit', async () => {
|
|
586
|
+
const gpu = createMockGpu();
|
|
587
|
+
const requiredLimits = { maxDynamicUniformBuffersPerPipelineLayout: 2 };
|
|
588
|
+
const r = await requestDevice({ gpu, deviceDescriptor: { requiredLimits } });
|
|
589
|
+
expect(r.ok).toBe(true);
|
|
590
|
+
|
|
591
|
+
const ev = lastOf(gpu.__captured, 'requestDevice');
|
|
592
|
+
expect(ev.options?.requiredLimits).toEqual({
|
|
593
|
+
maxDynamicUniformBuffersPerPipelineLayout: 2,
|
|
594
|
+
maxDynamicStorageBuffersPerPipelineLayout: 4,
|
|
595
|
+
});
|
|
596
|
+
expect(requiredLimits).toEqual({ maxDynamicUniformBuffersPerPipelineLayout: 2 });
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('clamps oversized dynamic limits to the adapter without mutating the descriptor', async () => {
|
|
600
|
+
const gpu = createMockGpu();
|
|
601
|
+
const requiredLimits = {
|
|
602
|
+
maxDynamicUniformBuffersPerPipelineLayout: 1_000_000,
|
|
603
|
+
maxDynamicStorageBuffersPerPipelineLayout: 1_000_000,
|
|
604
|
+
};
|
|
605
|
+
const r = await requestDevice({ gpu, deviceDescriptor: { requiredLimits } });
|
|
606
|
+
expect(r.ok).toBe(true);
|
|
607
|
+
|
|
608
|
+
const ev = lastOf(gpu.__captured, 'requestDevice');
|
|
609
|
+
expect(ev.options?.requiredLimits).toEqual({
|
|
610
|
+
maxDynamicUniformBuffersPerPipelineLayout: 8,
|
|
611
|
+
maxDynamicStorageBuffersPerPipelineLayout: 4,
|
|
612
|
+
});
|
|
613
|
+
expect(requiredLimits).toEqual({
|
|
614
|
+
maxDynamicUniformBuffersPerPipelineLayout: 1_000_000,
|
|
615
|
+
maxDynamicStorageBuffersPerPipelineLayout: 1_000_000,
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('applies the same normalization on the public adapter two-step path', async () => {
|
|
620
|
+
const gpu = createMockGpu();
|
|
621
|
+
vi.stubGlobal('navigator', { gpu });
|
|
622
|
+
const adapterResult = await requestAdapter();
|
|
623
|
+
expect(adapterResult.ok).toBe(true);
|
|
624
|
+
if (!adapterResult.ok) return;
|
|
625
|
+
|
|
626
|
+
const deviceResult = await adapterResult.value.requestDevice({
|
|
627
|
+
requiredLimits: { maxDynamicStorageBuffersPerPipelineLayout: 1 },
|
|
628
|
+
});
|
|
629
|
+
expect(deviceResult.ok).toBe(true);
|
|
630
|
+
const ev = lastOf(gpu.__captured, 'requestDevice');
|
|
631
|
+
expect(ev.options?.requiredLimits).toEqual({
|
|
632
|
+
maxDynamicUniformBuffersPerPipelineLayout: 8,
|
|
633
|
+
maxDynamicStorageBuffersPerPipelineLayout: 1,
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
502
637
|
it('BufferDescriptor passes through size / usage / label / mappedAtCreation', async () => {
|
|
503
638
|
const gpu = createMockGpu();
|
|
504
639
|
const r = await requestDevice({ gpu });
|
package/src/device.ts
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
queueWriteBufferOutOfBounds,
|
|
79
79
|
renderPassNotEnded,
|
|
80
80
|
} from './errors';
|
|
81
|
+
import { probeR32FloatCapability } from './internal/r32float-capability';
|
|
81
82
|
import { resolveTimestampQueries } from './internal/timestamp-query';
|
|
82
83
|
|
|
83
84
|
/**
|
|
@@ -170,6 +171,12 @@ const QUERY_SET_COUNT_LIMIT = 4096;
|
|
|
170
171
|
* forward recording calls to the underlying GPUCommandEncoder.
|
|
171
172
|
*/
|
|
172
173
|
const RAW_DEVICE_MAP: WeakMap<RhiDevice, GPUDevice> = new WeakMap();
|
|
174
|
+
const RAW_DEVICE_GENERATION_MAP: WeakMap<GPUDevice, number> = new WeakMap();
|
|
175
|
+
const R32FLOAT_PROBE_CACHE: WeakMap<
|
|
176
|
+
RhiDevice,
|
|
177
|
+
Promise<Result<import('@forgeax/engine-rhi').RhiTextureFormatCapabilityReceipt, RhiError>>
|
|
178
|
+
> = new WeakMap();
|
|
179
|
+
let NEXT_DEVICE_GENERATION = 1;
|
|
173
180
|
const BUFFER_RAW_MAP: WeakMap<Buffer, GPUBuffer> = new WeakMap();
|
|
174
181
|
const TEXTURE_VIEW_RAW_MAP: WeakMap<TextureView, GPUTextureView> = new WeakMap();
|
|
175
182
|
const ENCODER_STATE: WeakMap<RhiCommandEncoder, EncoderState> = new WeakMap();
|
|
@@ -1496,12 +1503,32 @@ export function makeRhiDevice(rawDevice: GPUDevice): {
|
|
|
1496
1503
|
const limits = rawDevice.limits as RhiLimits;
|
|
1497
1504
|
|
|
1498
1505
|
const queue: RhiQueue = makeQueue(rawDevice.queue);
|
|
1506
|
+
const deviceGeneration =
|
|
1507
|
+
RAW_DEVICE_GENERATION_MAP.get(rawDevice) ??
|
|
1508
|
+
(() => {
|
|
1509
|
+
const generation = NEXT_DEVICE_GENERATION++;
|
|
1510
|
+
RAW_DEVICE_GENERATION_MAP.set(rawDevice, generation);
|
|
1511
|
+
return generation;
|
|
1512
|
+
})();
|
|
1499
1513
|
|
|
1500
1514
|
const device: RhiDevice = {
|
|
1501
1515
|
caps,
|
|
1502
1516
|
features,
|
|
1503
1517
|
limits,
|
|
1518
|
+
probeTextureFormatCapability(): Promise<
|
|
1519
|
+
Result<import('@forgeax/engine-rhi').RhiTextureFormatCapabilityReceipt, RhiError>
|
|
1520
|
+
> {
|
|
1521
|
+
const cached = R32FLOAT_PROBE_CACHE.get(device);
|
|
1522
|
+
if (cached !== undefined) return cached;
|
|
1523
|
+
const probe = probeR32FloatCapability(rawDevice, deviceGeneration);
|
|
1524
|
+
R32FLOAT_PROBE_CACHE.set(device, probe);
|
|
1525
|
+
return probe;
|
|
1526
|
+
},
|
|
1504
1527
|
queue,
|
|
1528
|
+
// The raw backend owns the only real loss-injection seam. Keep this
|
|
1529
|
+
// Promise as a transparent observation boundary; a test provider must
|
|
1530
|
+
// operate on the backend before this shim and must never be added to the
|
|
1531
|
+
// public RHI surface.
|
|
1505
1532
|
lost: rawDevice.lost as unknown as Promise<{
|
|
1506
1533
|
readonly reason: 'destroyed' | 'unknown';
|
|
1507
1534
|
readonly message: string;
|
package/src/index.ts
CHANGED
|
@@ -76,12 +76,51 @@ export interface GpuLike {
|
|
|
76
76
|
requestAdapter(options?: GPURequestAdapterOptions | undefined): Promise<GpuAdapterLike | null>;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/** The `GPUAdapter` subset accepted at the provider seam —
|
|
79
|
+
/** The `GPUAdapter` subset accepted at the provider seam — capability limits plus `requestDevice`. */
|
|
80
80
|
export interface GpuAdapterLike {
|
|
81
|
+
readonly limits?: GPUSupportedLimits | undefined;
|
|
81
82
|
// forgeax-async-whitelist: dom-native — spec `GPUAdapter.requestDevice()` raw entry
|
|
82
83
|
requestDevice(descriptor?: GPUDeviceDescriptor | undefined): Promise<GpuDeviceLike>;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
const DYNAMIC_DEVICE_LIMIT_KEYS = [
|
|
87
|
+
'maxDynamicUniformBuffersPerPipelineLayout',
|
|
88
|
+
'maxDynamicStorageBuffersPerPipelineLayout',
|
|
89
|
+
] as const;
|
|
90
|
+
const SYNTHETIC_DAWN_DYNAMIC_LIMIT_DEFAULT = 1_000_000;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Keep browser-native Dawn from synthesizing the 1,000,000 dynamic-buffer
|
|
94
|
+
* defaults or clamping Dawn's synthetic 1,000,000 request. The adapter's
|
|
95
|
+
* reported values are the only safe values for omitted/defaulted limits: they
|
|
96
|
+
* are finite, supported by this adapter, and do not mutate the caller's
|
|
97
|
+
* descriptor.
|
|
98
|
+
*/
|
|
99
|
+
function normalizeDeviceDescriptor(
|
|
100
|
+
adapter: { readonly limits?: GPUSupportedLimits | undefined },
|
|
101
|
+
descriptor?: GPUDeviceDescriptor | undefined,
|
|
102
|
+
): GPUDeviceDescriptor | undefined {
|
|
103
|
+
const adapterLimits = adapter.limits;
|
|
104
|
+
if (adapterLimits === undefined || adapterLimits === null) return descriptor;
|
|
105
|
+
|
|
106
|
+
const requiredLimits = { ...(descriptor?.requiredLimits ?? {}) };
|
|
107
|
+
let changed = false;
|
|
108
|
+
for (const key of DYNAMIC_DEVICE_LIMIT_KEYS) {
|
|
109
|
+
const value = adapterLimits[key];
|
|
110
|
+
if (!Number.isFinite(value) || value < 1) continue;
|
|
111
|
+
const requested = requiredLimits[key];
|
|
112
|
+
if (
|
|
113
|
+
requested !== undefined &&
|
|
114
|
+
(requested <= value || requested !== SYNTHETIC_DAWN_DYNAMIC_LIMIT_DEFAULT)
|
|
115
|
+
) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
requiredLimits[key] = value;
|
|
119
|
+
changed = true;
|
|
120
|
+
}
|
|
121
|
+
return changed ? { ...(descriptor ?? {}), requiredLimits } : descriptor;
|
|
122
|
+
}
|
|
123
|
+
|
|
85
124
|
/**
|
|
86
125
|
* The `GPUDevice` subset accepted at the provider seam — exactly the fields the
|
|
87
126
|
* shim actually touches (the 5 descriptor `createX` calls + features / limits /
|
|
@@ -196,7 +235,9 @@ export async function requestDevice(
|
|
|
196
235
|
|
|
197
236
|
let rawDevice: GpuDeviceLike;
|
|
198
237
|
try {
|
|
199
|
-
rawDevice = await adapter.requestDevice(
|
|
238
|
+
rawDevice = await adapter.requestDevice(
|
|
239
|
+
normalizeDeviceDescriptor(adapter, opts.deviceDescriptor),
|
|
240
|
+
);
|
|
200
241
|
} catch (e) {
|
|
201
242
|
return classifyRequestDeviceError(e);
|
|
202
243
|
}
|
|
@@ -393,7 +434,7 @@ function makeRhiAdapter(rawAdapter: {
|
|
|
393
434
|
let rawDevice: GpuDeviceLike;
|
|
394
435
|
try {
|
|
395
436
|
rawDevice = (await rawAdapter.requestDevice(
|
|
396
|
-
opts as GPUDeviceDescriptor | undefined,
|
|
437
|
+
normalizeDeviceDescriptor(rawAdapter, opts as GPUDeviceDescriptor | undefined),
|
|
397
438
|
)) as GpuDeviceLike;
|
|
398
439
|
} catch (e) {
|
|
399
440
|
return classifyRequestDeviceError(e);
|