@aardworx/wombat.rendering 0.19.11 → 0.19.13
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/dist/core/renderObject.d.ts +9 -0
- package/dist/core/renderObject.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/codegen.d.ts +11 -0
- package/dist/runtime/derivedUniforms/codegen.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/codegen.js +59 -2
- package/dist/runtime/derivedUniforms/codegen.js.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/dispatch.js +21 -11
- package/dist/runtime/derivedUniforms/dispatch.js.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts +53 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/sceneIntegration.js +136 -6
- package/dist/runtime/derivedUniforms/sceneIntegration.js.map +1 -1
- package/dist/runtime/derivedUniforms/slots.d.ts +6 -0
- package/dist/runtime/derivedUniforms/slots.d.ts.map +1 -1
- package/dist/runtime/derivedUniforms/slots.js +14 -0
- package/dist/runtime/derivedUniforms/slots.js.map +1 -1
- package/dist/runtime/heapAdapter.d.ts.map +1 -1
- package/dist/runtime/heapAdapter.js +1 -0
- package/dist/runtime/heapAdapter.js.map +1 -1
- package/dist/runtime/heapScene.d.ts +11 -1
- package/dist/runtime/heapScene.d.ts.map +1 -1
- package/dist/runtime/heapScene.js +6 -0
- package/dist/runtime/heapScene.js.map +1 -1
- package/package.json +1 -1
- package/src/core/renderObject.ts +9 -0
- package/src/runtime/derivedUniforms/codegen.ts +61 -2
- package/src/runtime/derivedUniforms/dispatch.ts +20 -10
- package/src/runtime/derivedUniforms/sceneIntegration.ts +169 -6
- package/src/runtime/derivedUniforms/slots.ts +15 -0
- package/src/runtime/heapAdapter.ts +1 -0
- package/src/runtime/heapScene.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aardworx/wombat.rendering",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.13",
|
|
4
4
|
"description": "WebGPU rendering layer for the Wombat TypeScript stack — RenderObject/RenderTask/runtime + window glue, port of Aardvark.Rendering's lower layers on top of WebGPU and wombat.shader.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "krauthaufen",
|
package/src/core/renderObject.ts
CHANGED
|
@@ -102,4 +102,13 @@ export interface RenderObject {
|
|
|
102
102
|
readonly depthWrite?: import("../runtime/derivedModes/rule.js").DerivedModeRule<"depthWrite">;
|
|
103
103
|
readonly alphaToCoverage?: import("../runtime/derivedModes/rule.js").DerivedModeRule<"alphaToCoverage">;
|
|
104
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* GPU transform propagation: this RO's `Model` as an ancestor trafo CHAIN
|
|
107
|
+
* (root→leaf order, constant runs folded) instead of a single composed
|
|
108
|
+
* `ModelTrafo` aval. The scene-graph emits this so a shared root trafo over
|
|
109
|
+
* N objects never fans out to N composed CPU avals — the heap composes each
|
|
110
|
+
* RO's Model on the GPU. Heap-path only (the legacy path composes on CPU).
|
|
111
|
+
* See docs/gpu-transform-propagation.md.
|
|
112
|
+
*/
|
|
113
|
+
readonly modelChain?: readonly aval<import("@aardworx/wombat.base").Trafo3d>[];
|
|
105
114
|
}
|
|
@@ -23,6 +23,18 @@ export interface UberKernel {
|
|
|
23
23
|
readonly strideU32: number;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Reserved rule id for the built-in variable-length trafo-chain arm (GPU
|
|
28
|
+
* transform propagation). A chain record is laid out as
|
|
29
|
+
* [ CHAIN_RULE_ID, out_slot, count, link0, link1, … ]
|
|
30
|
+
* where each `linkK` is a Constituent handle. The arm folds the df32 product
|
|
31
|
+
* `link0 · link1 · … · link(count-1)` and writes it (collapsed) to `out_slot`.
|
|
32
|
+
* One arm handles every chain length, so distinct SG ancestor paths don't each
|
|
33
|
+
* spawn a kernel arm / recompile. Kept far above the registry's monotonic ids
|
|
34
|
+
* (which start at 0) so it can never collide. See docs/gpu-transform-propagation.md.
|
|
35
|
+
*/
|
|
36
|
+
export const CHAIN_RULE_ID = 0x7fffffff;
|
|
37
|
+
|
|
26
38
|
// ─── Rule-shape classification ────────────────────────────────────────
|
|
27
39
|
|
|
28
40
|
type Shape =
|
|
@@ -136,6 +148,12 @@ fn load_entry_mat4(h: u32, r: u32, c: u32) -> vec2<f32> {
|
|
|
136
148
|
fn write_mat4_entry(out_byte: u32, r: u32, c: u32, v: f32) {
|
|
137
149
|
MainHeap[(out_byte >> 2u) + r * 4u + c] = v;
|
|
138
150
|
}
|
|
151
|
+
// Write one df32 mat4 entry (r,c) into a constituent slot — keeps both hi+lo
|
|
152
|
+
// so a downstream §7 pass reads it at full df32 precision. Used by the chain
|
|
153
|
+
// pass, whose output IS a per-RO Model constituent (not a collapsed uniform).
|
|
154
|
+
fn write_constituent_entry(slot: u32, r: u32, c: u32, e: vec2<f32>) {
|
|
155
|
+
Constituents[slot * 16u + r * 4u + c] = e;
|
|
156
|
+
}
|
|
139
157
|
fn write_mat3_entry(out_byte: u32, r: u32, c: u32, v: f32) {
|
|
140
158
|
// std140 mat3<f32>: 3 rows × 4-float stride; the 4th column per row is left untouched.
|
|
141
159
|
MainHeap[(out_byte >> 2u) + r * 4u + c] = v;
|
|
@@ -144,7 +162,7 @@ fn write_mat3_entry(out_byte: u32, r: u32, c: u32, v: f32) {
|
|
|
144
162
|
|
|
145
163
|
function bindings(strideU32: number): string {
|
|
146
164
|
return /* wgsl */ `
|
|
147
|
-
@group(0) @binding(0) var<storage,
|
|
165
|
+
@group(0) @binding(0) var<storage, read_write> Constituents: array<vec2<f32>>;
|
|
148
166
|
@group(0) @binding(1) var<storage, read_write> MainHeap: array<f32>;
|
|
149
167
|
@group(0) @binding(2) var<storage, read> RecordData: array<u32>;
|
|
150
168
|
struct CountUniform { count: u32 }
|
|
@@ -214,6 +232,43 @@ function emitMatMulChainArm(id: number, n: number): string {
|
|
|
214
232
|
return `\nfn arm_${id}(${params}, out_byte: u32) {\n${body}}\n`;
|
|
215
233
|
}
|
|
216
234
|
|
|
235
|
+
/** Built-in `CHAIN` arm (GPU transform propagation): variable-length df32
|
|
236
|
+
* matmul chain read straight from the record. `RecordData[base+2]` = count;
|
|
237
|
+
* `RecordData[base+3+k]` = link k (Constituent handle). Folds
|
|
238
|
+
* P = L0·L1·…·L(count-1) in df32 and writes it — keeping BOTH hi+lo — into the
|
|
239
|
+
* output CONSTITUENT slot (`out_slot` = the out handle's payload), so a later
|
|
240
|
+
* §7 pass reads it at full df32 precision. The inverse half is produced by a
|
|
241
|
+
* second chain record over the links' inv slots in reverse order (same arm).
|
|
242
|
+
* P starts at identity so count==0 yields identity. */
|
|
243
|
+
const CHAIN_ARM = /* wgsl */ `
|
|
244
|
+
fn arm_chain(base: u32, out_slot: u32) {
|
|
245
|
+
let count = RecordData[base + 2u];
|
|
246
|
+
var P: array<vec2<f32>, 16>;
|
|
247
|
+
for (var i: u32 = 0u; i < 16u; i = i + 1u) { P[i] = vec2<f32>(0.0, 0.0); }
|
|
248
|
+
P[0] = vec2<f32>(1.0, 0.0); P[5] = vec2<f32>(1.0, 0.0);
|
|
249
|
+
P[10] = vec2<f32>(1.0, 0.0); P[15] = vec2<f32>(1.0, 0.0);
|
|
250
|
+
for (var k: u32 = 0u; k < count; k = k + 1u) {
|
|
251
|
+
let h = RecordData[base + 3u + k];
|
|
252
|
+
var Q: array<vec2<f32>, 16>;
|
|
253
|
+
for (var r: u32 = 0u; r < 4u; r = r + 1u) {
|
|
254
|
+
for (var c: u32 = 0u; c < 4u; c = c + 1u) {
|
|
255
|
+
var acc = vec2<f32>(0.0, 0.0);
|
|
256
|
+
for (var t: u32 = 0u; t < 4u; t = t + 1u) {
|
|
257
|
+
acc = df_add(acc, df_mul(P[r * 4u + t], load_entry_mat4(h, t, c)));
|
|
258
|
+
}
|
|
259
|
+
Q[r * 4u + c] = acc;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
P = Q;
|
|
263
|
+
}
|
|
264
|
+
for (var r: u32 = 0u; r < 4u; r = r + 1u) {
|
|
265
|
+
for (var c: u32 = 0u; c < 4u; c = c + 1u) {
|
|
266
|
+
write_constituent_entry(out_slot, r, c, P[r * 4u + c]);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
`;
|
|
271
|
+
|
|
217
272
|
// ─── Generic path: arbitrary IR lowered via the wgsl printer (f32) ────
|
|
218
273
|
//
|
|
219
274
|
// The rule's IR is rewritten so each input leaf — `ReadInput("Uniform", x)` or
|
|
@@ -454,13 +509,16 @@ export function buildUberKernel(registry: DerivedUniformRegistry, strideU32: num
|
|
|
454
509
|
|
|
455
510
|
const arms = entries.map((e, i) => emitArm(e, shapes[i]!)).join("");
|
|
456
511
|
const cases = entries.map((e, i) => emitCase(e, shapes[i]!)).join("\n");
|
|
457
|
-
|
|
512
|
+
// The CHAIN arm is always present (GPU transform propagation) and always
|
|
513
|
+
// needs the df32 library.
|
|
514
|
+
const needsDf32 = true;
|
|
458
515
|
|
|
459
516
|
const wgsl = `${bindings(strideU32)}
|
|
460
517
|
${needsDf32 ? DF32_LIB : ""}
|
|
461
518
|
${HANDLE_HELPERS}
|
|
462
519
|
${loadersStorers}
|
|
463
520
|
${arms}
|
|
521
|
+
${CHAIN_ARM}
|
|
464
522
|
@compute @workgroup_size(64)
|
|
465
523
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
466
524
|
if (gid.x >= Count.count) { return; }
|
|
@@ -468,6 +526,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
|
468
526
|
let rule_id = RecordData[base];
|
|
469
527
|
let out_byte = RecordData[base + 1u] & SLOT_PAYLOAD_MASK;
|
|
470
528
|
switch rule_id {
|
|
529
|
+
case ${CHAIN_RULE_ID >>> 0}u: { arm_chain(base, out_byte); }
|
|
471
530
|
${cases}
|
|
472
531
|
default: { return; }
|
|
473
532
|
}
|
|
@@ -43,7 +43,9 @@ export interface DerivedUniformsResources {
|
|
|
43
43
|
|
|
44
44
|
function bglEntries(): GPUBindGroupLayoutEntry[] {
|
|
45
45
|
return [
|
|
46
|
-
|
|
46
|
+
// Constituents: read_write — the chain pass writes per-RO Model constituents
|
|
47
|
+
// here (a separate, earlier dispatch); §7 reads them. read_write permits both.
|
|
48
|
+
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
|
|
47
49
|
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
|
|
48
50
|
{ binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
|
|
49
51
|
{ binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
|
|
@@ -119,9 +121,12 @@ class RecordsGpu {
|
|
|
119
121
|
class UberPipeline {
|
|
120
122
|
private readonly device: GPUDevice;
|
|
121
123
|
readonly bgl: GPUBindGroupLayout;
|
|
122
|
-
|
|
124
|
+
// Cache one pipeline per record stride (the kernel bakes RECORD_STRIDE). The
|
|
125
|
+
// chain pass and the §7 pass usually have different strides, and both are
|
|
126
|
+
// dispatched every frame, so a single-slot cache would thrash → recompile
|
|
127
|
+
// per frame. Cleared on a registry-version bump (new rule shape).
|
|
128
|
+
private readonly pipes = new Map<number, GPUComputePipeline>();
|
|
123
129
|
private builtVersion = -1;
|
|
124
|
-
private builtStride = -1;
|
|
125
130
|
|
|
126
131
|
constructor(device: GPUDevice) {
|
|
127
132
|
this.device = device;
|
|
@@ -129,20 +134,25 @@ class UberPipeline {
|
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
pipeline(registry: DerivedUniformRegistry, strideU32: number): GPUComputePipeline | undefined {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
// Always buildable: the kernel carries the built-in CHAIN arm even with an
|
|
138
|
+
// empty registry, so a scene using only transform-propagation chains (no
|
|
139
|
+
// §7 rules) still gets a pipeline. encodeChunk's recordCount===0 guard is
|
|
140
|
+
// what skips the dispatch when there's genuinely nothing to do.
|
|
141
|
+
if (registry.version !== this.builtVersion) {
|
|
142
|
+
this.pipes.clear();
|
|
143
|
+
this.builtVersion = registry.version;
|
|
135
144
|
}
|
|
145
|
+
const cached = this.pipes.get(strideU32);
|
|
146
|
+
if (cached !== undefined) return cached;
|
|
136
147
|
const { wgsl } = buildUberKernel(registry, strideU32);
|
|
137
148
|
const module = this.device.createShaderModule({ code: wgsl, label: "derivedUniforms.uber" });
|
|
138
|
-
|
|
149
|
+
const pipe = this.device.createComputePipeline({
|
|
139
150
|
label: "derivedUniforms.uber",
|
|
140
151
|
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bgl] }),
|
|
141
152
|
compute: { module, entryPoint: "main" },
|
|
142
153
|
});
|
|
143
|
-
this.
|
|
144
|
-
|
|
145
|
-
return this.pipe;
|
|
154
|
+
this.pipes.set(strideU32, pipe);
|
|
155
|
+
return pipe;
|
|
146
156
|
}
|
|
147
157
|
}
|
|
148
158
|
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "./slots.js";
|
|
26
26
|
import { DerivedUniformRegistry } from "./registry.js";
|
|
27
27
|
import { RecordsBuffer, SlotTag, makeHandle } from "./records.js";
|
|
28
|
+
import { CHAIN_RULE_ID } from "./codegen.js";
|
|
28
29
|
import { DerivedUniformsDispatcher, uploadConstituentsRange } from "./dispatch.js";
|
|
29
30
|
import { flatten, type RuleInput } from "./flatten.js";
|
|
30
31
|
import { ruleFromIR, type DerivedRule } from "./rule.js";
|
|
@@ -41,6 +42,15 @@ export class DerivedUniformsScene {
|
|
|
41
42
|
* scene-wide; records partition by chunk so each dispatch binds
|
|
42
43
|
* the right chunk's main heap. */
|
|
43
44
|
private readonly recordsByChunk = new Map<number, RecordsBuffer>();
|
|
45
|
+
/** CHAIN records partitioned by trie LEVEL (GPU transform propagation). A
|
|
46
|
+
* trie node at level L computes `link · parentNode.world` — reading its
|
|
47
|
+
* parent (level L-1) output — so levels must dispatch in ascending order,
|
|
48
|
+
* BEFORE the §7 per-chunk passes. Scene-wide: they target the shared
|
|
49
|
+
* constituents buffer (per-node Model), not any chunk's main heap.
|
|
50
|
+
* Prefix-sharing: sibling chains share trie nodes (see TrafoTree). */
|
|
51
|
+
private readonly chainByLevel = new Map<number, RecordsBuffer>();
|
|
52
|
+
/** The suffix trie that dedups shared ancestor sub-chains across ROs. */
|
|
53
|
+
readonly trafoTree: TrafoTree;
|
|
44
54
|
readonly dispatcher: DerivedUniformsDispatcher;
|
|
45
55
|
/** Constituents storage GPU buffer (`array<vec2<f32>>` — df32 mat4 halves). */
|
|
46
56
|
constituentsBuf: GPUBuffer;
|
|
@@ -57,11 +67,22 @@ export class DerivedUniformsScene {
|
|
|
57
67
|
this.constituentsBuf = device.createBuffer({
|
|
58
68
|
label: "derivedUniforms.constituents",
|
|
59
69
|
size: initial * DF32_MAT4_BYTES,
|
|
60
|
-
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
70
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
|
61
71
|
});
|
|
62
72
|
this.dispatcher = new DerivedUniformsDispatcher(device, {
|
|
63
73
|
constituentsBuf: () => this.constituentsBuf,
|
|
64
74
|
});
|
|
75
|
+
this.trafoTree = new TrafoTree(this);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Get-or-create the CHAIN records buffer for trie `level`. */
|
|
79
|
+
chainRecordsFor(level: number): RecordsBuffer {
|
|
80
|
+
let r = this.chainByLevel.get(level);
|
|
81
|
+
if (r === undefined) {
|
|
82
|
+
r = new RecordsBuffer();
|
|
83
|
+
this.chainByLevel.set(level, r);
|
|
84
|
+
}
|
|
85
|
+
return r;
|
|
65
86
|
}
|
|
66
87
|
|
|
67
88
|
/** Register (or replace) the GPUBuffer getter for a chunk's main
|
|
@@ -71,6 +92,13 @@ export class DerivedUniformsScene {
|
|
|
71
92
|
this.mainHeapByChunk.set(chunkIdx, getter);
|
|
72
93
|
}
|
|
73
94
|
|
|
95
|
+
/** Any registered main-heap getter (the chain pass needs a MainHeap binding
|
|
96
|
+
* even though its arm never touches it). */
|
|
97
|
+
private firstHeapGetter(): (() => GPUBuffer) | undefined {
|
|
98
|
+
for (const g of this.mainHeapByChunk.values()) return g;
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
74
102
|
/** Get-or-create the records buffer for `chunkIdx`. */
|
|
75
103
|
recordsFor(chunkIdx: number): RecordsBuffer {
|
|
76
104
|
let r = this.recordsByChunk.get(chunkIdx);
|
|
@@ -97,7 +125,7 @@ export class DerivedUniformsScene {
|
|
|
97
125
|
this.constituentsBuf = this.device.createBuffer({
|
|
98
126
|
label: "derivedUniforms.constituents",
|
|
99
127
|
size: cap,
|
|
100
|
-
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
128
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
|
|
101
129
|
});
|
|
102
130
|
this.bufferEpoch++;
|
|
103
131
|
// Fresh buffer is zero-initialised; re-upload everything packed so far.
|
|
@@ -134,6 +162,23 @@ export class DerivedUniformsScene {
|
|
|
134
162
|
* main heap buffer. */
|
|
135
163
|
encode(enc: GPUCommandEncoder): boolean {
|
|
136
164
|
let any = false;
|
|
165
|
+
// Chain pass — writes per-RO/per-node Model constituents (fwd+inv) into the
|
|
166
|
+
// shared constituents buffer. Dispatch trie LEVELS in ascending order: a
|
|
167
|
+
// level-L node reads its parent's (level L-1) output. Must run BEFORE the
|
|
168
|
+
// §7 passes that read the Models. MainHeap binding is unused here; bind any.
|
|
169
|
+
const levels = [...this.chainByLevel.keys()].sort((a, b) => a - b);
|
|
170
|
+
if (levels.length > 0) {
|
|
171
|
+
const anyHeap = this.firstHeapGetter();
|
|
172
|
+
if (anyHeap === undefined) {
|
|
173
|
+
throw new Error("derivedUniforms.encode: chain records present but no chunk main-heap registered to bind.");
|
|
174
|
+
}
|
|
175
|
+
for (const lvl of levels) {
|
|
176
|
+
const recs = this.chainByLevel.get(lvl)!;
|
|
177
|
+
if (recs.recordCount === 0) continue;
|
|
178
|
+
if (this.dispatcher.encodeChunk(enc, this.registry, recs, anyHeap)) any = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Phase 2: §7 per-chunk (reads constituents incl. the chain-written Model).
|
|
137
182
|
for (const [chunkIdx, records] of this.recordsByChunk) {
|
|
138
183
|
if (records.recordCount === 0) continue;
|
|
139
184
|
const heapGetter = this.mainHeapByChunk.get(chunkIdx);
|
|
@@ -154,11 +199,101 @@ export class DerivedUniformsScene {
|
|
|
154
199
|
}
|
|
155
200
|
}
|
|
156
201
|
|
|
202
|
+
// ─── Trafo suffix-trie (prefix sharing) ───────────────────────────────
|
|
203
|
+
//
|
|
204
|
+
// Dedups shared ancestor sub-chains across ROs. Each RO's chain is interned
|
|
205
|
+
// from the ROOT end, so sibling chains share trie nodes for their common
|
|
206
|
+
// ancestor suffix. A trie node owns a per-node Model constituent (fwd+inv) and
|
|
207
|
+
// two CHAIN records computing `node.world = link · parent.world` (and the
|
|
208
|
+
// reversed inverse) — at the node's depth LEVEL, so a shared ancestor is
|
|
209
|
+
// composed ONCE and every descendant reads it. Levels dispatch in ascending
|
|
210
|
+
// order (DerivedUniformsScene.encode). Refcounted: a node is freed when no RO
|
|
211
|
+
// path references it. See docs/gpu-transform-propagation.md (Phase 2).
|
|
212
|
+
|
|
213
|
+
export interface TrieNode {
|
|
214
|
+
readonly id: number;
|
|
215
|
+
readonly key: string;
|
|
216
|
+
readonly modelPair: PairedSlots;
|
|
217
|
+
readonly level: number;
|
|
218
|
+
readonly parent: TrieNode | undefined;
|
|
219
|
+
refcount: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export class TrafoTree {
|
|
223
|
+
private readonly byKey = new Map<string, TrieNode>();
|
|
224
|
+
private nextId = 0;
|
|
225
|
+
constructor(private readonly scene: DerivedUniformsScene) {}
|
|
226
|
+
|
|
227
|
+
/** Number of live trie nodes — diagnostics / tests (prefix-sharing check). */
|
|
228
|
+
get nodeCount(): number { return this.byKey.size; }
|
|
229
|
+
|
|
230
|
+
/** Intern a chain (array order `[leaf, …, root]`); returns the LEAF trie node
|
|
231
|
+
* whose Model is the full product. increfs every node on the path so shared
|
|
232
|
+
* ancestor nodes survive while any RO references them. */
|
|
233
|
+
intern(linkPairs: readonly PairedSlots[]): TrieNode {
|
|
234
|
+
let parent: TrieNode | undefined;
|
|
235
|
+
// Root end (last) → leaf (first), so shared ancestor suffixes share nodes.
|
|
236
|
+
for (let i = linkPairs.length - 1; i >= 0; i--) {
|
|
237
|
+
parent = this.internOne(parent, linkPairs[i]!);
|
|
238
|
+
}
|
|
239
|
+
if (parent === undefined) throw new Error("TrafoTree.intern: empty chain");
|
|
240
|
+
return parent;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Decref every node on the leaf→root path; free those that hit 0. */
|
|
244
|
+
release(leaf: TrieNode): void {
|
|
245
|
+
let n: TrieNode | undefined = leaf;
|
|
246
|
+
while (n !== undefined) {
|
|
247
|
+
const parent: TrieNode | undefined = n.parent;
|
|
248
|
+
if (--n.refcount === 0) {
|
|
249
|
+
this.byKey.delete(n.key);
|
|
250
|
+
this.scene.chainRecordsFor(n.level).removeAllForOwner(n);
|
|
251
|
+
this.scene.constituents.freeOutputPair(n.modelPair);
|
|
252
|
+
}
|
|
253
|
+
n = parent;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private internOne(parent: TrieNode | undefined, link: PairedSlots): TrieNode {
|
|
258
|
+
const key = `${parent?.id ?? -1}:${link.fwd}`;
|
|
259
|
+
const existing = this.byKey.get(key);
|
|
260
|
+
if (existing !== undefined) { existing.refcount++; return existing; }
|
|
261
|
+
const level = parent === undefined ? 0 : parent.level + 1;
|
|
262
|
+
const modelPair = this.scene.constituents.allocOutputPair();
|
|
263
|
+
const node: TrieNode = { id: this.nextId++, key, modelPair, level, parent, refcount: 1 };
|
|
264
|
+
this.byKey.set(key, node);
|
|
265
|
+
const h = (s: number): number => makeHandle(SlotTag.Constituent, s);
|
|
266
|
+
const recs = this.scene.chainRecordsFor(level);
|
|
267
|
+
// node.world.fwd = link.fwd · parent.world.fwd (just link at the root end).
|
|
268
|
+
// node.world.inv = parent.world.inv · link.inv (reversed; just link at root).
|
|
269
|
+
if (parent === undefined) {
|
|
270
|
+
recs.add(node, CHAIN_RULE_ID, h(modelPair.fwd), [1, h(link.fwd)]);
|
|
271
|
+
recs.add(node, CHAIN_RULE_ID, h(modelPair.inv), [1, h(link.inv)]);
|
|
272
|
+
} else {
|
|
273
|
+
recs.add(node, CHAIN_RULE_ID, h(modelPair.fwd), [2, h(link.fwd), h(parent.modelPair.fwd)]);
|
|
274
|
+
recs.add(node, CHAIN_RULE_ID, h(modelPair.inv), [2, h(parent.modelPair.inv), h(link.inv)]);
|
|
275
|
+
}
|
|
276
|
+
return node;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
157
280
|
// ─── Per-RO registration ──────────────────────────────────────────────
|
|
158
281
|
|
|
159
282
|
export interface RoDerivedRequest {
|
|
160
283
|
/** Derived rules to install, keyed by the uniform name each produces. */
|
|
161
284
|
readonly rules: ReadonlyMap<string, DerivedRule>;
|
|
285
|
+
/**
|
|
286
|
+
* GPU transform propagation: the SG ancestor trafo chain for THIS RO's
|
|
287
|
+
* `Model` (root→leaf order, constant runs folded), as `aval<Trafo3d>` links.
|
|
288
|
+
* Links are constituents shared by aval identity, so a root trafo shared by
|
|
289
|
+
* N ROs is ONE slot referenced N times (no CPU fan-out). When present, the
|
|
290
|
+
* chain pass computes a per-RO `Model` constituent (fwd from the links, inv
|
|
291
|
+
* from the reversed inverse links) and the §7 rules' `Model` leaf resolves to
|
|
292
|
+
* it — so ModelTrafo / ModelView / ModelViewInv / NormalMatrix / custom rules
|
|
293
|
+
* all derive from the GPU-composed Model, unchanged. See
|
|
294
|
+
* docs/gpu-transform-propagation.md.
|
|
295
|
+
*/
|
|
296
|
+
readonly modelChain?: readonly aval<Trafo3d>[];
|
|
162
297
|
/** Trafo avals available on this RO, keyed by the uniform name a rule leaf may reference (e.g. "ModelTrafo"). */
|
|
163
298
|
readonly trafoAvals: ReadonlyMap<string, aval<Trafo3d>>;
|
|
164
299
|
/** drawHeader byte offset (relative to `drawHeaderBaseByte`) of a host-supplied uniform on this RO; undefined if not present. */
|
|
@@ -180,6 +315,9 @@ export interface RoRegistration {
|
|
|
180
315
|
readonly ruleHashes: readonly string[];
|
|
181
316
|
/** Chunk this registration was placed into — used by deregister. */
|
|
182
317
|
readonly chunkIdx: number;
|
|
318
|
+
/** This RO's leaf trie node (transform propagation); its path is decref'd on
|
|
319
|
+
* deregister. Present iff the request carried a `modelChain`. */
|
|
320
|
+
readonly modelLeaf?: TrieNode;
|
|
183
321
|
}
|
|
184
322
|
|
|
185
323
|
export function registerRoDerivations(
|
|
@@ -187,7 +325,9 @@ export function registerRoDerivations(
|
|
|
187
325
|
owner: object,
|
|
188
326
|
req: RoDerivedRequest,
|
|
189
327
|
): RoRegistration {
|
|
190
|
-
if (req.rules.size === 0
|
|
328
|
+
if (req.rules.size === 0 && req.modelChain === undefined) {
|
|
329
|
+
return { owner, constituentAvals: [], ruleHashes: [], chunkIdx: req.chunkIdx };
|
|
330
|
+
}
|
|
191
331
|
const records = scene.recordsFor(req.chunkIdx);
|
|
192
332
|
|
|
193
333
|
const acquiredAvals: aval<Trafo3d>[] = [];
|
|
@@ -207,8 +347,9 @@ export function registerRoDerivations(
|
|
|
207
347
|
};
|
|
208
348
|
const resolve = (inp: RuleInput): number => {
|
|
209
349
|
// A matrix-typed leaf bound as an aval is a `Trafo3d` ⇒ a df32 constituent slot
|
|
210
|
-
// (its `Inverse` reads the stored backward half — free).
|
|
211
|
-
|
|
350
|
+
// (its `Inverse` reads the stored backward half — free). `Model` may instead
|
|
351
|
+
// be a GPU-composed chain output already in `pairByName` (modelChain case).
|
|
352
|
+
if (inp.type.kind === "Matrix" && (req.trafoAvals.has(inp.name) || pairByName.has(inp.name))) {
|
|
212
353
|
const p = acquireTrafo(inp.name);
|
|
213
354
|
return makeHandle(SlotTag.Constituent, inp.inverse ? p.inv : p.fwd);
|
|
214
355
|
}
|
|
@@ -225,6 +366,23 @@ export function registerRoDerivations(
|
|
|
225
366
|
);
|
|
226
367
|
};
|
|
227
368
|
|
|
369
|
+
// GPU transform propagation: compute this RO's `Model` as a per-RO constituent
|
|
370
|
+
// from its ancestor chain, and point §7's `Model` leaf at it. The chain pass
|
|
371
|
+
// (a separate, earlier dispatch) writes the fwd half from the links and the
|
|
372
|
+
// inv half from the reversed inverse links.
|
|
373
|
+
let modelLeaf: TrieNode | undefined;
|
|
374
|
+
if (req.modelChain !== undefined && req.modelChain.length > 0) {
|
|
375
|
+
const linkPairs = req.modelChain.map((av) => {
|
|
376
|
+
acquiredAvals.push(av);
|
|
377
|
+
return scene.constituents.acquire(av);
|
|
378
|
+
});
|
|
379
|
+
// Intern the chain into the suffix trie — siblings share ancestor nodes,
|
|
380
|
+
// each composed once (link · parent.world) at its level. The RO's Model is
|
|
381
|
+
// the leaf node's per-node Model constituent.
|
|
382
|
+
modelLeaf = scene.trafoTree.intern(linkPairs);
|
|
383
|
+
pairByName.set("Model", modelLeaf.modelPair);
|
|
384
|
+
}
|
|
385
|
+
|
|
228
386
|
for (const [outName, rule] of req.rules) {
|
|
229
387
|
// `flatten` returns the same Expr object when nothing was substituted (the common case —
|
|
230
388
|
// the standard recipes reference no derived names), so we can reuse `rule` (it already
|
|
@@ -241,12 +399,17 @@ export function registerRoDerivations(
|
|
|
241
399
|
}
|
|
242
400
|
records.add(owner, id, makeHandle(SlotTag.HostHeap, req.drawHeaderBaseByte + outOff), inSlots);
|
|
243
401
|
}
|
|
402
|
+
|
|
244
403
|
scene.ensureConstituentsCapacity(scene.constituents.slotCount * DF32_MAT4_BYTES);
|
|
245
|
-
return {
|
|
404
|
+
return {
|
|
405
|
+
owner, constituentAvals: acquiredAvals, ruleHashes, chunkIdx: req.chunkIdx,
|
|
406
|
+
...(modelLeaf !== undefined ? { modelLeaf } : {}),
|
|
407
|
+
};
|
|
246
408
|
}
|
|
247
409
|
|
|
248
410
|
export function deregisterRoDerivations(scene: DerivedUniformsScene, reg: RoRegistration): void {
|
|
249
411
|
scene.recordsFor(reg.chunkIdx).removeAllForOwner(reg.owner);
|
|
250
412
|
for (const h of reg.ruleHashes) scene.registry.release(h);
|
|
251
413
|
for (const av of reg.constituentAvals) scene.constituents.release(av);
|
|
414
|
+
if (reg.modelLeaf !== undefined) scene.trafoTree.release(reg.modelLeaf);
|
|
252
415
|
}
|
|
@@ -87,6 +87,21 @@ export class ConstituentSlots {
|
|
|
87
87
|
this.mirror = new Float32Array(initialCapacity * 32);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Allocate a fwd+inv slot pair for a GPU-WRITTEN output (not keyed by any
|
|
91
|
+
* aval, never dirty-tracked or CPU-uploaded). Used for a per-RO `Model`
|
|
92
|
+
* produced by the transform-propagation chain pass and then consumed by §7
|
|
93
|
+
* as a constituent. Free with `freeOutputPair`. */
|
|
94
|
+
allocOutputPair(): PairedSlots {
|
|
95
|
+
const fwd = this.pool.alloc() as SlotIndex;
|
|
96
|
+
const inv = this.pool.alloc() as SlotIndex;
|
|
97
|
+
this.ensureCapacity(this.pool.highWaterMark);
|
|
98
|
+
return { fwd, inv };
|
|
99
|
+
}
|
|
100
|
+
freeOutputPair(p: PairedSlots): void {
|
|
101
|
+
this.pool.release(p.fwd);
|
|
102
|
+
this.pool.release(p.inv);
|
|
103
|
+
}
|
|
104
|
+
|
|
90
105
|
acquire(av: aval<Trafo3d>): PairedSlots {
|
|
91
106
|
let entry = this.byAval.get(av);
|
|
92
107
|
if (entry === undefined) {
|
|
@@ -464,6 +464,7 @@ export function renderObjectToHeapSpec(
|
|
|
464
464
|
...geom,
|
|
465
465
|
...(textures !== undefined ? { textures } : {}),
|
|
466
466
|
...(ro.modeRules !== undefined ? { modeRules: ro.modeRules } : {}),
|
|
467
|
+
...(ro.modelChain !== undefined ? { modelChain: ro.modelChain } : {}),
|
|
467
468
|
// Pass `active` through unforced so the heap path can subscribe
|
|
468
469
|
// and react to flips without re-running the spec adapter.
|
|
469
470
|
...(ro.active !== undefined ? { active: ro.active } : {}),
|
package/src/runtime/heapScene.ts
CHANGED
|
@@ -530,6 +530,16 @@ export interface HeapDrawSpec {
|
|
|
530
530
|
readonly depthWrite?: import("./derivedModes/rule.js").DerivedModeRule<"depthWrite">;
|
|
531
531
|
readonly alphaToCoverage?: import("./derivedModes/rule.js").DerivedModeRule<"alphaToCoverage">;
|
|
532
532
|
};
|
|
533
|
+
/**
|
|
534
|
+
* GPU transform propagation: this RO's `Model` ancestor trafo chain (root→
|
|
535
|
+
* leaf, constant runs folded) as `aval<Trafo3d>` links. When present, the §7
|
|
536
|
+
* compute pass composes a per-RO Model constituent from these on the GPU
|
|
537
|
+
* (instead of the RO supplying a single composed `ModelTrafo` aval), and the
|
|
538
|
+
* standard recipes / custom rules derive ModelView / inverses / NormalMatrix
|
|
539
|
+
* from it. Eliminates the CPU fan-out of a shared root trafo over many ROs.
|
|
540
|
+
* See docs/gpu-transform-propagation.md.
|
|
541
|
+
*/
|
|
542
|
+
readonly modelChain?: readonly aval<Trafo3d>[];
|
|
533
543
|
}
|
|
534
544
|
|
|
535
545
|
export interface HeapSceneStats {
|
|
@@ -1193,6 +1203,10 @@ export function buildHeapScene(
|
|
|
1193
1203
|
// just means "this RO doesn't supply the base trafos", so the field falls back to its
|
|
1194
1204
|
// packed `spec.inputs` value (or errors later if there is none — same as without §7).
|
|
1195
1205
|
for (const inp of inputsOf(rule.ir)) {
|
|
1206
|
+
// `Model` may be supplied by the GPU transform-propagation chain rather
|
|
1207
|
+
// than a ModelTrafo aval — treat it as bound when a modelChain is present
|
|
1208
|
+
// (registerRoDerivations points the §7 Model leaf at the chain output).
|
|
1209
|
+
if (inp.name === "Model" && spec.modelChain !== undefined) continue;
|
|
1196
1210
|
const specName = STANDARD_TRAFO_LEAVES.get(inp.name) ?? inp.name;
|
|
1197
1211
|
const lv = spec.inputs[specName];
|
|
1198
1212
|
if (lv === undefined || isDerivedRule(lv)) {
|
|
@@ -3415,6 +3429,7 @@ export function buildHeapScene(
|
|
|
3415
3429
|
const reg = registerRoDerivations(derivedScene, {}, {
|
|
3416
3430
|
rules: ruleSubset,
|
|
3417
3431
|
trafoAvals,
|
|
3432
|
+
...(spec.modelChain !== undefined ? { modelChain: spec.modelChain } : {}),
|
|
3418
3433
|
hostUniformOffset: (n) => {
|
|
3419
3434
|
const r = perDrawRefs.get(n);
|
|
3420
3435
|
return r === undefined ? undefined : r + ALLOC_HEADER_PAD_TO;
|