@forgeax/engine-rhi-webgpu 0.1.20 → 0.1.23
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 +15 -7
- 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.mjs +159 -43
- package/dist/index.mjs.map +1 -1
- package/dist/internal/r32float-capability.d.ts +6 -0
- package/dist/internal/r32float-capability.d.ts.map +1 -0
- package/dist/internal/timestamp-query.d.ts +0 -5
- package/dist/internal/timestamp-query.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/dawn-real-gpu.dawn.test.ts +170 -3
- 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 +73 -101
- package/src/device.ts +49 -54
- package/src/internal/__tests__/timestamp-query.unit.test.ts +110 -78
- package/src/internal/r32float-capability.ts +153 -0
- package/src/internal/timestamp-query.ts +0 -27
- package/dist/.tsbuildinfo +0 -1
package/src/device.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// exposes the spec Promise without a second cache.
|
|
15
15
|
//
|
|
16
16
|
// w3 / w5 / w6 (feat-20260508-rhi-surface-completion, co-commit):
|
|
17
|
-
// - createCommandEncoder + 12 RhiCommandEncoder methods
|
|
17
|
+
// - createCommandEncoder + 12 RhiCommandEncoder spec methods plus the
|
|
18
|
+
// ForgeaX encodeEmptyComputePass compound operation + 3 mixin (w3)
|
|
18
19
|
// - 17 RhiRenderPassEncoder spec stable methods + 1 setBindGroup overload +
|
|
19
20
|
// 3 placeholders (executeBundles / beginOcclusionQuery / endOcclusionQuery) (w5)
|
|
20
21
|
// - Queue.submit / writeBuffer real implementation + bounds validation (w6)
|
|
@@ -33,6 +34,7 @@ import type {
|
|
|
33
34
|
BindGroupLayout,
|
|
34
35
|
BindGroupLayoutDescriptor,
|
|
35
36
|
Buffer,
|
|
37
|
+
BufferCopyDestination,
|
|
36
38
|
BufferDescriptor,
|
|
37
39
|
CanvasConfiguration,
|
|
38
40
|
CommandBuffer,
|
|
@@ -63,6 +65,7 @@ import type {
|
|
|
63
65
|
Sampler,
|
|
64
66
|
SamplerDescriptor,
|
|
65
67
|
Texture,
|
|
68
|
+
TextureCopySource,
|
|
66
69
|
TextureDescriptor,
|
|
67
70
|
TextureView,
|
|
68
71
|
TextureViewDescriptor,
|
|
@@ -75,7 +78,8 @@ import {
|
|
|
75
78
|
queueWriteBufferOutOfBounds,
|
|
76
79
|
renderPassNotEnded,
|
|
77
80
|
} from './errors';
|
|
78
|
-
import {
|
|
81
|
+
import { probeR32FloatCapability } from './internal/r32float-capability';
|
|
82
|
+
import { resolveTimestampQueries } from './internal/timestamp-query';
|
|
79
83
|
|
|
80
84
|
/**
|
|
81
85
|
* Mirror forgeax `?: T | undefined` descriptor onto the spec GPUXxxDescriptor
|
|
@@ -167,6 +171,12 @@ const QUERY_SET_COUNT_LIMIT = 4096;
|
|
|
167
171
|
* forward recording calls to the underlying GPUCommandEncoder.
|
|
168
172
|
*/
|
|
169
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;
|
|
170
180
|
const BUFFER_RAW_MAP: WeakMap<Buffer, GPUBuffer> = new WeakMap();
|
|
171
181
|
const TEXTURE_VIEW_RAW_MAP: WeakMap<TextureView, GPUTextureView> = new WeakMap();
|
|
172
182
|
const ENCODER_STATE: WeakMap<RhiCommandEncoder, EncoderState> = new WeakMap();
|
|
@@ -812,11 +822,7 @@ function mirrorRenderPipelineDescriptor(
|
|
|
812
822
|
* - render-pass-not-ended is detected by tracking activePass; finish()
|
|
813
823
|
* while a pass has not been end()-ed returns the structured error.
|
|
814
824
|
*/
|
|
815
|
-
function makeCommandEncoder(
|
|
816
|
-
rawEncoder: GPUCommandEncoder,
|
|
817
|
-
caps: { readonly timestampQuery: boolean },
|
|
818
|
-
fireFeatureNotEnabled: (featureName: string, hint: string) => void,
|
|
819
|
-
): RhiCommandEncoder {
|
|
825
|
+
function makeCommandEncoder(rawEncoder: GPUCommandEncoder): RhiCommandEncoder {
|
|
820
826
|
function mirrorComputePassDescriptor(
|
|
821
827
|
desc: ComputePassDescriptor | undefined,
|
|
822
828
|
): GPUComputePassDescriptor | undefined {
|
|
@@ -890,6 +896,12 @@ function makeCommandEncoder(
|
|
|
890
896
|
};
|
|
891
897
|
return pass;
|
|
892
898
|
},
|
|
899
|
+
encodeEmptyComputePass(desc: ComputePassDescriptor): void {
|
|
900
|
+
const state = ENCODER_STATE.get(enc);
|
|
901
|
+
throwIfFinished(state);
|
|
902
|
+
const rawPass = rawEncoder.beginComputePass(mirrorComputePassDescriptor(desc));
|
|
903
|
+
rawPass.end();
|
|
904
|
+
},
|
|
893
905
|
copyBufferToBuffer(
|
|
894
906
|
source: Buffer,
|
|
895
907
|
arg2: number | Buffer,
|
|
@@ -933,19 +945,27 @@ function makeCommandEncoder(
|
|
|
933
945
|
rawEncoder.copyBufferToTexture(rawSrc, destination, copySize);
|
|
934
946
|
},
|
|
935
947
|
copyTextureToBuffer(
|
|
936
|
-
source: GPUTexelCopyTextureInfo,
|
|
937
|
-
destination: GPUTexelCopyBufferInfo,
|
|
948
|
+
source: GPUTexelCopyTextureInfo | TextureCopySource,
|
|
949
|
+
destination: GPUTexelCopyBufferInfo | BufferCopyDestination,
|
|
938
950
|
copySize: GPUExtent3DStrict,
|
|
939
951
|
): void {
|
|
940
952
|
const state = ENCODER_STATE.get(enc);
|
|
941
953
|
throwIfFinished(state);
|
|
942
|
-
const
|
|
943
|
-
|
|
954
|
+
const rawSource: GPUTexelCopyTextureInfo = {
|
|
955
|
+
texture: source.texture as unknown as GPUTexture,
|
|
956
|
+
};
|
|
957
|
+
if (source.mipLevel !== undefined) rawSource.mipLevel = source.mipLevel;
|
|
958
|
+
if (source.origin !== undefined) rawSource.origin = source.origin;
|
|
959
|
+
if (source.aspect !== undefined) rawSource.aspect = source.aspect;
|
|
960
|
+
const rawDst: GPUTexelCopyBufferInfo = {
|
|
944
961
|
buffer:
|
|
945
962
|
BUFFER_RAW_MAP.get(destination.buffer as unknown as Buffer) ??
|
|
946
963
|
(destination.buffer as unknown as GPUBuffer),
|
|
947
964
|
};
|
|
948
|
-
|
|
965
|
+
if (destination.offset !== undefined) rawDst.offset = destination.offset;
|
|
966
|
+
if (destination.bytesPerRow !== undefined) rawDst.bytesPerRow = destination.bytesPerRow;
|
|
967
|
+
if (destination.rowsPerImage !== undefined) rawDst.rowsPerImage = destination.rowsPerImage;
|
|
968
|
+
rawEncoder.copyTextureToBuffer(rawSource, rawDst, copySize);
|
|
949
969
|
},
|
|
950
970
|
copyTextureToTexture(
|
|
951
971
|
source: GPUTexelCopyTextureInfo,
|
|
@@ -1045,25 +1065,6 @@ function makeCommandEncoder(
|
|
|
1045
1065
|
insertDebugMarker(markerLabel: string): void {
|
|
1046
1066
|
rawEncoder.insertDebugMarker(markerLabel);
|
|
1047
1067
|
},
|
|
1048
|
-
writeTimestamp(querySet: QuerySet, queryIndex: number): void {
|
|
1049
|
-
// M5 / K-3 (research §2.4): timestamp-query feature gate. spec
|
|
1050
|
-
// writeTimestamp returns void; the forgeax form keeps the void shape
|
|
1051
|
-
// and fans out 'feature-not-enabled' through the engine onError
|
|
1052
|
-
// channel rather than wrapping in Result. When the capability is true,
|
|
1053
|
-
// a missing or throwing raw write is a structured runtime failure so a
|
|
1054
|
-
// Render capture cannot publish a fabricated interval.
|
|
1055
|
-
const state = ENCODER_STATE.get(enc);
|
|
1056
|
-
throwIfFinished(state);
|
|
1057
|
-
if (caps.timestampQuery !== true) {
|
|
1058
|
-
fireFeatureNotEnabled(
|
|
1059
|
-
'timestamp-query',
|
|
1060
|
-
'check device.caps.timestampQuery before calling writeTimestamp',
|
|
1061
|
-
);
|
|
1062
|
-
return;
|
|
1063
|
-
}
|
|
1064
|
-
const rawQs = QUERY_SET_RAW_MAP.get(querySet) ?? (querySet as unknown as GPUQuerySet);
|
|
1065
|
-
writeTimestamp({ rawEncoder, rawQuerySet: rawQs, queryIndex });
|
|
1066
|
-
},
|
|
1067
1068
|
finish(): Result<CommandBuffer, RhiError> {
|
|
1068
1069
|
const state = ENCODER_STATE.get(enc);
|
|
1069
1070
|
if (state === undefined) {
|
|
@@ -1502,11 +1503,27 @@ export function makeRhiDevice(rawDevice: GPUDevice): {
|
|
|
1502
1503
|
const limits = rawDevice.limits as RhiLimits;
|
|
1503
1504
|
|
|
1504
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
|
+
})();
|
|
1505
1513
|
|
|
1506
1514
|
const device: RhiDevice = {
|
|
1507
1515
|
caps,
|
|
1508
1516
|
features,
|
|
1509
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
|
+
},
|
|
1510
1527
|
queue,
|
|
1511
1528
|
lost: rawDevice.lost as unknown as Promise<{
|
|
1512
1529
|
readonly reason: 'destroyed' | 'unknown';
|
|
@@ -1915,29 +1932,7 @@ export function makeRhiDevice(rawDevice: GPUDevice): {
|
|
|
1915
1932
|
: rawDevice.createCommandEncoder(
|
|
1916
1933
|
mirror(desc, ENC_KEYS) as unknown as GPUCommandEncoderDescriptor,
|
|
1917
1934
|
);
|
|
1918
|
-
|
|
1919
|
-
// writeTimestamp can fire 'feature-not-enabled' through the engine
|
|
1920
|
-
// channel when caps.timestampQuery is false (K-3: spec writeTimestamp
|
|
1921
|
-
// returns void; the forgeax form keeps that shape).
|
|
1922
|
-
//
|
|
1923
|
-
// Round 3 fix-up F-P3-3: the forgeax RhiDevice
|
|
1924
|
-
// does not expose `onError` directly (charter proposition 5: keep
|
|
1925
|
-
// RHI math-free + listener-free), so the shim writes a structured
|
|
1926
|
-
// diagnostic to `console.error` matching the RhiError shape. The
|
|
1927
|
-
// engine layer subscribes through `Renderer.onError` and fans the
|
|
1928
|
-
// same RhiError out; this keeps the unsupported capability observable
|
|
1929
|
-
// for pure-RHI consumers (mock unit tests, dawn-real-gpu probes) that
|
|
1930
|
-
// never instantiate a Renderer.
|
|
1931
|
-
const fireFeatureNotEnabled = (featureName: string, hint: string): void => {
|
|
1932
|
-
// Diagnostic channel (a) of the K-9 double-channel pattern: default
|
|
1933
|
-
// console.error so AI consumers running headless / mock paths still
|
|
1934
|
-
// observe the unsupported capability without subscribing to a
|
|
1935
|
-
// listener. The capability-disabled entry remains non-throwing.
|
|
1936
|
-
console.error(
|
|
1937
|
-
`[RhiError feature-not-enabled] expected: device.features.has('${featureName}') === true; hint: ${hint}`,
|
|
1938
|
-
);
|
|
1939
|
-
};
|
|
1940
|
-
return ok(makeCommandEncoder(rawEnc, caps, fireFeatureNotEnabled));
|
|
1935
|
+
return ok(makeCommandEncoder(rawEnc));
|
|
1941
1936
|
},
|
|
1942
1937
|
// fix-f3: synchronous createShaderModule placeholder removed; the
|
|
1943
1938
|
// shader-compile-failed path lives in the top-level async factory
|
|
@@ -1,88 +1,120 @@
|
|
|
1
|
-
import { RhiError } from '@forgeax/engine-rhi';
|
|
2
1
|
import { describe, expect, it } from 'vitest';
|
|
3
2
|
import { createMockGpu } from '../../__tests__/__mocks__/gpu-device';
|
|
4
3
|
import { makeRhiDevice } from '../../device';
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
5
|
+
describe('timestamp query pass descriptor forwarding', () => {
|
|
6
|
+
it('maps an opaque QuerySet in a compute-pass timestampWrites descriptor', async () => {
|
|
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
|
+
(raw.features as unknown as Set<GPUFeatureName>).add('timestamp-query');
|
|
12
|
+
let rawQuerySet: unknown;
|
|
13
|
+
const originalCreateQuerySet = raw.createQuerySet.bind(raw);
|
|
14
|
+
raw.createQuerySet = (descriptor) => {
|
|
15
|
+
const result = originalCreateQuerySet(descriptor);
|
|
16
|
+
rawQuerySet = result;
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
let captured: GPUComputePassDescriptor | undefined;
|
|
20
|
+
const originalCreateCommandEncoder = raw.createCommandEncoder.bind(raw);
|
|
21
|
+
raw.createCommandEncoder = (descriptor) => {
|
|
22
|
+
const encoder = originalCreateCommandEncoder(descriptor) as unknown as Record<
|
|
23
|
+
string,
|
|
24
|
+
unknown
|
|
25
|
+
>;
|
|
26
|
+
const originalBegin = encoder.beginComputePass as (
|
|
27
|
+
passDescriptor?: GPUComputePassDescriptor,
|
|
28
|
+
) => unknown;
|
|
29
|
+
encoder.beginComputePass = (passDescriptor?: GPUComputePassDescriptor) => {
|
|
30
|
+
captured = passDescriptor;
|
|
31
|
+
return originalBegin.call(encoder, passDescriptor);
|
|
32
|
+
};
|
|
33
|
+
return encoder as unknown as ReturnType<typeof raw.createCommandEncoder>;
|
|
34
|
+
};
|
|
35
|
+
const { device } = makeRhiDevice(raw as unknown as GPUDevice);
|
|
36
|
+
const querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
|
|
37
|
+
expect(querySet.ok).toBe(true);
|
|
38
|
+
if (!querySet.ok) return;
|
|
39
|
+
const encoder = device.createCommandEncoder();
|
|
40
|
+
expect(encoder.ok).toBe(true);
|
|
41
|
+
if (!encoder.ok) return;
|
|
42
|
+
const pass = encoder.value.beginComputePass({
|
|
43
|
+
label: 'timestamp-pass',
|
|
44
|
+
timestampWrites: {
|
|
45
|
+
querySet: querySet.value,
|
|
46
|
+
beginningOfPassWriteIndex: 0,
|
|
47
|
+
endOfPassWriteIndex: 1,
|
|
48
|
+
},
|
|
32
49
|
});
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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');
|
|
50
|
+
pass.end();
|
|
51
|
+
expect(captured).toMatchObject({
|
|
52
|
+
label: 'timestamp-pass',
|
|
53
|
+
timestampWrites: {
|
|
54
|
+
beginningOfPassWriteIndex: 0,
|
|
55
|
+
endOfPassWriteIndex: 1,
|
|
56
|
+
},
|
|
59
57
|
});
|
|
60
|
-
|
|
61
|
-
expect(
|
|
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
|
-
}
|
|
58
|
+
expect(rawQuerySet).toBeDefined();
|
|
59
|
+
expect(captured?.timestampWrites?.querySet).toBe(rawQuerySet);
|
|
71
60
|
});
|
|
72
61
|
|
|
73
|
-
it('
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
62
|
+
it('maps an opaque QuerySet in a render-pass timestampWrites descriptor', async () => {
|
|
63
|
+
const gpu = createMockGpu();
|
|
64
|
+
const adapter = await gpu.requestAdapter();
|
|
65
|
+
if (adapter === null) throw new Error('mock adapter should exist');
|
|
66
|
+
const raw = await adapter.requestDevice();
|
|
67
|
+
(raw.features as unknown as Set<GPUFeatureName>).add('timestamp-query');
|
|
68
|
+
let captured: GPURenderPassDescriptor | undefined;
|
|
69
|
+
const originalCreateCommandEncoder = raw.createCommandEncoder.bind(raw);
|
|
70
|
+
raw.createCommandEncoder = (descriptor) => {
|
|
71
|
+
const encoder = originalCreateCommandEncoder(descriptor) as unknown as Record<
|
|
72
|
+
string,
|
|
73
|
+
unknown
|
|
74
|
+
>;
|
|
75
|
+
const originalBegin = encoder.beginRenderPass as (
|
|
76
|
+
passDescriptor: GPURenderPassDescriptor,
|
|
77
|
+
) => unknown;
|
|
78
|
+
encoder.beginRenderPass = (passDescriptor: GPURenderPassDescriptor) => {
|
|
79
|
+
captured = passDescriptor;
|
|
80
|
+
return originalBegin.call(encoder, passDescriptor);
|
|
81
|
+
};
|
|
82
|
+
return encoder as unknown as ReturnType<typeof raw.createCommandEncoder>;
|
|
83
|
+
};
|
|
84
|
+
const { device } = makeRhiDevice(raw as unknown as GPUDevice);
|
|
85
|
+
const querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
|
|
86
|
+
expect(querySet.ok).toBe(true);
|
|
87
|
+
if (!querySet.ok) return;
|
|
88
|
+
const texture = device.createTexture({
|
|
89
|
+
size: [1, 1, 1],
|
|
90
|
+
format: 'rgba8unorm',
|
|
91
|
+
usage: 0x10,
|
|
92
|
+
});
|
|
93
|
+
expect(texture.ok).toBe(true);
|
|
94
|
+
if (!texture.ok) return;
|
|
95
|
+
const view = device.createTextureView(texture.value, {});
|
|
96
|
+
expect(view.ok).toBe(true);
|
|
97
|
+
if (!view.ok) return;
|
|
98
|
+
const encoder = device.createCommandEncoder();
|
|
99
|
+
expect(encoder.ok).toBe(true);
|
|
100
|
+
if (!encoder.ok) return;
|
|
101
|
+
const pass = encoder.value.beginRenderPass({
|
|
102
|
+
label: 'timestamp-raster',
|
|
103
|
+
colorAttachments: [{ view: view.value, loadOp: 'clear', storeOp: 'store' }],
|
|
104
|
+
timestampWrites: {
|
|
105
|
+
querySet: querySet.value,
|
|
106
|
+
beginningOfPassWriteIndex: 0,
|
|
107
|
+
endOfPassWriteIndex: 1,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
pass.end();
|
|
111
|
+
expect(captured).toMatchObject({
|
|
112
|
+
label: 'timestamp-raster',
|
|
113
|
+
timestampWrites: {
|
|
114
|
+
beginningOfPassWriteIndex: 0,
|
|
115
|
+
endOfPassWriteIndex: 1,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
expect(captured?.timestampWrites?.querySet).toBeDefined();
|
|
87
119
|
});
|
|
88
120
|
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createUnavailableR32FloatReceipt,
|
|
3
|
+
err,
|
|
4
|
+
ok,
|
|
5
|
+
R32FLOAT_PROBE_STAGES,
|
|
6
|
+
type Result,
|
|
7
|
+
RhiError,
|
|
8
|
+
type RhiTextureFormatCapabilityReceipt,
|
|
9
|
+
type RhiTextureFormatProbeStage,
|
|
10
|
+
validateR32FloatReceipt,
|
|
11
|
+
} from '@forgeax/engine-rhi';
|
|
12
|
+
|
|
13
|
+
const TEXTURE_BINDING = 0x04;
|
|
14
|
+
const STORAGE_BINDING = 0x08;
|
|
15
|
+
const COPY_SRC = 0x01;
|
|
16
|
+
const COPY_DST = 0x02;
|
|
17
|
+
const MAP_READ = 0x0001;
|
|
18
|
+
const BUFFER_COPY_DST = 0x0008;
|
|
19
|
+
|
|
20
|
+
type RawDevice = GPUDevice;
|
|
21
|
+
|
|
22
|
+
/** Execute the complete real-device r32float profile without exposing raw handles. */
|
|
23
|
+
export async function probeR32FloatCapability(
|
|
24
|
+
rawDevice: RawDevice,
|
|
25
|
+
deviceGeneration: number,
|
|
26
|
+
): Promise<Result<RhiTextureFormatCapabilityReceipt, RhiError>> {
|
|
27
|
+
let stage: RhiTextureFormatProbeStage = 'texture-create';
|
|
28
|
+
let texture: GPUTexture | undefined;
|
|
29
|
+
let readback: GPUBuffer | undefined;
|
|
30
|
+
rawDevice.pushErrorScope('validation');
|
|
31
|
+
try {
|
|
32
|
+
texture = rawDevice.createTexture({
|
|
33
|
+
size: { width: 2, height: 2, depthOrArrayLayers: 1 },
|
|
34
|
+
format: 'r32float',
|
|
35
|
+
mipLevelCount: 2,
|
|
36
|
+
usage: TEXTURE_BINDING | STORAGE_BINDING | COPY_SRC | COPY_DST,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
stage = 'mip-view';
|
|
40
|
+
const sourceView = texture.createView({ baseMipLevel: 0, mipLevelCount: 1 });
|
|
41
|
+
const destinationView = texture.createView({ baseMipLevel: 1, mipLevelCount: 1 });
|
|
42
|
+
|
|
43
|
+
stage = 'sampled-storage-bind-group';
|
|
44
|
+
const layout = rawDevice.createBindGroupLayout({
|
|
45
|
+
entries: [
|
|
46
|
+
{
|
|
47
|
+
binding: 0,
|
|
48
|
+
visibility: 4,
|
|
49
|
+
texture: { sampleType: 'unfilterable-float', viewDimension: '2d' },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
binding: 1,
|
|
53
|
+
visibility: 4,
|
|
54
|
+
storageTexture: { access: 'write-only', format: 'r32float', viewDimension: '2d' },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
const bindGroup = rawDevice.createBindGroup({
|
|
59
|
+
layout,
|
|
60
|
+
entries: [
|
|
61
|
+
{ binding: 0, resource: sourceView },
|
|
62
|
+
{ binding: 1, resource: destinationView },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
stage = 'pipeline-bind';
|
|
67
|
+
const shader = rawDevice.createShaderModule({
|
|
68
|
+
code: `
|
|
69
|
+
@group(0) @binding(0) var source: texture_2d<f32>;
|
|
70
|
+
@group(0) @binding(1) var destination: texture_storage_2d<r32float, write>;
|
|
71
|
+
@compute @workgroup_size(1) fn main() {
|
|
72
|
+
let value = textureLoad(source, vec2i(0, 0), 0).r;
|
|
73
|
+
textureStore(destination, vec2i(0, 0), vec4f(value));
|
|
74
|
+
}
|
|
75
|
+
`,
|
|
76
|
+
});
|
|
77
|
+
const pipeline = rawDevice.createComputePipeline({
|
|
78
|
+
layout: rawDevice.createPipelineLayout({ bindGroupLayouts: [layout] }),
|
|
79
|
+
compute: { module: shader, entryPoint: 'main' },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const encoder = rawDevice.createCommandEncoder({ label: 'r32float-profile' });
|
|
83
|
+
const pass = encoder.beginComputePass();
|
|
84
|
+
pass.setPipeline(pipeline);
|
|
85
|
+
pass.setBindGroup(0, bindGroup);
|
|
86
|
+
pass.dispatchWorkgroups(1);
|
|
87
|
+
pass.end();
|
|
88
|
+
|
|
89
|
+
stage = 'finish';
|
|
90
|
+
readback = rawDevice.createBuffer({ size: 256, usage: MAP_READ | BUFFER_COPY_DST });
|
|
91
|
+
encoder.copyTextureToBuffer(
|
|
92
|
+
{ texture, mipLevel: 1 },
|
|
93
|
+
{ buffer: readback, bytesPerRow: 256, rowsPerImage: 1 },
|
|
94
|
+
{ width: 1, height: 1, depthOrArrayLayers: 1 },
|
|
95
|
+
);
|
|
96
|
+
const commandBuffer = encoder.finish();
|
|
97
|
+
|
|
98
|
+
stage = 'submit';
|
|
99
|
+
rawDevice.queue.submit([commandBuffer]);
|
|
100
|
+
|
|
101
|
+
stage = 'completion';
|
|
102
|
+
await rawDevice.queue.onSubmittedWorkDone();
|
|
103
|
+
const validationError = await rawDevice.popErrorScope();
|
|
104
|
+
if (validationError !== null) {
|
|
105
|
+
throw new Error(`WebGPU validation: ${validationError.message}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stage = 'readback';
|
|
109
|
+
await readback.mapAsync(MAP_READ);
|
|
110
|
+
const values = Array.from(new Float32Array(readback.getMappedRange(0, 4).slice(0)));
|
|
111
|
+
readback.unmap();
|
|
112
|
+
const receipt: RhiTextureFormatCapabilityReceipt = {
|
|
113
|
+
profile: 'r32float-mip-sampled-storage',
|
|
114
|
+
verdict: 'admitted',
|
|
115
|
+
evidence: 'real',
|
|
116
|
+
deviceGeneration,
|
|
117
|
+
stages: R32FLOAT_PROBE_STAGES.map((entry) => ({
|
|
118
|
+
stage: entry,
|
|
119
|
+
verdict: 'admitted',
|
|
120
|
+
evidence: 'real',
|
|
121
|
+
})),
|
|
122
|
+
sampleType: 'unfilterable-float',
|
|
123
|
+
usages: ['texture-binding', 'storage-binding', 'copy-src'],
|
|
124
|
+
readback: { byteLength: 4, values },
|
|
125
|
+
probeExecutions: 1,
|
|
126
|
+
};
|
|
127
|
+
const validated = validateR32FloatReceipt(receipt);
|
|
128
|
+
return validated.ok ? ok(validated.value) : validated;
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
131
|
+
const receipt = createUnavailableR32FloatReceipt({
|
|
132
|
+
deviceGeneration,
|
|
133
|
+
failedStage: stage,
|
|
134
|
+
detail: message,
|
|
135
|
+
});
|
|
136
|
+
void receipt;
|
|
137
|
+
return err(
|
|
138
|
+
new RhiError({
|
|
139
|
+
code: 'rhi-texture-format-capability-unavailable',
|
|
140
|
+
expected: `r32float profile stage ${stage} to complete on the live WebGPU device`,
|
|
141
|
+
hint: 'retain fallback-only rendering and inspect the device validation error',
|
|
142
|
+
detail: {
|
|
143
|
+
stage,
|
|
144
|
+
deviceGeneration,
|
|
145
|
+
reason: message,
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
} finally {
|
|
150
|
+
texture?.destroy();
|
|
151
|
+
readback?.destroy();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -30,30 +30,3 @@ export function resolveTimestampQueries(args: {
|
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
export function writeTimestamp(args: {
|
|
35
|
-
rawEncoder: GPUCommandEncoder;
|
|
36
|
-
rawQuerySet: GPUQuerySet;
|
|
37
|
-
queryIndex: number;
|
|
38
|
-
}): void {
|
|
39
|
-
const encoderWithTimestamp = args.rawEncoder as unknown as {
|
|
40
|
-
writeTimestamp?: (querySet: GPUQuerySet, queryIndex: number) => void;
|
|
41
|
-
};
|
|
42
|
-
if (typeof encoderWithTimestamp.writeTimestamp !== 'function') {
|
|
43
|
-
throw new RhiError({
|
|
44
|
-
code: 'webgpu-runtime-error',
|
|
45
|
-
expected: 'underlying GPUCommandEncoder.writeTimestamp to be callable',
|
|
46
|
-
hint: 'timestamp-query is advertised but the raw encoder has no writeTimestamp method',
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
try {
|
|
50
|
-
encoderWithTimestamp.writeTimestamp(args.rawQuerySet, args.queryIndex);
|
|
51
|
-
} catch (error) {
|
|
52
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
53
|
-
throw new RhiError({
|
|
54
|
-
code: 'webgpu-runtime-error',
|
|
55
|
-
expected: 'underlying GPUCommandEncoder.writeTimestamp to succeed',
|
|
56
|
-
hint: `writeTimestamp raised: ${message}`,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
}
|