@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/LICENSE +202 -0
- package/README.md +129 -0
- package/dist/.tsbuildinfo +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 +67 -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/adapter.ts +40 -0
- package/src/bookkeeping.ts +179 -0
- package/src/canvas-context.ts +71 -0
- package/src/command-encoder.ts +146 -0
- package/src/device.ts +258 -0
- package/src/index.ts +63 -0
- package/src/pass-encoders.ts +211 -0
- package/src/queue.ts +61 -0
- package/src/shader.ts +37 -0
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
|
+
}
|