@forgeax/engine-rhi-null 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 +128 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/device-lost-owner.test-d.d.ts +2 -0
- package/dist/__tests__/device-lost-owner.test-d.d.ts.map +1 -0
- package/dist/adapter.d.ts +9 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/bookkeeping.d.ts +86 -0
- package/dist/bookkeeping.d.ts.map +1 -0
- package/dist/canvas-context.d.ts +22 -0
- package/dist/canvas-context.d.ts.map +1 -0
- package/dist/command-encoder.d.ts +27 -0
- package/dist/command-encoder.d.ts.map +1 -0
- package/dist/device.d.ts +66 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +470 -0
- package/dist/index.mjs.map +1 -0
- package/dist/pass-encoders.d.ts +65 -0
- package/dist/pass-encoders.d.ts.map +1 -0
- package/dist/queue.d.ts +10 -0
- package/dist/queue.d.ts.map +1 -0
- package/dist/shader.d.ts +14 -0
- package/dist/shader.d.ts.map +1 -0
- package/package.json +58 -0
- package/src/__tests__/device-lost-owner.test-d.ts +48 -0
- package/src/adapter.ts +40 -0
- package/src/bookkeeping.ts +179 -0
- package/src/canvas-context.ts +71 -0
- package/src/command-encoder.ts +145 -0
- package/src/device.ts +259 -0
- package/src/index.ts +63 -0
- package/src/pass-encoders.ts +211 -0
- package/src/queue.ts +62 -0
- package/src/shader.ts +37 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// @forgeax/engine-rhi-null/src/command-encoder - headless command encoder.
|
|
2
|
+
//
|
|
3
|
+
// RhiNullCommandEncoder is a no-op recorder: begin*Pass returns a fresh
|
|
4
|
+
// pass-encoder bound to the same per-device ledger; copy* / clear* / debug* /
|
|
5
|
+
// writeTimestamp are no-ops; resolveQuerySet / finish return ok. finish() mints
|
|
6
|
+
// a legal CommandBuffer brand through the ledger so submit-side bookkeeping
|
|
7
|
+
// (AC-12) can read it back.
|
|
8
|
+
//
|
|
9
|
+
// Each beginRenderPass / beginComputePass reads the `label` from the descriptor
|
|
10
|
+
// and threads it as the pass name to the per-device counters so M3 unit tests
|
|
11
|
+
// (w17 AC-04) can assert per-frame pass scheduling order.
|
|
12
|
+
//
|
|
13
|
+
// Related: requirements AC-04 (pass sequence) + AC-06 (draw count via pass
|
|
14
|
+
// encoders); research Finding A1 row 5; plan-strategy §3.1.
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
Buffer,
|
|
18
|
+
CommandBuffer,
|
|
19
|
+
ComputePassDescriptor,
|
|
20
|
+
QuerySet,
|
|
21
|
+
Result,
|
|
22
|
+
RhiCommandEncoder,
|
|
23
|
+
RhiComputePassEncoder,
|
|
24
|
+
RhiError as RhiErrorType,
|
|
25
|
+
RhiRenderPassEncoder,
|
|
26
|
+
} from '@forgeax/engine-rhi';
|
|
27
|
+
import { ok } from '@forgeax/engine-types';
|
|
28
|
+
import type { Bookkeeper } from './bookkeeping';
|
|
29
|
+
import type { RhiNullDevice } from './device';
|
|
30
|
+
import {
|
|
31
|
+
type PassCounter,
|
|
32
|
+
RhiNullComputePassEncoder,
|
|
33
|
+
RhiNullRenderPassEncoder,
|
|
34
|
+
} from './pass-encoders';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Internal counter that writes per-frame stats back to the owning device.
|
|
38
|
+
*/
|
|
39
|
+
class DeviceCounter implements PassCounter {
|
|
40
|
+
constructor(private readonly device: RhiNullDevice) {}
|
|
41
|
+
|
|
42
|
+
recordDraw(): void {
|
|
43
|
+
this.device.totalDrawCount++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
recordDispatch(): void {
|
|
47
|
+
this.device.totalDispatchCount++;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
recordBindGroup(): void {
|
|
51
|
+
this.device.totalBindGroupCount++;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
recordPassName(name: string): void {
|
|
55
|
+
this.device.framePassNames.push(name);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read the `label` field from a GPURenderPassDescriptor (or undefined if not
|
|
61
|
+
* set). The engine sets `label` on the render pass descriptor; the graph's
|
|
62
|
+
* compile path doesn't always thread a per-pass label, so we fall back to the
|
|
63
|
+
* RenderPassDescriptor's generic label (or '<unnamed>').
|
|
64
|
+
*/
|
|
65
|
+
function readPassLabel(desc: Record<string, unknown> | undefined): string {
|
|
66
|
+
if (desc && typeof desc.label === 'string' && desc.label.length > 0) {
|
|
67
|
+
return desc.label as string;
|
|
68
|
+
}
|
|
69
|
+
return '<unnamed>';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Headless command encoder. begin*Pass returns a no-op pass encoder bound to
|
|
74
|
+
* the issuing device's ledger; recording methods are no-ops; finish mints a
|
|
75
|
+
* CommandBuffer brand.
|
|
76
|
+
*/
|
|
77
|
+
export class RhiNullCommandEncoder implements RhiCommandEncoder {
|
|
78
|
+
private readonly bookkeeper: Bookkeeper;
|
|
79
|
+
private readonly counter: PassCounter;
|
|
80
|
+
|
|
81
|
+
constructor(bookkeeper: Bookkeeper, device: RhiNullDevice) {
|
|
82
|
+
this.bookkeeper = bookkeeper;
|
|
83
|
+
this.counter = new DeviceCounter(device);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
beginRenderPass(desc: GPURenderPassDescriptor): RhiRenderPassEncoder {
|
|
87
|
+
const label = readPassLabel(desc as unknown as Record<string, unknown>);
|
|
88
|
+
return new RhiNullRenderPassEncoder(this.bookkeeper, this.counter, label);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beginComputePass(desc?: ComputePassDescriptor | undefined): RhiComputePassEncoder {
|
|
92
|
+
const label = readPassLabel(desc as unknown as Record<string, unknown> | undefined);
|
|
93
|
+
return new RhiNullComputePassEncoder(this.bookkeeper, this.counter, label);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
copyBufferToBuffer(
|
|
97
|
+
_source: Buffer,
|
|
98
|
+
_sourceOffsetOrDestination: number | Buffer,
|
|
99
|
+
_destinationOrSize?: Buffer | number | undefined,
|
|
100
|
+
_destinationOffset?: number | undefined,
|
|
101
|
+
_size?: number | undefined,
|
|
102
|
+
): void {}
|
|
103
|
+
|
|
104
|
+
copyBufferToTexture(
|
|
105
|
+
_source: GPUTexelCopyBufferInfo,
|
|
106
|
+
_destination: GPUTexelCopyTextureInfo,
|
|
107
|
+
_copySize: GPUExtent3DStrict,
|
|
108
|
+
): void {}
|
|
109
|
+
|
|
110
|
+
copyTextureToBuffer(
|
|
111
|
+
_source: GPUTexelCopyTextureInfo,
|
|
112
|
+
_destination: GPUTexelCopyBufferInfo,
|
|
113
|
+
_copySize: GPUExtent3DStrict,
|
|
114
|
+
): void {}
|
|
115
|
+
|
|
116
|
+
copyTextureToTexture(
|
|
117
|
+
_source: GPUTexelCopyTextureInfo,
|
|
118
|
+
_destination: GPUTexelCopyTextureInfo,
|
|
119
|
+
_copySize: GPUExtent3DStrict,
|
|
120
|
+
): void {}
|
|
121
|
+
|
|
122
|
+
clearBuffer(_buffer: Buffer, _offset?: number | undefined, _size?: number | undefined): void {}
|
|
123
|
+
|
|
124
|
+
resolveQuerySet(
|
|
125
|
+
_querySet: QuerySet,
|
|
126
|
+
_firstQuery: number,
|
|
127
|
+
_queryCount: number,
|
|
128
|
+
_destination: Buffer,
|
|
129
|
+
_destinationOffset: number,
|
|
130
|
+
): Result<void, RhiErrorType> {
|
|
131
|
+
return ok(undefined);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
writeTimestamp(_querySet: QuerySet, _queryIndex: number): void {}
|
|
135
|
+
|
|
136
|
+
pushDebugGroup(_groupLabel: string): void {}
|
|
137
|
+
|
|
138
|
+
popDebugGroup(): void {}
|
|
139
|
+
|
|
140
|
+
insertDebugMarker(_markerLabel: string): void {}
|
|
141
|
+
|
|
142
|
+
finish(): Result<CommandBuffer, RhiErrorType> {
|
|
143
|
+
return ok(this.bookkeeper.register('CommandBuffer') as unknown as CommandBuffer);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/device.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// @forgeax/engine-rhi-null/src/device - RhiNullDevice + caps for the headless
|
|
2
|
+
// no-op backend.
|
|
3
|
+
//
|
|
4
|
+
// RhiNullDevice implements the full RhiDevice surface (research Finding A1
|
|
5
|
+
// row 3) as no-ops that mint legal opaque-handle brands and thread them through
|
|
6
|
+
// the per-device Bookkeeper (method A; research Finding A6). The `implements
|
|
7
|
+
// RhiDevice` clause is the completeness guard: a missing method is a tsc -b
|
|
8
|
+
// red, satisfying AC-01 without a hand-maintained member checklist.
|
|
9
|
+
//
|
|
10
|
+
// caps (D-5): backendKind 'null'; the 3 wgpu-native-only reserved flags
|
|
11
|
+
// (multiDrawIndirect / pushConstants / textureBindingArray) are false; every
|
|
12
|
+
// other boolean cap is true and maxColorAttachments is 8, so the headless
|
|
13
|
+
// backend maximizes structural coverage of capability-gated paths.
|
|
14
|
+
//
|
|
15
|
+
// The queue is supplied at construction (the adapter's requestDevice mints the
|
|
16
|
+
// RhiNullQueue, w10) so device.ts carries no dependency on queue.ts.
|
|
17
|
+
//
|
|
18
|
+
// Related: requirements scope row 2 (8 interfaces full method set) + AC-01
|
|
19
|
+
// (implements compiles) + AC-08 (backendKind === 'null'); plan-strategy §2 D-5
|
|
20
|
+
// (caps field-level) + §4 R-3; research Finding A1 + A6.
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
BindGroup,
|
|
24
|
+
BindGroupDescriptor,
|
|
25
|
+
BindGroupLayout,
|
|
26
|
+
BindGroupLayoutDescriptor,
|
|
27
|
+
Buffer,
|
|
28
|
+
BufferDescriptor,
|
|
29
|
+
CommandEncoderDescriptor,
|
|
30
|
+
ComputePipeline,
|
|
31
|
+
ComputePipelineDescriptor,
|
|
32
|
+
PipelineLayout,
|
|
33
|
+
PipelineLayoutDescriptor,
|
|
34
|
+
QuerySet,
|
|
35
|
+
QuerySetDescriptor,
|
|
36
|
+
RenderPipeline,
|
|
37
|
+
RenderPipelineDescriptor,
|
|
38
|
+
Result,
|
|
39
|
+
RhiCaps,
|
|
40
|
+
RhiCommandEncoder,
|
|
41
|
+
RhiComputePipelineOps,
|
|
42
|
+
RhiDevice,
|
|
43
|
+
RhiError as RhiErrorType,
|
|
44
|
+
RhiFeatures,
|
|
45
|
+
RhiLimits,
|
|
46
|
+
RhiQueue,
|
|
47
|
+
RhiRenderPipelineOps,
|
|
48
|
+
Sampler,
|
|
49
|
+
SamplerDescriptor,
|
|
50
|
+
Texture,
|
|
51
|
+
TextureDescriptor,
|
|
52
|
+
TextureView,
|
|
53
|
+
TextureViewDescriptor,
|
|
54
|
+
} from '@forgeax/engine-rhi';
|
|
55
|
+
import { RhiError as RhiErrorClass } from '@forgeax/engine-rhi';
|
|
56
|
+
import { err, ok } from '@forgeax/engine-types';
|
|
57
|
+
import { Bookkeeper } from './bookkeeping';
|
|
58
|
+
|
|
59
|
+
type RhiNullDeviceLost = Awaited<RhiDevice['lost']>;
|
|
60
|
+
|
|
61
|
+
/** Monotonic device-id source so each RhiNullDevice owns a distinct id; the id
|
|
62
|
+
* threads into the Bookkeeper for cross-device handle-chain validation. */
|
|
63
|
+
let nextDeviceId = 0;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Factory that builds a command encoder bound to a device's ledger. Injected at
|
|
67
|
+
* device construction (rather than imported here) so device.ts carries no
|
|
68
|
+
* dependency on command-encoder.ts; the singleton assembly (index.ts) supplies
|
|
69
|
+
* the real factory. The Bookkeeper and RhiNullDevice are passed so the encoder
|
|
70
|
+
* threads draw / dispatch counts + binding validation through the same per-device
|
|
71
|
+
* ledger AND writes aggregated frame stats to the device for M3 unit-test readback.
|
|
72
|
+
*/
|
|
73
|
+
export type CommandEncoderFactory = (
|
|
74
|
+
bookkeeper: Bookkeeper,
|
|
75
|
+
device: RhiNullDevice,
|
|
76
|
+
) => RhiCommandEncoder;
|
|
77
|
+
|
|
78
|
+
/** A pipeline brand augmented with its no-op `getBindGroupLayout` ops method
|
|
79
|
+
* (D-2). createRenderPipeline / createComputePipeline return objects of this
|
|
80
|
+
* shape so the auto-layout consumers can call getBindGroupLayout. */
|
|
81
|
+
type PipelineHandle<Brand> = Brand & RhiRenderPipelineOps & RhiComputePipelineOps;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Headless no-op RhiDevice. Every create* mints a legal brand and records it;
|
|
85
|
+
* every destroy* fail-fasts a double-destroy; caps reports the all-true-except-
|
|
86
|
+
* reserved profile (D-5).
|
|
87
|
+
*/
|
|
88
|
+
export class RhiNullDevice implements RhiDevice {
|
|
89
|
+
private readonly internalBookkeeper: Bookkeeper;
|
|
90
|
+
private readonly nullQueue: RhiQueue;
|
|
91
|
+
private readonly encoderFactory: CommandEncoderFactory;
|
|
92
|
+
|
|
93
|
+
/** Per-frame total draw count across all pass encoders executed this frame
|
|
94
|
+
* (aggregated by the command encoder on finish, then reset). M3 unit tests
|
|
95
|
+
* (w17) read this to assert draw count >= 1 (AC-06). */
|
|
96
|
+
totalDrawCount = 0;
|
|
97
|
+
/** Per-frame total direct and indirect compute dispatch count. */
|
|
98
|
+
totalDispatchCount = 0;
|
|
99
|
+
/** Per-frame total bind group set count (AC-06 / AC-05 readback). */
|
|
100
|
+
totalBindGroupCount = 0;
|
|
101
|
+
/** Per-frame pass names executed this frame, in schedule order (AC-04). */
|
|
102
|
+
framePassNames: string[] = [];
|
|
103
|
+
|
|
104
|
+
/** The per-device handle ledger — exposed so M3 tests can assert create/destroy
|
|
105
|
+
* pairing and BGL/PSO shape counts (AC-05/06/07). */
|
|
106
|
+
get bookkeeper(): Bookkeeper {
|
|
107
|
+
return this.internalBookkeeper;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
constructor(queue: RhiQueue, encoderFactory: CommandEncoderFactory) {
|
|
111
|
+
this.internalBookkeeper = new Bookkeeper(nextDeviceId++);
|
|
112
|
+
this.nullQueue = queue;
|
|
113
|
+
this.encoderFactory = encoderFactory;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get caps(): RhiCaps {
|
|
117
|
+
return {
|
|
118
|
+
backendKind: 'null',
|
|
119
|
+
compute: true,
|
|
120
|
+
timestampQuery: false,
|
|
121
|
+
timestampPeriodNanoseconds: null,
|
|
122
|
+
indirectDrawing: true,
|
|
123
|
+
textureCompressionBc: false,
|
|
124
|
+
textureCompressionEtc2: false,
|
|
125
|
+
textureCompressionAstc: false,
|
|
126
|
+
// 3 wgpu-native-only reserved flags stay false on non-native backends
|
|
127
|
+
// (D-5); the headless backend is not a native runtime.
|
|
128
|
+
multiDrawIndirect: false,
|
|
129
|
+
pushConstants: false,
|
|
130
|
+
textureBindingArray: false,
|
|
131
|
+
samplerAliasing: true,
|
|
132
|
+
firstInstanceIndirect: true,
|
|
133
|
+
storageBuffer: true,
|
|
134
|
+
storageTexture: true,
|
|
135
|
+
rgba16floatRenderable: true,
|
|
136
|
+
rg11b10ufloatRenderable: true,
|
|
137
|
+
float32Filterable: true,
|
|
138
|
+
maxColorAttachments: 8,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get features(): RhiFeatures {
|
|
143
|
+
return EMPTY_FEATURES;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get limits(): RhiLimits {
|
|
147
|
+
return EMPTY_LIMITS;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get queue(): RhiQueue {
|
|
151
|
+
return this.nullQueue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// forgeax-async-whitelist: dom-native — spec `GPUDevice.lost` Promise
|
|
155
|
+
// passthrough. The headless backend never loses a device (no GPU), so the
|
|
156
|
+
// Promise stays unsettled for the lifetime of the device, mirroring a live
|
|
157
|
+
// device that never transitions to the lost state.
|
|
158
|
+
get lost(): Promise<RhiNullDeviceLost> {
|
|
159
|
+
return NEVER;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
createBuffer(_desc: BufferDescriptor): Result<Buffer, RhiErrorType> {
|
|
163
|
+
return ok(this.internalBookkeeper.register('Buffer') as unknown as Buffer);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
createTexture(_desc: TextureDescriptor): Result<Texture, RhiErrorType> {
|
|
167
|
+
return ok(this.internalBookkeeper.register('Texture') as unknown as Texture);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
destroyBuffer(buf: Buffer): Result<void, RhiErrorType> {
|
|
171
|
+
return this.internalBookkeeper.destroy(buf);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
destroyQuerySet(querySet: QuerySet): Result<void, RhiErrorType> {
|
|
175
|
+
return this.internalBookkeeper.destroy(querySet);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
destroyTexture(tex: Texture): Result<void, RhiErrorType> {
|
|
179
|
+
return this.internalBookkeeper.destroy(tex);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
createTextureView(
|
|
183
|
+
_texture: Texture,
|
|
184
|
+
_desc: TextureViewDescriptor,
|
|
185
|
+
): Result<TextureView, RhiErrorType> {
|
|
186
|
+
return ok(this.internalBookkeeper.register('TextureView') as unknown as TextureView);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
createSampler(_desc?: SamplerDescriptor | undefined): Result<Sampler, RhiErrorType> {
|
|
190
|
+
return ok(this.internalBookkeeper.register('Sampler') as unknown as Sampler);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
createBindGroupLayout(_desc: BindGroupLayoutDescriptor): Result<BindGroupLayout, RhiErrorType> {
|
|
194
|
+
return ok(this.internalBookkeeper.register('BindGroupLayout') as unknown as BindGroupLayout);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
createBindGroup(_desc: BindGroupDescriptor): Result<BindGroup, RhiErrorType> {
|
|
198
|
+
return ok(this.internalBookkeeper.register('BindGroup') as unknown as BindGroup);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
createPipelineLayout(_desc: PipelineLayoutDescriptor): Result<PipelineLayout, RhiErrorType> {
|
|
202
|
+
return ok(this.internalBookkeeper.register('PipelineLayout') as unknown as PipelineLayout);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
createRenderPipeline(_desc: RenderPipelineDescriptor): Result<RenderPipeline, RhiErrorType> {
|
|
206
|
+
return ok(this.makePipeline<RenderPipeline>('RenderPipeline'));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
createComputePipeline(_desc: ComputePipelineDescriptor): Result<ComputePipeline, RhiErrorType> {
|
|
210
|
+
return ok(this.makePipeline<ComputePipeline>('ComputePipeline'));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
createQuerySet(desc: QuerySetDescriptor): Result<QuerySet, RhiErrorType> {
|
|
214
|
+
if (desc.type === 'timestamp') {
|
|
215
|
+
return err(
|
|
216
|
+
new RhiErrorClass({
|
|
217
|
+
code: 'feature-not-enabled',
|
|
218
|
+
expected: 'caps.timestampQuery === true (timestamp-query feature)',
|
|
219
|
+
hint: 'RhiNull is structural-only and cannot produce GPU timestamp ticks',
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return ok(this.internalBookkeeper.register('QuerySet') as unknown as QuerySet);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
createCommandEncoder(
|
|
227
|
+
_desc?: CommandEncoderDescriptor | undefined,
|
|
228
|
+
): Result<RhiCommandEncoder, RhiErrorType> {
|
|
229
|
+
return ok(this.encoderFactory(this.internalBookkeeper, this));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Mint a pipeline handle whose object also carries the no-op
|
|
234
|
+
* `getBindGroupLayout(index)` ops method (D-2). The prod auto-layout path
|
|
235
|
+
* (debug-draw.ts) and the existing mock unit tests both call
|
|
236
|
+
* `pipeline.getBindGroupLayout(n)`; returning a legal BindGroupLayout brand
|
|
237
|
+
* (recorded in the ledger) keeps those consumers from crashing on a missing
|
|
238
|
+
* method.
|
|
239
|
+
*/
|
|
240
|
+
private makePipeline<Brand>(kind: string): PipelineHandle<Brand> {
|
|
241
|
+
const handle = this.internalBookkeeper.register(kind);
|
|
242
|
+
const getBindGroupLayout = (_index: number): BindGroupLayout =>
|
|
243
|
+
this.internalBookkeeper.register('BindGroupLayout') as unknown as BindGroupLayout;
|
|
244
|
+
return Object.assign(handle, { getBindGroupLayout }) as unknown as PipelineHandle<Brand>;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Empty enabled-feature set — headless backend enables nothing beyond the
|
|
249
|
+
* always-true caps profile (research Finding A1: features getter returns an
|
|
250
|
+
* empty ReadonlySet). */
|
|
251
|
+
const EMPTY_FEATURES: RhiFeatures = new Set() as RhiFeatures;
|
|
252
|
+
|
|
253
|
+
/** Empty numeric-limits map. The headless backend reports no concrete numeric
|
|
254
|
+
* limits; capability planning reads caps booleans instead. */
|
|
255
|
+
const EMPTY_LIMITS: RhiLimits = {} as RhiLimits;
|
|
256
|
+
|
|
257
|
+
/** A Promise that never settles, mirroring a live GPUDevice.lost that stays
|
|
258
|
+
* unsettled while the device is healthy. */
|
|
259
|
+
const NEVER: Promise<RhiNullDeviceLost> = new Promise<RhiNullDeviceLost>(() => {});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @forgeax/engine-rhi-null - headless no-op RHI backend.
|
|
2
|
+
//
|
|
3
|
+
// Single entry for `import { rhi } from '@forgeax/engine-rhi-null'`. The
|
|
4
|
+
// exported `rhi` singleton has the RhiBackendPack-mandated shape
|
|
5
|
+
// `RhiInstance & { acquireCanvasContext }` (research Finding A4 — Channel 1
|
|
6
|
+
// injects this verbatim and calls acquireCanvasContext on the facade, so the
|
|
7
|
+
// method MUST exist on the singleton even though the public RendererOptions.rhi
|
|
8
|
+
// type does not require it). createShaderModule is exposed at the top level
|
|
9
|
+
// (R-2) for symmetry with rhi-webgpu: createRenderer's ready chain resolves the
|
|
10
|
+
// shader step through `RhiBackendPack.createShaderModule`, otherwise it rejects
|
|
11
|
+
// rhi-not-available.
|
|
12
|
+
//
|
|
13
|
+
// Strict two-step path: rhi.requestAdapter() -> adapter.requestDevice().
|
|
14
|
+
//
|
|
15
|
+
// Related: requirements AC-02 + AC-10 + AC-12 + scope row 4; research Finding
|
|
16
|
+
// A1 row 1 + A4 + A5; plan-strategy §4 R-1 + R-2.
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
RequestAdapterOptions,
|
|
20
|
+
Result,
|
|
21
|
+
RhiAdapter,
|
|
22
|
+
RhiError as RhiErrorType,
|
|
23
|
+
RhiInstance,
|
|
24
|
+
} from '@forgeax/engine-rhi';
|
|
25
|
+
import { ok } from '@forgeax/engine-types';
|
|
26
|
+
import { RhiNullAdapter } from './adapter';
|
|
27
|
+
import { acquireCanvasContext } from './canvas-context';
|
|
28
|
+
import { createShaderModule } from './shader';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Request a headless adapter. The two-positional-arg signature mirrors the spec
|
|
32
|
+
* RhiInstance.requestAdapter (the second compatibleSurface arg is accepted and
|
|
33
|
+
* ignored — there is no GL backend to route it to).
|
|
34
|
+
*/
|
|
35
|
+
function requestAdapter(
|
|
36
|
+
_opts?: RequestAdapterOptions | undefined,
|
|
37
|
+
_compatibleSurface?: HTMLCanvasElement | OffscreenCanvas | undefined,
|
|
38
|
+
): Promise<Result<RhiAdapter, RhiErrorType>> {
|
|
39
|
+
return Promise.resolve(ok(new RhiNullAdapter()));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `rhi` singleton — RhiBackendPack-shaped entry for Channel 1 injection
|
|
44
|
+
* (`createRenderer(canvas, { rhi })`). Carries acquireCanvasContext (R-1) so
|
|
45
|
+
* the facade never crashes on a missing method, and createShaderModule (R-2) so
|
|
46
|
+
* the ready chain's shader step resolves rather than rejecting.
|
|
47
|
+
*/
|
|
48
|
+
export const rhi: RhiInstance & {
|
|
49
|
+
acquireCanvasContext: typeof acquireCanvasContext;
|
|
50
|
+
createShaderModule: typeof createShaderModule;
|
|
51
|
+
} = {
|
|
52
|
+
requestAdapter,
|
|
53
|
+
acquireCanvasContext,
|
|
54
|
+
createShaderModule,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export { RhiNullAdapter } from './adapter';
|
|
58
|
+
export { acquireCanvasContext, RhiNullCanvasContext } from './canvas-context';
|
|
59
|
+
export { RhiNullCommandEncoder } from './command-encoder';
|
|
60
|
+
export { RhiNullDevice } from './device';
|
|
61
|
+
export { RhiNullComputePassEncoder, RhiNullRenderPassEncoder } from './pass-encoders';
|
|
62
|
+
export { RhiNullQueue } from './queue';
|
|
63
|
+
export { createShaderModule } from './shader';
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// @forgeax/engine-rhi-null/src/pass-encoders - headless render / compute pass
|
|
2
|
+
// encoders.
|
|
3
|
+
//
|
|
4
|
+
// Both encoders are no-ops that thread state changes through nothing real; the
|
|
5
|
+
// only side effects are command-stream bookkeeping the M3 unit tests read back:
|
|
6
|
+
// - draw / drawIndexed / drawIndirect / drawIndexedIndirect bump a draw
|
|
7
|
+
// counter (AC-06);
|
|
8
|
+
// - dispatchWorkgroups bumps a dispatch counter;
|
|
9
|
+
// - setVertexBuffer / setBindGroup validate handle ownership against the
|
|
10
|
+
// issuing device's ledger (AC-09 handle-chain consistency) and record the
|
|
11
|
+
// outcome so an assertion can read the most recent validation result
|
|
12
|
+
// without the call site throwing (the spec method form is void).
|
|
13
|
+
//
|
|
14
|
+
// Counters + last-validation live on the encoder instance (public readonly) so
|
|
15
|
+
// a test holding the pass encoder reads them directly; the per-device ledger
|
|
16
|
+
// (Bookkeeper) supplies cross-device validation.
|
|
17
|
+
//
|
|
18
|
+
// Related: requirements AC-04 (pass sequence) + AC-06 (draw count + binding
|
|
19
|
+
// assembly) + AC-09 (handle-chain consistency); research Finding A1 rows 6/7;
|
|
20
|
+
// plan-strategy §3.1 (pass encoder bookkeeping design) + §2 D-1.
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
BindGroup,
|
|
24
|
+
Buffer,
|
|
25
|
+
ComputePipeline,
|
|
26
|
+
RenderPipeline,
|
|
27
|
+
Result,
|
|
28
|
+
RhiComputePassEncoder,
|
|
29
|
+
RhiError as RhiErrorType,
|
|
30
|
+
RhiRenderPassEncoder,
|
|
31
|
+
} from '@forgeax/engine-rhi';
|
|
32
|
+
import { ok } from '@forgeax/engine-types';
|
|
33
|
+
import type { Bookkeeper } from './bookkeeping';
|
|
34
|
+
|
|
35
|
+
/** Shared counter interface that pass encoders bump so the device can aggregate
|
|
36
|
+
* per-frame stats for M3 unit-test readback. */
|
|
37
|
+
export interface PassCounter {
|
|
38
|
+
recordDraw(): void;
|
|
39
|
+
recordDispatch(): void;
|
|
40
|
+
recordBindGroup(): void;
|
|
41
|
+
recordPassName(name: string): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Headless render pass encoder. Records draw counts + the most recent handle
|
|
46
|
+
* validation outcome; all state-setting methods are no-ops.
|
|
47
|
+
*/
|
|
48
|
+
export class RhiNullRenderPassEncoder implements RhiRenderPassEncoder {
|
|
49
|
+
/** Number of draw* calls issued on this pass (AC-06 readback). */
|
|
50
|
+
drawCount = 0;
|
|
51
|
+
bindGroupCount = 0;
|
|
52
|
+
/** Most recent setVertexBuffer / setBindGroup ownership validation; ok unless
|
|
53
|
+
* a foreign handle was passed (AC-09 readback). */
|
|
54
|
+
lastValidation: Result<unknown, RhiErrorType> = ok(undefined);
|
|
55
|
+
|
|
56
|
+
private readonly bookkeeper: Bookkeeper;
|
|
57
|
+
private readonly counter: PassCounter | null;
|
|
58
|
+
readonly passName: string;
|
|
59
|
+
|
|
60
|
+
constructor(bookkeeper: Bookkeeper, counter: PassCounter | null, passName: string) {
|
|
61
|
+
this.bookkeeper = bookkeeper;
|
|
62
|
+
this.counter = counter;
|
|
63
|
+
this.passName = passName;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setPipeline(_pipeline: RenderPipeline): void {}
|
|
67
|
+
|
|
68
|
+
setVertexBuffer(
|
|
69
|
+
_slot: number,
|
|
70
|
+
buffer: Buffer,
|
|
71
|
+
_offset?: number | undefined,
|
|
72
|
+
_size?: number | undefined,
|
|
73
|
+
): void {
|
|
74
|
+
this.lastValidation = this.bookkeeper.validateOwnership(buffer);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setIndexBuffer(
|
|
78
|
+
_buffer: Buffer,
|
|
79
|
+
_format: 'uint16' | 'uint32',
|
|
80
|
+
_offset?: number | undefined,
|
|
81
|
+
_size?: number | undefined,
|
|
82
|
+
): void {}
|
|
83
|
+
|
|
84
|
+
setBindGroup(
|
|
85
|
+
_index: number,
|
|
86
|
+
bindGroup: BindGroup,
|
|
87
|
+
_dynamicOffsetsData?: readonly number[] | Uint32Array | undefined,
|
|
88
|
+
_dynamicOffsetsDataStart?: number | undefined,
|
|
89
|
+
_dynamicOffsetsDataLength?: number | undefined,
|
|
90
|
+
): void {
|
|
91
|
+
this.bindGroupCount++;
|
|
92
|
+
this.counter?.recordBindGroup();
|
|
93
|
+
this.lastValidation = this.bookkeeper.validateOwnership(bindGroup);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
draw(
|
|
97
|
+
_vertexCount: number,
|
|
98
|
+
_instanceCount?: number | undefined,
|
|
99
|
+
_firstVertex?: number | undefined,
|
|
100
|
+
_firstInstance?: number | undefined,
|
|
101
|
+
): void {
|
|
102
|
+
this.drawCount++;
|
|
103
|
+
this.counter?.recordDraw();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
drawIndexed(
|
|
107
|
+
_indexCount: number,
|
|
108
|
+
_instanceCount?: number | undefined,
|
|
109
|
+
_firstIndex?: number | undefined,
|
|
110
|
+
_baseVertex?: number | undefined,
|
|
111
|
+
_firstInstance?: number | undefined,
|
|
112
|
+
): void {
|
|
113
|
+
this.drawCount++;
|
|
114
|
+
this.counter?.recordDraw();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
end(): void {
|
|
118
|
+
this.counter?.recordPassName(this.passName);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setViewport(
|
|
122
|
+
_x: number,
|
|
123
|
+
_y: number,
|
|
124
|
+
_w: number,
|
|
125
|
+
_h: number,
|
|
126
|
+
_minDepth: number,
|
|
127
|
+
_maxDepth: number,
|
|
128
|
+
): void {}
|
|
129
|
+
|
|
130
|
+
setScissorRect(_x: number, _y: number, _w: number, _h: number): void {}
|
|
131
|
+
|
|
132
|
+
setBlendConstant(_color: GPUColor): void {}
|
|
133
|
+
|
|
134
|
+
setStencilReference(_reference: number): void {}
|
|
135
|
+
|
|
136
|
+
drawIndirect(_indirectBuffer: Buffer, _indirectOffset: number): void {
|
|
137
|
+
this.drawCount++;
|
|
138
|
+
this.counter?.recordDraw();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
drawIndexedIndirect(_indirectBuffer: Buffer, _indirectOffset: number): void {
|
|
142
|
+
this.drawCount++;
|
|
143
|
+
this.counter?.recordDraw();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
pushDebugGroup(_groupLabel: string): void {}
|
|
147
|
+
|
|
148
|
+
popDebugGroup(): void {}
|
|
149
|
+
|
|
150
|
+
insertDebugMarker(_markerLabel: string): void {}
|
|
151
|
+
|
|
152
|
+
executeBundles(_bundles: Iterable<unknown>): Result<void, RhiErrorType> {
|
|
153
|
+
// Headless no-op: executing zero bundles against no GPU succeeds vacuously
|
|
154
|
+
// (plan-strategy §3.1 — pass-encoder Result methods return ok(void)).
|
|
155
|
+
return ok(undefined);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
beginOcclusionQuery(_queryIndex: number): Result<void, RhiErrorType> {
|
|
159
|
+
return ok(undefined);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
endOcclusionQuery(): Result<void, RhiErrorType> {
|
|
163
|
+
return ok(undefined);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Headless compute pass encoder. Records dispatch counts; all state-setting
|
|
169
|
+
* methods are no-ops.
|
|
170
|
+
*/
|
|
171
|
+
export class RhiNullComputePassEncoder implements RhiComputePassEncoder {
|
|
172
|
+
/** Number of dispatchWorkgroups calls issued on this pass (readback). */
|
|
173
|
+
dispatchCount = 0;
|
|
174
|
+
/** Most recent setBindGroup ownership validation (AC-09 readback). */
|
|
175
|
+
lastValidation: Result<unknown, RhiErrorType> = ok(undefined);
|
|
176
|
+
|
|
177
|
+
private readonly bookkeeper: Bookkeeper;
|
|
178
|
+
private readonly counter: PassCounter | null;
|
|
179
|
+
readonly passName: string;
|
|
180
|
+
|
|
181
|
+
constructor(bookkeeper: Bookkeeper, counter: PassCounter | null, passName: string) {
|
|
182
|
+
this.bookkeeper = bookkeeper;
|
|
183
|
+
this.counter = counter;
|
|
184
|
+
this.passName = passName;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
setPipeline(_pipeline: ComputePipeline): void {}
|
|
188
|
+
|
|
189
|
+
setBindGroup(
|
|
190
|
+
_index: number,
|
|
191
|
+
bindGroup: BindGroup,
|
|
192
|
+
_dynamicOffsets?: readonly number[] | undefined,
|
|
193
|
+
): void {
|
|
194
|
+
this.lastValidation = this.bookkeeper.validateOwnership(bindGroup);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
dispatchWorkgroups(_x: number, _y?: number | undefined, _z?: number | undefined): void {
|
|
198
|
+
this.dispatchCount++;
|
|
199
|
+
this.counter?.recordDispatch();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
dispatchWorkgroupsIndirect(indirectBuffer: Buffer, _indirectOffset: number): void {
|
|
203
|
+
this.lastValidation = this.bookkeeper.validateOwnership(indirectBuffer);
|
|
204
|
+
this.dispatchCount++;
|
|
205
|
+
this.counter?.recordDispatch();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
end(): void {
|
|
209
|
+
this.counter?.recordPassName(this.passName);
|
|
210
|
+
}
|
|
211
|
+
}
|