@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.
@@ -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
+ }