@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.
@@ -0,0 +1,65 @@
1
+ import type { BindGroup, Buffer, ComputePipeline, RenderPipeline, Result, RhiComputePassEncoder, RhiError as RhiErrorType, RhiRenderPassEncoder } from '@forgeax/engine-rhi';
2
+ import type { Bookkeeper } from './bookkeeping';
3
+ /** Shared counter interface that pass encoders bump so the device can aggregate
4
+ * per-frame stats for M3 unit-test readback. */
5
+ export interface PassCounter {
6
+ recordDraw(): void;
7
+ recordDispatch(): void;
8
+ recordBindGroup(): void;
9
+ recordPassName(name: string): void;
10
+ }
11
+ /**
12
+ * Headless render pass encoder. Records draw counts + the most recent handle
13
+ * validation outcome; all state-setting methods are no-ops.
14
+ */
15
+ export declare class RhiNullRenderPassEncoder implements RhiRenderPassEncoder {
16
+ /** Number of draw* calls issued on this pass (AC-06 readback). */
17
+ drawCount: number;
18
+ bindGroupCount: number;
19
+ /** Most recent setVertexBuffer / setBindGroup ownership validation; ok unless
20
+ * a foreign handle was passed (AC-09 readback). */
21
+ lastValidation: Result<unknown, RhiErrorType>;
22
+ private readonly bookkeeper;
23
+ private readonly counter;
24
+ readonly passName: string;
25
+ constructor(bookkeeper: Bookkeeper, counter: PassCounter | null, passName: string);
26
+ setPipeline(_pipeline: RenderPipeline): void;
27
+ setVertexBuffer(_slot: number, buffer: Buffer, _offset?: number | undefined, _size?: number | undefined): void;
28
+ setIndexBuffer(_buffer: Buffer, _format: 'uint16' | 'uint32', _offset?: number | undefined, _size?: number | undefined): void;
29
+ setBindGroup(_index: number, bindGroup: BindGroup, _dynamicOffsetsData?: readonly number[] | Uint32Array | undefined, _dynamicOffsetsDataStart?: number | undefined, _dynamicOffsetsDataLength?: number | undefined): void;
30
+ draw(_vertexCount: number, _instanceCount?: number | undefined, _firstVertex?: number | undefined, _firstInstance?: number | undefined): void;
31
+ drawIndexed(_indexCount: number, _instanceCount?: number | undefined, _firstIndex?: number | undefined, _baseVertex?: number | undefined, _firstInstance?: number | undefined): void;
32
+ end(): void;
33
+ setViewport(_x: number, _y: number, _w: number, _h: number, _minDepth: number, _maxDepth: number): void;
34
+ setScissorRect(_x: number, _y: number, _w: number, _h: number): void;
35
+ setBlendConstant(_color: GPUColor): void;
36
+ setStencilReference(_reference: number): void;
37
+ drawIndirect(_indirectBuffer: Buffer, _indirectOffset: number): void;
38
+ drawIndexedIndirect(_indirectBuffer: Buffer, _indirectOffset: number): void;
39
+ pushDebugGroup(_groupLabel: string): void;
40
+ popDebugGroup(): void;
41
+ insertDebugMarker(_markerLabel: string): void;
42
+ executeBundles(_bundles: Iterable<unknown>): Result<void, RhiErrorType>;
43
+ beginOcclusionQuery(_queryIndex: number): Result<void, RhiErrorType>;
44
+ endOcclusionQuery(): Result<void, RhiErrorType>;
45
+ }
46
+ /**
47
+ * Headless compute pass encoder. Records dispatch counts; all state-setting
48
+ * methods are no-ops.
49
+ */
50
+ export declare class RhiNullComputePassEncoder implements RhiComputePassEncoder {
51
+ /** Number of dispatchWorkgroups calls issued on this pass (readback). */
52
+ dispatchCount: number;
53
+ /** Most recent setBindGroup ownership validation (AC-09 readback). */
54
+ lastValidation: Result<unknown, RhiErrorType>;
55
+ private readonly bookkeeper;
56
+ private readonly counter;
57
+ readonly passName: string;
58
+ constructor(bookkeeper: Bookkeeper, counter: PassCounter | null, passName: string);
59
+ setPipeline(_pipeline: ComputePipeline): void;
60
+ setBindGroup(_index: number, bindGroup: BindGroup, _dynamicOffsets?: readonly number[] | undefined): void;
61
+ dispatchWorkgroups(_x: number, _y?: number | undefined, _z?: number | undefined): void;
62
+ dispatchWorkgroupsIndirect(indirectBuffer: Buffer, _indirectOffset: number): void;
63
+ end(): void;
64
+ }
65
+ //# sourceMappingURL=pass-encoders.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pass-encoders.d.ts","sourceRoot":"","sources":["../src/pass-encoders.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ import type { Buffer, CommandBuffer, ExternalImageTextureDestination, Result, RhiError as RhiErrorType, RhiQueue, TextureWriteDestination } from '@forgeax/engine-rhi';
2
+ /** Headless no-op queue. */
3
+ export declare class RhiNullQueue implements RhiQueue {
4
+ writeBuffer(_buffer: Buffer, _bufferOffset: number, _data: ArrayBufferView | ArrayBuffer, _dataOffset?: number | undefined, _size?: number | undefined): Result<void, RhiErrorType>;
5
+ writeTexture(_destination: TextureWriteDestination, _data: ArrayBufferView | ArrayBuffer, _dataLayout: Pick<GPUTexelCopyBufferLayout, 'offset' | 'bytesPerRow' | 'rowsPerImage'>, _size: GPUExtent3DStrict): Result<void, RhiErrorType>;
6
+ copyExternalImageToTexture(_source: Pick<GPUCopyExternalImageSourceInfo, 'source' | 'origin' | 'flipY'>, _destination: ExternalImageTextureDestination, _copySize: GPUExtent3DStrict): Result<void, RhiErrorType>;
7
+ submit(_commandBuffers: readonly CommandBuffer[]): Result<void, RhiErrorType>;
8
+ onSubmittedWorkDone(): Promise<undefined>;
9
+ }
10
+ //# sourceMappingURL=queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EACV,MAAM,EACN,aAAa,EACb,+BAA+B,EAC/B,MAAM,EACN,QAAQ,IAAI,YAAY,EACxB,QAAQ,EACR,uBAAuB,EACxB,MAAM,qBAAqB,CAAC;AAG7B,4BAA4B;AAC5B,qBAAa,YAAa,YAAW,QAAQ;IAC3C,WAAW,CACT,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,KAAK,EAAE,eAAe,GAAG,WAAW,EACpC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,EAChC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,GACzB,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC;IAI7B,YAAY,CACV,YAAY,EAAE,uBAAuB,EACrC,KAAK,EAAE,eAAe,GAAG,WAAW,EACpC,WAAW,EAAE,IAAI,CAAC,wBAAwB,EAAE,QAAQ,GAAG,aAAa,GAAG,cAAc,CAAC,EACtF,KAAK,EAAE,iBAAiB,GACvB,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC;IAI7B,0BAA0B,CACxB,OAAO,EAAE,IAAI,CAAC,8BAA8B,EAAE,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC,EAC5E,YAAY,EAAE,+BAA+B,EAC7C,SAAS,EAAE,iBAAiB,GAC3B,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC;IAI7B,MAAM,CAAC,eAAe,EAAE,SAAS,aAAa,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC;IAO7E,mBAAmB,IAAI,OAAO,CAAC,SAAS,CAAC;CAG1C"}
@@ -0,0 +1,14 @@
1
+ import type { Result, RhiDevice, RhiError as RhiErrorType, ShaderModule } from '@forgeax/engine-rhi';
2
+ /**
3
+ * Build a shader module for the headless backend. No WGSL is compiled — the
4
+ * code is ignored and a legal ShaderModule brand is returned immediately
5
+ * (<1ms), so the ready chain's shader step always resolves ok (AC-10). Mirrors
6
+ * the rhi-webgpu top-level `createShaderModule(device, desc)` signature so
7
+ * Channel 1's RhiBackendPack picks it up via the same `'createShaderModule' in
8
+ * mod` probe.
9
+ */
10
+ export declare function createShaderModule(_device: RhiDevice, _desc: {
11
+ label?: string | undefined;
12
+ code: string;
13
+ }): Promise<Result<ShaderModule, RhiErrorType>>;
14
+ //# sourceMappingURL=shader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shader.d.ts","sourceRoot":"","sources":["../src/shader.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EACV,MAAM,EACN,SAAS,EACT,QAAQ,IAAI,YAAY,EACxB,YAAY,EACb,MAAM,qBAAqB,CAAC;AAG7B;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,SAAS,EAClB,KAAK,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAClD,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,YAAY,CAAC,CAAC,CAE7C"}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@forgeax/engine-rhi-null",
3
+ "version": "0.0.0-dev.8d955ade1c79",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "sideEffects": false,
8
+ "description": "Headless no-op RHI backend for structural unit tests — implements @forgeax/engine-rhi with zero GPU/DOM dependency.",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "main": "./dist/index.mjs",
17
+ "types": "./dist/index.d.ts",
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "dependencies": {
25
+ "@forgeax/engine-rhi": "0.0.0-dev.8d955ade1c79",
26
+ "@forgeax/engine-types": "0.0.0-dev.8d955ade1c79",
27
+ "@webgpu/types": "^0.1.71"
28
+ },
29
+ "forgeax": {
30
+ "metrics": {
31
+ "bundle-size": {
32
+ "enabled": true,
33
+ "path": "dist/index.mjs",
34
+ "compression": "gzip"
35
+ },
36
+ "fps": {
37
+ "enabled": false,
38
+ "reason": "headless no-op backend has no runtime canvas; no fps to report"
39
+ },
40
+ "bench": {
41
+ "enabled": false,
42
+ "reason": "no-op backend has no hot path; bench focus stays on real backends"
43
+ },
44
+ "gate": {
45
+ "enabled": false,
46
+ "reason": "no standalone binary gate; structural assertions provided by runtime dogfood unit tests consuming RhiNull via Channel 1"
47
+ },
48
+ "spike-report": {
49
+ "enabled": false,
50
+ "reason": "not a spike package"
51
+ }
52
+ }
53
+ },
54
+ "scripts": {
55
+ "build": "tsup",
56
+ "test": "vitest run"
57
+ }
58
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,40 @@
1
+ // @forgeax/engine-rhi-null/src/adapter - headless adapter.
2
+ //
3
+ // requestDevice mints a fresh RhiNullDevice, wiring its queue + command-encoder
4
+ // factory. features / limits are empty (the headless backend enables nothing
5
+ // beyond the always-true caps profile); the two-step requestAdapter ->
6
+ // requestDevice path mirrors the spec idiom (research Finding A1 row 2).
7
+ //
8
+ // Related: requirements AC-02 (createRenderer ready chain needs adapter ->
9
+ // device) + AC-12; research Finding A1 row 2.
10
+
11
+ import type {
12
+ RequestDeviceOptions,
13
+ Result,
14
+ RhiAdapter,
15
+ RhiDevice,
16
+ RhiError as RhiErrorType,
17
+ } from '@forgeax/engine-rhi';
18
+ import { ok } from '@forgeax/engine-types';
19
+ import { RhiNullCommandEncoder } from './command-encoder';
20
+ import { RhiNullDevice } from './device';
21
+ import { RhiNullQueue } from './queue';
22
+
23
+ /** Headless adapter. Holds an empty feature set + limits map; requestDevice
24
+ * builds a fully-wired RhiNullDevice. */
25
+ export class RhiNullAdapter implements RhiAdapter {
26
+ readonly features: ReadonlySet<GPUFeatureName> = new Set();
27
+ readonly limits: Readonly<Record<string, number>> = {};
28
+
29
+ // forgeax-async-whitelist is not needed: this returns Promise<Result<...>>
30
+ // per the spec contract; never rejects.
31
+ requestDevice(
32
+ _opts?: RequestDeviceOptions | undefined,
33
+ ): Promise<Result<RhiDevice, RhiErrorType>> {
34
+ const device = new RhiNullDevice(
35
+ new RhiNullQueue(),
36
+ (bookkeeper, dev) => new RhiNullCommandEncoder(bookkeeper, dev),
37
+ );
38
+ return Promise.resolve(ok(device));
39
+ }
40
+ }
@@ -0,0 +1,179 @@
1
+ // @forgeax/engine-rhi-null/src/bookkeeping - method-A handle ledger for the
2
+ // headless no-op backend.
3
+ //
4
+ // RhiNull issues legal opaque-handle brands (each RHI brand is a compile-time
5
+ // `unique symbol` with no runtime field, so any plain object cast via
6
+ // `as unknown as Buffer` satisfies the type contract; research Finding A6). To
7
+ // let M3 command-stream assertions read back create/destroy pairing, draw /
8
+ // dispatch counts and binding assembly, RhiNull threads each issued handle
9
+ // through a per-device Bookkeeper that records a numeric id + kind + destroyed
10
+ // flag + the issuing device id.
11
+ //
12
+ // Error contract (plan-strategy D-1): handle-chain inconsistencies reuse the
13
+ // existing closed RhiErrorCode union with ZERO new members:
14
+ // - a handle observed on a device other than its issuer -> 'rhi-not-available'
15
+ // - a destroy / use observed on an already-destroyed handle ->
16
+ // 'destroy-after-destroy'
17
+ // Every RhiError carries the required code / expected / hint triple
18
+ // (charter proposition 3 structured failure).
19
+ //
20
+ // Related: requirements AC-09 (foreign handle returns structured err, no silent
21
+ // pass); plan-strategy §2 D-1; research Finding A3 (RhiErrorCode 20 members,
22
+ // 'rhi-not-available' at errors.ts:113, 'destroy-after-destroy' at errors.ts:127)
23
+ // + Finding A6 (brand construction); charter P3.
24
+
25
+ import type { Result, RhiError as RhiErrorType } from '@forgeax/engine-rhi';
26
+ import { RhiError } from '@forgeax/engine-rhi';
27
+ import { err, ok } from '@forgeax/engine-types';
28
+
29
+ /**
30
+ * One ledger row for an issued opaque-handle brand.
31
+ *
32
+ * - `id` — per-device monotonic numeric id assigned at register time; the value
33
+ * M3 assertions read back to pair create / destroy and count draws.
34
+ * - `kind` — the handle category (`'Buffer'` / `'Texture'` / `'BindGroupLayout'`
35
+ * etc.); free-form so every create* entry can tag what it minted.
36
+ * - `destroyed` — flipped to `true` by the first destroy; a second destroy
37
+ * fail-fasts via `'destroy-after-destroy'`.
38
+ * - `sourceDeviceId` — the id of the device that issued the handle; cross-device
39
+ * use fail-fasts via `'rhi-not-available'`.
40
+ */
41
+ export interface HandleRecord {
42
+ readonly id: number;
43
+ readonly kind: string;
44
+ destroyed: boolean;
45
+ readonly sourceDeviceId: number;
46
+ }
47
+
48
+ /**
49
+ * The brand attachment RhiNull stamps onto every issued handle object so the
50
+ * ledger can be recovered from the handle alone (handle-chain validation reads
51
+ * `handle[BOOKKEEPING_KEY]` rather than holding a side Map keyed on identity,
52
+ * which would leak across device instances).
53
+ *
54
+ * Lives behind a module-private symbol so it never collides with a real RHI
55
+ * brand symbol and never appears in the public `.d.ts` surface.
56
+ */
57
+ export const BOOKKEEPING_KEY = Symbol('forgeax-rhi-null-bookkeeping');
58
+
59
+ /** A handle object as RhiNull mints it: a plain object carrying its ledger row
60
+ * under the private bookkeeping symbol, cast to the relevant brand at the
61
+ * create* call site. */
62
+ export interface BookkeptHandle {
63
+ readonly [BOOKKEEPING_KEY]: HandleRecord;
64
+ }
65
+
66
+ /**
67
+ * Per-device handle ledger. One Bookkeeper instance lives on each
68
+ * RhiNullDevice; it assigns the device id at construction and mints monotonic
69
+ * handle ids thereafter.
70
+ */
71
+ export class Bookkeeper {
72
+ readonly deviceId: number;
73
+ private nextHandleId = 0;
74
+ private readonly records = new Map<number, HandleRecord>();
75
+
76
+ constructor(deviceId: number) {
77
+ this.deviceId = deviceId;
78
+ }
79
+
80
+ /**
81
+ * Register a freshly-minted handle of the given kind, returning a plain
82
+ * object that carries its ledger row. The caller casts the return value to
83
+ * the concrete brand (`as unknown as Buffer` etc.).
84
+ */
85
+ register(kind: string): BookkeptHandle {
86
+ const id = this.nextHandleId++;
87
+ const record: HandleRecord = {
88
+ id,
89
+ kind,
90
+ destroyed: false,
91
+ sourceDeviceId: this.deviceId,
92
+ };
93
+ this.records.set(id, record);
94
+ return { [BOOKKEEPING_KEY]: record };
95
+ }
96
+
97
+ /**
98
+ * Mark a handle destroyed. Fail-fasts on a second destroy
99
+ * ('destroy-after-destroy') or on a handle issued by a different device
100
+ * ('rhi-not-available'); otherwise flips the destroyed flag and returns ok.
101
+ */
102
+ destroy(handle: unknown): Result<void, RhiErrorType> {
103
+ const ownership = this.validateOwnership(handle);
104
+ if (!ownership.ok) return ownership;
105
+ const destroyedCheck = isHandleDestroyed(handle);
106
+ if (!destroyedCheck.ok) return destroyedCheck;
107
+ ownership.value.destroyed = true;
108
+ return ok(undefined);
109
+ }
110
+
111
+ /**
112
+ * Report whether a handle has already been destroyed (true once destroy()
113
+ * has flipped its flag). Used by command-stream methods that must not
114
+ * consume a stale handle.
115
+ */
116
+ isDestroyed(handle: unknown): boolean {
117
+ return readRecord(handle)?.destroyed === true;
118
+ }
119
+
120
+ /**
121
+ * Validate that a handle was issued by THIS device. Returns the ledger row on
122
+ * success so callers can mutate it (e.g. flip destroyed); returns
123
+ * 'rhi-not-available' for a foreign handle (AC-09 — no silent pass).
124
+ */
125
+ validateOwnership(handle: unknown): Result<HandleRecord, RhiErrorType> {
126
+ const record = readRecord(handle);
127
+ if (record === undefined || record.sourceDeviceId !== this.deviceId) {
128
+ return err(
129
+ new RhiError({
130
+ code: 'rhi-not-available',
131
+ expected: 'handle was issued by this RhiNull device',
132
+ hint: 'do not pass a handle created on a different RhiNull device into this device; create resources on the device they are used with',
133
+ }),
134
+ );
135
+ }
136
+ return ok(record);
137
+ }
138
+
139
+ /**
140
+ * Return all ledger rows as a readonly array. M3 unit tests (w17) read this
141
+ * to assert create/destroy pairing, BGL/PSO shape counts, and resource
142
+ * lifecycle coverage (AC-05/06/07). Returns a snapshot of the current Map
143
+ * so callers can iterate without a stale reference after further mutations.
144
+ */
145
+ allRecords(): readonly HandleRecord[] {
146
+ return [...this.records.values()];
147
+ }
148
+
149
+ /** Report the total number of records in the ledger. */
150
+ recordCount(): number {
151
+ return this.records.size;
152
+ }
153
+ }
154
+
155
+ /** Recover the ledger row from a handle object, or undefined if the object
156
+ * carries no RhiNull bookkeeping (foreign / non-RhiNull handle). */
157
+ function readRecord(handle: unknown): HandleRecord | undefined {
158
+ if (handle === null || typeof handle !== 'object') return undefined;
159
+ const tagged = handle as Partial<BookkeptHandle>;
160
+ return tagged[BOOKKEEPING_KEY];
161
+ }
162
+
163
+ /**
164
+ * Validation function: has this handle already been destroyed? Returns ok(void)
165
+ * for a live handle; returns 'destroy-after-destroy' for a handle whose ledger
166
+ * row is flagged destroyed (charter P3 fail-fast over silent idempotency).
167
+ */
168
+ export function isHandleDestroyed(handle: unknown): Result<void, RhiErrorType> {
169
+ if (readRecord(handle)?.destroyed === true) {
170
+ return err(
171
+ new RhiError({
172
+ code: 'destroy-after-destroy',
173
+ expected: 'GPU buffer/texture handle has not been destroyed yet',
174
+ hint: 'object already destroyed; track lifecycle in caller or check isDestroyed before re-destroy',
175
+ }),
176
+ );
177
+ }
178
+ return ok(undefined);
179
+ }
@@ -0,0 +1,71 @@
1
+ // @forgeax/engine-rhi-null/src/canvas-context - headless canvas context (R-1).
2
+ //
3
+ // The headless backend has no real canvas. RhiNullCanvasContext satisfies the
4
+ // RhiCanvasContext interface as no-ops: configure returns ok (a headless
5
+ // configure is vacuously successful), unconfigure is void, getConfiguration
6
+ // returns undefined (no real configuration).
7
+ //
8
+ // getCurrentTexture returns a legal Texture brand so the swap-chain
9
+ // acquisition path succeeds — without this the entire render-frame path fails
10
+ // at the first swap-chain access, blocking all dogfood testing of
11
+ // render-graph-to-RhiNull command-flow. The returned Texture carries no real
12
+ // pixels; it is opaque-handle syntactically valid and structurally sound for
13
+ // CI structural unit tests.
14
+ //
15
+ // acquireCanvasContext (mounted on the rhi singleton, R-1) accepts any
16
+ // canvas / null / undefined and returns ok(canvasContext) so the facade
17
+ // never crashes and canvas-dependent paths exercise through to the
18
+ // render-graph level.
19
+ //
20
+ // Related: requirements AC-11 (canvas ctx structured behaviour) — the
21
+ // context itself succeeds; AC-11 verify tests confirm the getCurrentTexture
22
+ // brand is a valid opaque handle and downstream consumption doesn't crash.
23
+ // Research Finding A4 (acquireCanvasContext on singleton) + A3
24
+ // (rhi-not-available legal); plan-strategy §4 R-1; charter P3.
25
+
26
+ import type {
27
+ CanvasConfiguration,
28
+ Result,
29
+ RhiCanvasContext,
30
+ RhiError as RhiErrorType,
31
+ Texture,
32
+ } from '@forgeax/engine-rhi';
33
+ import { ok } from '@forgeax/engine-types';
34
+
35
+ /**
36
+ * Headless canvas context. configure succeeds vacuously; getCurrentTexture
37
+ * mints a legal opaque-handle Texture brand so the swap-chain acquisition path
38
+ * never blocks frame rendering in headless CI.
39
+ */
40
+ export class RhiNullCanvasContext implements RhiCanvasContext {
41
+ configure(_desc: CanvasConfiguration): Result<void, RhiErrorType> {
42
+ return ok(undefined);
43
+ }
44
+
45
+ unconfigure(): void {}
46
+
47
+ getConfiguration(): CanvasConfiguration | undefined {
48
+ return undefined;
49
+ }
50
+
51
+ getCurrentTexture(): Result<Texture, RhiErrorType> {
52
+ // A brand is a compile-time unique symbol with no runtime field, so a plain
53
+ // object cast is a structurally valid Texture (research Finding A6). No real
54
+ // pixels; the command-stream path only needs a legal handle to thread on.
55
+ return ok({} as unknown as Texture);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Acquire a canvas context. The headless backend returns ok(context) so the
61
+ * createRenderer context-acquisition step succeeds. The context's
62
+ * getCurrentTexture mints a legal Texture brand once a bookkeeper is attached.
63
+ *
64
+ * R-1: this is mounted on the rhi singleton so Channel 1's facade never
65
+ * crashes on a missing acquireCanvasContext.
66
+ */
67
+ export function acquireCanvasContext(
68
+ _canvas?: HTMLCanvasElement | OffscreenCanvas | null | undefined,
69
+ ): Result<RhiCanvasContext, RhiErrorType> {
70
+ return ok(new RhiNullCanvasContext());
71
+ }
@@ -0,0 +1,146 @@
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
+ RenderPassDescriptor,
22
+ Result,
23
+ RhiCommandEncoder,
24
+ RhiComputePassEncoder,
25
+ RhiError as RhiErrorType,
26
+ RhiRenderPassEncoder,
27
+ } from '@forgeax/engine-rhi';
28
+ import { ok } from '@forgeax/engine-types';
29
+ import type { Bookkeeper } from './bookkeeping';
30
+ import type { RhiNullDevice } from './device';
31
+ import {
32
+ type PassCounter,
33
+ RhiNullComputePassEncoder,
34
+ RhiNullRenderPassEncoder,
35
+ } from './pass-encoders';
36
+
37
+ /**
38
+ * Internal counter that writes per-frame stats back to the owning device.
39
+ */
40
+ class DeviceCounter implements PassCounter {
41
+ constructor(private readonly device: RhiNullDevice) {}
42
+
43
+ recordDraw(): void {
44
+ this.device.totalDrawCount++;
45
+ }
46
+
47
+ recordDispatch(): void {
48
+ this.device.totalDispatchCount++;
49
+ }
50
+
51
+ recordBindGroup(): void {
52
+ this.device.totalBindGroupCount++;
53
+ }
54
+
55
+ recordPassName(name: string): void {
56
+ this.device.framePassNames.push(name);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Read the `label` field from a GPURenderPassDescriptor (or undefined if not
62
+ * set). The engine sets `label` on the render pass descriptor; the graph's
63
+ * compile path doesn't always thread a per-pass label, so we fall back to the
64
+ * RenderPassDescriptor's generic label (or '<unnamed>').
65
+ */
66
+ function readPassLabel(desc: { readonly label?: string | undefined } | undefined): string {
67
+ if (desc && typeof desc.label === 'string' && desc.label.length > 0) {
68
+ return desc.label as string;
69
+ }
70
+ return '<unnamed>';
71
+ }
72
+
73
+ /**
74
+ * Headless command encoder. begin*Pass returns a no-op pass encoder bound to
75
+ * the issuing device's ledger; recording methods are no-ops; finish mints a
76
+ * CommandBuffer brand.
77
+ */
78
+ export class RhiNullCommandEncoder implements RhiCommandEncoder {
79
+ private readonly bookkeeper: Bookkeeper;
80
+ private readonly counter: PassCounter;
81
+
82
+ constructor(bookkeeper: Bookkeeper, device: RhiNullDevice) {
83
+ this.bookkeeper = bookkeeper;
84
+ this.counter = new DeviceCounter(device);
85
+ }
86
+
87
+ beginRenderPass(desc: RenderPassDescriptor): RhiRenderPassEncoder {
88
+ const label = readPassLabel(desc);
89
+ return new RhiNullRenderPassEncoder(this.bookkeeper, this.counter, label);
90
+ }
91
+
92
+ beginComputePass(desc?: ComputePassDescriptor | undefined): RhiComputePassEncoder {
93
+ const label = readPassLabel(desc);
94
+ return new RhiNullComputePassEncoder(this.bookkeeper, this.counter, label);
95
+ }
96
+
97
+ copyBufferToBuffer(
98
+ _source: Buffer,
99
+ _sourceOffsetOrDestination: number | Buffer,
100
+ _destinationOrSize?: Buffer | number | undefined,
101
+ _destinationOffset?: number | undefined,
102
+ _size?: number | undefined,
103
+ ): void {}
104
+
105
+ copyBufferToTexture(
106
+ _source: GPUTexelCopyBufferInfo,
107
+ _destination: GPUTexelCopyTextureInfo,
108
+ _copySize: GPUExtent3DStrict,
109
+ ): void {}
110
+
111
+ copyTextureToBuffer(
112
+ _source: GPUTexelCopyTextureInfo,
113
+ _destination: GPUTexelCopyBufferInfo,
114
+ _copySize: GPUExtent3DStrict,
115
+ ): void {}
116
+
117
+ copyTextureToTexture(
118
+ _source: GPUTexelCopyTextureInfo,
119
+ _destination: GPUTexelCopyTextureInfo,
120
+ _copySize: GPUExtent3DStrict,
121
+ ): void {}
122
+
123
+ clearBuffer(_buffer: Buffer, _offset?: number | undefined, _size?: number | undefined): void {}
124
+
125
+ resolveQuerySet(
126
+ _querySet: QuerySet,
127
+ _firstQuery: number,
128
+ _queryCount: number,
129
+ _destination: Buffer,
130
+ _destinationOffset: number,
131
+ ): Result<void, RhiErrorType> {
132
+ return ok(undefined);
133
+ }
134
+
135
+ writeTimestamp(_querySet: QuerySet, _queryIndex: number): void {}
136
+
137
+ pushDebugGroup(_groupLabel: string): void {}
138
+
139
+ popDebugGroup(): void {}
140
+
141
+ insertDebugMarker(_markerLabel: string): void {}
142
+
143
+ finish(): Result<CommandBuffer, RhiErrorType> {
144
+ return ok(this.bookkeeper.register('CommandBuffer') as unknown as CommandBuffer);
145
+ }
146
+ }