@forgeax/engine-rhi-null 0.0.0-dev.8d955ade1c79

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/src/device.ts ADDED
@@ -0,0 +1,258 @@
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
+ /** Monotonic device-id source so each RhiNullDevice owns a distinct id; the id
60
+ * threads into the Bookkeeper for cross-device handle-chain validation. */
61
+ let nextDeviceId = 0;
62
+
63
+ /**
64
+ * Factory that builds a command encoder bound to a device's ledger. Injected at
65
+ * device construction (rather than imported here) so device.ts carries no
66
+ * dependency on command-encoder.ts; the singleton assembly (index.ts) supplies
67
+ * the real factory. The Bookkeeper and RhiNullDevice are passed so the encoder
68
+ * threads draw / dispatch counts + binding validation through the same per-device
69
+ * ledger AND writes aggregated frame stats to the device for M3 unit-test readback.
70
+ */
71
+ export type CommandEncoderFactory = (
72
+ bookkeeper: Bookkeeper,
73
+ device: RhiNullDevice,
74
+ ) => RhiCommandEncoder;
75
+
76
+ /** A pipeline brand augmented with its no-op `getBindGroupLayout` ops method
77
+ * (D-2). createRenderPipeline / createComputePipeline return objects of this
78
+ * shape so the auto-layout consumers can call getBindGroupLayout. */
79
+ type PipelineHandle<Brand> = Brand & RhiRenderPipelineOps & RhiComputePipelineOps;
80
+
81
+ /**
82
+ * Headless no-op RhiDevice. Every create* mints a legal brand and records it;
83
+ * every destroy* fail-fasts a double-destroy; caps reports the all-true-except-
84
+ * reserved profile (D-5).
85
+ */
86
+ export class RhiNullDevice implements RhiDevice {
87
+ private readonly internalBookkeeper: Bookkeeper;
88
+ private readonly nullQueue: RhiQueue;
89
+ private readonly encoderFactory: CommandEncoderFactory;
90
+
91
+ /** Per-frame total draw count across all pass encoders executed this frame
92
+ * (aggregated by the command encoder on finish, then reset). M3 unit tests
93
+ * (w17) read this to assert draw count >= 1 (AC-06). */
94
+ totalDrawCount = 0;
95
+ /** Per-frame total direct and indirect compute dispatch count. */
96
+ totalDispatchCount = 0;
97
+ /** Per-frame total bind group set count (AC-06 / AC-05 readback). */
98
+ totalBindGroupCount = 0;
99
+ /** Per-frame pass names executed this frame, in schedule order (AC-04). */
100
+ framePassNames: string[] = [];
101
+
102
+ /** The per-device handle ledger — exposed so M3 tests can assert create/destroy
103
+ * pairing and BGL/PSO shape counts (AC-05/06/07). */
104
+ get bookkeeper(): Bookkeeper {
105
+ return this.internalBookkeeper;
106
+ }
107
+
108
+ constructor(queue: RhiQueue, encoderFactory: CommandEncoderFactory) {
109
+ this.internalBookkeeper = new Bookkeeper(nextDeviceId++);
110
+ this.nullQueue = queue;
111
+ this.encoderFactory = encoderFactory;
112
+ }
113
+
114
+ get caps(): RhiCaps {
115
+ return {
116
+ backendKind: 'null',
117
+ compute: true,
118
+ timestampQuery: false,
119
+ timestampPeriodNanoseconds: null,
120
+ indirectDrawing: true,
121
+ textureCompressionBc: false,
122
+ textureCompressionEtc2: false,
123
+ textureCompressionAstc: false,
124
+ // 3 wgpu-native-only reserved flags stay false on non-native backends
125
+ // (D-5); the headless backend is not a native runtime.
126
+ multiDrawIndirect: false,
127
+ pushConstants: false,
128
+ textureBindingArray: false,
129
+ samplerAliasing: true,
130
+ firstInstanceIndirect: true,
131
+ storageBuffer: true,
132
+ storageTexture: true,
133
+ rgba16floatRenderable: true,
134
+ rg11b10ufloatRenderable: true,
135
+ float32Filterable: true,
136
+ maxColorAttachments: 8,
137
+ };
138
+ }
139
+
140
+ get features(): RhiFeatures {
141
+ return EMPTY_FEATURES;
142
+ }
143
+
144
+ get limits(): RhiLimits {
145
+ return EMPTY_LIMITS;
146
+ }
147
+
148
+ get queue(): RhiQueue {
149
+ return this.nullQueue;
150
+ }
151
+
152
+ // forgeax-async-whitelist: dom-native — spec `GPUDevice.lost` Promise
153
+ // passthrough. The headless backend never loses a device (no GPU), so the
154
+ // Promise stays unsettled for the lifetime of the device, mirroring a live
155
+ // device that never transitions to the lost state.
156
+ get lost(): Promise<{ readonly reason: 'destroyed' | 'unknown'; readonly message: string }> {
157
+ return NEVER;
158
+ }
159
+
160
+ createBuffer(_desc: BufferDescriptor): Result<Buffer, RhiErrorType> {
161
+ return ok(this.internalBookkeeper.register('Buffer') as unknown as Buffer);
162
+ }
163
+
164
+ createTexture(_desc: TextureDescriptor): Result<Texture, RhiErrorType> {
165
+ return ok(this.internalBookkeeper.register('Texture') as unknown as Texture);
166
+ }
167
+
168
+ destroyBuffer(buf: Buffer): Result<void, RhiErrorType> {
169
+ return this.internalBookkeeper.destroy(buf);
170
+ }
171
+
172
+ destroyQuerySet(querySet: QuerySet): Result<void, RhiErrorType> {
173
+ return this.internalBookkeeper.destroy(querySet);
174
+ }
175
+
176
+ destroyTexture(tex: Texture): Result<void, RhiErrorType> {
177
+ return this.internalBookkeeper.destroy(tex);
178
+ }
179
+
180
+ createTextureView(
181
+ _texture: Texture,
182
+ _desc: TextureViewDescriptor,
183
+ ): Result<TextureView, RhiErrorType> {
184
+ return ok(this.internalBookkeeper.register('TextureView') as unknown as TextureView);
185
+ }
186
+
187
+ createSampler(_desc?: SamplerDescriptor | undefined): Result<Sampler, RhiErrorType> {
188
+ return ok(this.internalBookkeeper.register('Sampler') as unknown as Sampler);
189
+ }
190
+
191
+ createBindGroupLayout(_desc: BindGroupLayoutDescriptor): Result<BindGroupLayout, RhiErrorType> {
192
+ return ok(this.internalBookkeeper.register('BindGroupLayout') as unknown as BindGroupLayout);
193
+ }
194
+
195
+ createBindGroup(_desc: BindGroupDescriptor): Result<BindGroup, RhiErrorType> {
196
+ return ok(this.internalBookkeeper.register('BindGroup') as unknown as BindGroup);
197
+ }
198
+
199
+ createPipelineLayout(_desc: PipelineLayoutDescriptor): Result<PipelineLayout, RhiErrorType> {
200
+ return ok(this.internalBookkeeper.register('PipelineLayout') as unknown as PipelineLayout);
201
+ }
202
+
203
+ createRenderPipeline(_desc: RenderPipelineDescriptor): Result<RenderPipeline, RhiErrorType> {
204
+ return ok(this.makePipeline<RenderPipeline>('RenderPipeline'));
205
+ }
206
+
207
+ createComputePipeline(_desc: ComputePipelineDescriptor): Result<ComputePipeline, RhiErrorType> {
208
+ return ok(this.makePipeline<ComputePipeline>('ComputePipeline'));
209
+ }
210
+
211
+ createQuerySet(desc: QuerySetDescriptor): Result<QuerySet, RhiErrorType> {
212
+ if (desc.type === 'timestamp') {
213
+ return err(
214
+ new RhiErrorClass({
215
+ code: 'feature-not-enabled',
216
+ expected: 'caps.timestampQuery === true (timestamp-query feature)',
217
+ hint: 'RhiNull is structural-only and cannot produce GPU timestamp ticks',
218
+ }),
219
+ );
220
+ }
221
+ return ok(this.internalBookkeeper.register('QuerySet') as unknown as QuerySet);
222
+ }
223
+
224
+ createCommandEncoder(
225
+ _desc?: CommandEncoderDescriptor | undefined,
226
+ ): Result<RhiCommandEncoder, RhiErrorType> {
227
+ return ok(this.encoderFactory(this.internalBookkeeper, this));
228
+ }
229
+
230
+ /**
231
+ * Mint a pipeline handle whose object also carries the no-op
232
+ * `getBindGroupLayout(index)` ops method (D-2). The prod auto-layout path
233
+ * (debug-draw.ts) and the existing mock unit tests both call
234
+ * `pipeline.getBindGroupLayout(n)`; returning a legal BindGroupLayout brand
235
+ * (recorded in the ledger) keeps those consumers from crashing on a missing
236
+ * method.
237
+ */
238
+ private makePipeline<Brand>(kind: string): PipelineHandle<Brand> {
239
+ const handle = this.internalBookkeeper.register(kind);
240
+ const getBindGroupLayout = (_index: number): BindGroupLayout =>
241
+ this.internalBookkeeper.register('BindGroupLayout') as unknown as BindGroupLayout;
242
+ return Object.assign(handle, { getBindGroupLayout }) as unknown as PipelineHandle<Brand>;
243
+ }
244
+ }
245
+
246
+ /** Empty enabled-feature set — headless backend enables nothing beyond the
247
+ * always-true caps profile (research Finding A1: features getter returns an
248
+ * empty ReadonlySet). */
249
+ const EMPTY_FEATURES: RhiFeatures = new Set() as RhiFeatures;
250
+
251
+ /** Empty numeric-limits map. The headless backend reports no concrete numeric
252
+ * limits; capability planning reads caps booleans instead. */
253
+ const EMPTY_LIMITS: RhiLimits = {} as RhiLimits;
254
+
255
+ /** A Promise that never settles, mirroring a live GPUDevice.lost that stays
256
+ * unsettled while the device is healthy. */
257
+ const NEVER: Promise<{ readonly reason: 'destroyed' | 'unknown'; readonly message: string }> =
258
+ new Promise(() => {});
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 renderer options.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
+ }
package/src/queue.ts ADDED
@@ -0,0 +1,61 @@
1
+ // @forgeax/engine-rhi-null/src/queue - headless command queue.
2
+ //
3
+ // All write* methods are no-ops returning ok; submit records nothing and
4
+ // returns ok (AC-12). onSubmittedWorkDone resolves immediately so headless
5
+ // read-back idioms never hang (the real GPU resolves after work completes; with
6
+ // no pending work the headless backend resolves synchronously).
7
+ //
8
+ // Related: requirements AC-12 (submit ok + onSubmittedWorkDone resolves);
9
+ // research Finding A1 row 4.
10
+
11
+ import type {
12
+ Buffer,
13
+ CommandBuffer,
14
+ ExternalImageTextureDestination,
15
+ Result,
16
+ RhiError as RhiErrorType,
17
+ RhiQueue,
18
+ TextureWriteDestination,
19
+ } from '@forgeax/engine-rhi';
20
+ import { ok } from '@forgeax/engine-types';
21
+
22
+ /** Headless no-op queue. */
23
+ export class RhiNullQueue implements RhiQueue {
24
+ writeBuffer(
25
+ _buffer: Buffer,
26
+ _bufferOffset: number,
27
+ _data: ArrayBufferView | ArrayBuffer,
28
+ _dataOffset?: number | undefined,
29
+ _size?: number | undefined,
30
+ ): Result<void, RhiErrorType> {
31
+ return ok(undefined);
32
+ }
33
+
34
+ writeTexture(
35
+ _destination: TextureWriteDestination,
36
+ _data: ArrayBufferView | ArrayBuffer,
37
+ _dataLayout: Pick<GPUTexelCopyBufferLayout, 'offset' | 'bytesPerRow' | 'rowsPerImage'>,
38
+ _size: GPUExtent3DStrict,
39
+ ): Result<void, RhiErrorType> {
40
+ return ok(undefined);
41
+ }
42
+
43
+ copyExternalImageToTexture(
44
+ _source: Pick<GPUCopyExternalImageSourceInfo, 'source' | 'origin' | 'flipY'>,
45
+ _destination: ExternalImageTextureDestination,
46
+ _copySize: GPUExtent3DStrict,
47
+ ): Result<void, RhiErrorType> {
48
+ return ok(undefined);
49
+ }
50
+
51
+ submit(_commandBuffers: readonly CommandBuffer[]): Result<void, RhiErrorType> {
52
+ return ok(undefined);
53
+ }
54
+
55
+ // forgeax-async-whitelist: dom-native — spec `GPUQueue.onSubmittedWorkDone`
56
+ // never rejects. The headless backend has no pending GPU work, so it resolves
57
+ // immediately (AC-12: read-back idioms must not hang).
58
+ onSubmittedWorkDone(): Promise<undefined> {
59
+ return Promise.resolve(undefined);
60
+ }
61
+ }
package/src/shader.ts ADDED
@@ -0,0 +1,37 @@
1
+ // @forgeax/engine-rhi-null/src/shader - headless shader module factory (R-2).
2
+ //
3
+ // createShaderModule is the highest-risk hidden contract: the createRenderer
4
+ // ready chain resolves a shader module through a three-tier fallback
5
+ // (RhiBackendPack.createShaderModule -> RhiDevice.createShaderModule duck-typed
6
+ // -> err('rhi-not-available')). If RhiNull provides neither, createRenderer ->
7
+ // ready REJECTS rhi-not-available and AC-02 / AC-10 / AC-13 all fail. RhiNull
8
+ // exposes the top-level async factory (symmetric with rhi-webgpu) that skips
9
+ // real WGSL compilation and returns a legal ShaderModule brand.
10
+ //
11
+ // Related: requirements AC-10 (shader brand skips compile) + AC-02
12
+ // (createRenderer ready does not reject); research Finding A5 (three-tier
13
+ // fallback, top-level factory recommended) + A6 (brand construction);
14
+ // plan-strategy §4 R-2.
15
+
16
+ import type {
17
+ Result,
18
+ RhiDevice,
19
+ RhiError as RhiErrorType,
20
+ ShaderModule,
21
+ } from '@forgeax/engine-rhi';
22
+ import { ok } from '@forgeax/engine-types';
23
+
24
+ /**
25
+ * Build a shader module for the headless backend. No WGSL is compiled — the
26
+ * code is ignored and a legal ShaderModule brand is returned immediately
27
+ * (<1ms), so the ready chain's shader step always resolves ok (AC-10). Mirrors
28
+ * the rhi-webgpu top-level `createShaderModule(device, desc)` signature so
29
+ * Channel 1's RhiBackendPack picks it up via the same `'createShaderModule' in
30
+ * mod` probe.
31
+ */
32
+ export function createShaderModule(
33
+ _device: RhiDevice,
34
+ _desc: { label?: string | undefined; code: string },
35
+ ): Promise<Result<ShaderModule, RhiErrorType>> {
36
+ return Promise.resolve(ok({} as unknown as ShaderModule));
37
+ }