@aardworx/wombat.rendering 0.5.4 → 0.7.0

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.
@@ -14,6 +14,8 @@
14
14
 
15
15
  import {
16
16
  AdaptiveResource,
17
+ BufferView,
18
+ ElementType,
17
19
  type CompiledEffect,
18
20
  type FramebufferSignature,
19
21
  type ProgramInterface,
@@ -25,7 +27,6 @@ import {
25
27
  HashMap,
26
28
  cval,
27
29
  } from "@aardworx/wombat.adaptive";
28
- import type { BufferView } from "../core/bufferView.js";
29
30
  import type { Type } from "@aardworx/wombat.shader/ir";
30
31
  import { prepareAdaptiveBuffer } from "./adaptiveBuffer.js";
31
32
  import { prepareAdaptiveTexture } from "./adaptiveTexture.js";
@@ -34,32 +35,6 @@ import { prepareUniformBuffer } from "./uniformBuffer.js";
34
35
  import { compileRenderPipeline, type CompileRenderPipelineDescription } from "./renderPipeline.js";
35
36
  import { BufferUsage, ShaderStage } from "./webgpuFlags.js";
36
37
 
37
- // ---------------------------------------------------------------------------
38
- // Helpers
39
- // ---------------------------------------------------------------------------
40
-
41
- function isIndexFormat(fmt: string): boolean {
42
- return fmt === "uint16" || fmt === "uint32";
43
- }
44
-
45
- function vertexFormatStride(fmt: GPUVertexFormat): number {
46
- switch (fmt) {
47
- case "float32": return 4;
48
- case "float32x2": return 8;
49
- case "float32x3": return 12;
50
- case "float32x4": return 16;
51
- case "uint32": case "sint32": return 4;
52
- case "uint32x2": case "sint32x2": return 8;
53
- case "uint32x3": case "sint32x3": return 12;
54
- case "uint32x4": case "sint32x4": return 16;
55
- case "uint16x2": case "sint16x2": case "float16x2": return 4;
56
- case "uint16x4": case "sint16x4": case "float16x4": return 8;
57
- case "uint8x2": case "sint8x2": case "unorm8x2": case "snorm8x2": return 2;
58
- case "uint8x4": case "sint8x4": case "unorm8x4": case "snorm8x4": return 4;
59
- default: throw new Error(`vertexFormatStride: unsupported format ${fmt}`);
60
- }
61
- }
62
-
63
38
  interface VertexBindingInfo {
64
39
  readonly name: string;
65
40
  readonly slot: number;
@@ -371,10 +346,10 @@ export class PreparedRenderObject {
371
346
  private readonly device: GPUDevice,
372
347
  private readonly vertexBindings: readonly VertexBindingInfo[],
373
348
  private readonly vertexBuffers: ReadonlyMap<string, AdaptiveResource<GPUBuffer>>,
374
- private readonly vertexViews: ReadonlyMap<string, aval<import("../core/index.js").BufferView>>,
349
+ private readonly vertexViews: ReadonlyMap<string, BufferView>,
375
350
  private readonly indexBuffer: AdaptiveResource<GPUBuffer> | undefined,
376
351
  private readonly indexFormat: GPUIndexFormat | undefined,
377
- private readonly indices: aval<import("../core/index.js").BufferView> | undefined,
352
+ private readonly indices: BufferView | undefined,
378
353
  groups: readonly GroupDesc[],
379
354
  private readonly drawCall: aval<import("../core/index.js").DrawCall>,
380
355
  pipelineCtx: PipelineBuildContext,
@@ -479,15 +454,12 @@ export class PreparedRenderObject {
479
454
  for (const vb of this.vertexBindings) {
480
455
  const res = this.vertexBuffers.get(vb.name);
481
456
  if (res === undefined) throw new Error(`PreparedRenderObject.record: missing vertex buffer for "${vb.name}"`);
482
- const viewAval = this.vertexViews.get(vb.name);
483
- const view = viewAval !== undefined ? viewAval.getValue(token) : undefined;
457
+ const view = this.vertexViews.get(vb.name);
484
458
  const buf = res.getValue(token);
485
- if (view !== undefined && view.offset > 0) {
486
- // size omitted → WebGPU uses (buffer.size offset). The
487
- // BufferView's count·stride span doesn't include the offset,
488
- // so passing it here would request bytes past the end of the
489
- // buffer for any non-zero offset; let WebGPU default.
490
- pass.setVertexBuffer(vb.slot, buf, view.offset);
459
+ const offset = view !== undefined ? BufferView.offsetOf(view) : 0;
460
+ if (offset > 0) {
461
+ // size omitted WebGPU uses (buffer.size offset).
462
+ pass.setVertexBuffer(vb.slot, buf, offset);
491
463
  } else {
492
464
  pass.setVertexBuffer(vb.slot, buf);
493
465
  }
@@ -495,8 +467,8 @@ export class PreparedRenderObject {
495
467
 
496
468
  if (this.indexBuffer !== undefined && this.indices !== undefined && this.indexFormat !== undefined) {
497
469
  const buf = this.indexBuffer.getValue(token);
498
- const view = this.indices.getValue(token);
499
- pass.setIndexBuffer(buf, this.indexFormat, view.offset, view.count * (this.indexFormat === "uint16" ? 2 : 4));
470
+ // size omitted → WebGPU uses (buffer.size − offset).
471
+ pass.setIndexBuffer(buf, this.indexFormat, BufferView.offsetOf(this.indices));
500
472
  }
501
473
 
502
474
  const dc = this.drawCall.getValue(token);
@@ -531,7 +503,7 @@ export function prepareRenderObject(
531
503
  opts: PrepareRenderObjectOptions = {},
532
504
  ): PreparedRenderObject {
533
505
  const iface = effect.interface;
534
- const vertexBindings = vertexBindingsFor(iface);
506
+ let vertexBindings = vertexBindingsFor(iface);
535
507
 
536
508
  // The set of attribute names is fixed structurally; only the
537
509
  // per-attribute BufferView avals are reactive. We resolve names →
@@ -539,9 +511,28 @@ export function prepareRenderObject(
539
511
  const vertexMap = obj.vertexAttributes;
540
512
  const instanceMap = obj.instanceAttributes ?? emptyAttrMap();
541
513
 
542
- const vertexBuffers = new Map<string, AdaptiveResource<GPUBuffer>>();
543
- const vertexViews = new Map<string, aval<import("../core/index.js").BufferView>>();
544
- const vertexLayouts: GPUVertexBufferLayout[] = [];
514
+ // Per-attribute resolution + grouping. Multiple attributes can
515
+ // share one underlying GPUBuffer slot when they all point at the
516
+ // same `aval<IBuffer>` (by reference identity) with the same
517
+ // stride + stepMode (the auto-instancing rewrite emits 4 vec4
518
+ // column attributes per M44 instance trafo — without packing
519
+ // we'd hit `maxVertexBuffers` (8) for any moderately rich
520
+ // instancing scope). Each attribute's `view.offset` becomes its
521
+ // shader-location offset within the shared layout.
522
+ //
523
+ // BufferView is now structural-eager (elementType/offset/stride),
524
+ // so no `force()` is needed to discover layout — the attribute
525
+ // grouping reads the plain fields directly.
526
+ interface ResolvedBinding {
527
+ readonly name: string;
528
+ readonly shaderLocation: number;
529
+ readonly stepMode: GPUVertexStepMode;
530
+ readonly stride: number;
531
+ readonly offset: number;
532
+ readonly format: GPUVertexFormat;
533
+ readonly view: BufferView;
534
+ }
535
+ const resolved: ResolvedBinding[] = [];
545
536
  for (const vb of vertexBindings) {
546
537
  const fromVertex = vertexMap.tryFind(vb.name);
547
538
  const fromInstance = instanceMap.tryFind(vb.name);
@@ -551,61 +542,106 @@ export function prepareRenderObject(
551
542
  const isInstance = fromVertex === undefined && fromInstance !== undefined;
552
543
  const stepMode: GPUVertexStepMode = isInstance ? "instance" : "vertex";
553
544
 
554
- const viewAval = (fromVertex ?? fromInstance)!;
555
- // BufferView's stride drives `arrayStride` so callers can pack
556
- // multiple attributes into one interleaved buffer (each attribute
557
- // names the same IBuffer with a different `offset`, all sharing
558
- // a `stride` ≥ sum of attribute sizes). The renderer honors
559
- // `view.offset` at draw time via `setVertexBuffer(slot, buf,
560
- // offset)`, so each attribute reads its own slice. Sampled at
561
- // build time — switching stride / offset dynamically would need
562
- // a fresh pipeline.
563
- const initialView = viewAval.force();
564
- const explicitZeroStride = initialView.stride === 0;
565
- // The buffer's own `format` (e.g. "float32x3" for a packed-Vec3
566
- // position stream) is the source of truth — use it whenever
567
- // present so the layout matches what's actually in memory.
568
- // WebGPU auto-pads missing components when the shader declares a
569
- // wider vector (vec4 input fed by Float32x3 → w=1).
570
- const viewFormat = initialView.format as GPUVertexFormat | undefined;
571
- const format = (viewFormat !== undefined && !isIndexFormat(viewFormat))
572
- ? viewFormat
573
- : vb.format;
574
- const stride = explicitZeroStride
575
- ? 0
576
- : (initialView.stride > 0
577
- ? initialView.stride
578
- : vertexFormatStride(format));
579
-
580
- const bufAval = viewAval.map(view => view.buffer);
581
- const res = prepareAdaptiveBuffer(device, bufAval, {
582
- usage: BufferUsage.VERTEX,
583
- ...(opts.label !== undefined ? { label: `${opts.label}.${vb.name}` } : {}),
545
+ const view = (fromVertex ?? fromInstance)!;
546
+ const format = ElementType.toVertexFormat(view.elementType, view.normalized ?? false);
547
+ const stride = BufferView.strideOf(view);
548
+ const offset = BufferView.offsetOf(view);
549
+ resolved.push({
550
+ name: vb.name,
551
+ shaderLocation: vb.slot,
552
+ stepMode, stride, offset,
553
+ format, view,
584
554
  });
585
- vertexBuffers.set(vb.name, res);
586
- vertexViews.set(vb.name, viewAval);
587
- vertexLayouts[vb.slot] = {
588
- arrayStride: stride,
589
- stepMode,
590
- attributes: [{ shaderLocation: vb.slot, offset: 0, format }],
555
+ }
556
+
557
+ // Group resolved bindings by (IBuffer-aval identity, stride, stepMode).
558
+ // The aval reference is the natural identity now that BufferView
559
+ // owns its `aval<IBuffer>` directly.
560
+ type GroupKey = string;
561
+ interface BindingGroup {
562
+ readonly key: GroupKey;
563
+ readonly slot: number; // GPU vertex-buffer slot index
564
+ readonly stride: number;
565
+ readonly stepMode: GPUVertexStepMode;
566
+ readonly view: BufferView;
567
+ readonly bufferRes: AdaptiveResource<GPUBuffer>;
568
+ readonly members: ResolvedBinding[];
569
+ }
570
+ const bufferIds = new WeakMap<object, number>();
571
+ let nextBufId = 0;
572
+ const idOf = (b: object): number => {
573
+ let n = bufferIds.get(b);
574
+ if (n === undefined) { n = nextBufId++; bufferIds.set(b, n); }
575
+ return n;
576
+ };
577
+ const vbGroups = new Map<GroupKey, BindingGroup>();
578
+ let nextSlot = 0;
579
+ const vertexBuffers = new Map<string, AdaptiveResource<GPUBuffer>>();
580
+ const vertexViews = new Map<string, BufferView>();
581
+ for (const r of resolved) {
582
+ const bufId = idOf(r.view.buffer as unknown as object);
583
+ const key = `${bufId}|${r.stride}|${r.stepMode}`;
584
+ let group = vbGroups.get(key);
585
+ if (!group) {
586
+ const bufferRes = prepareAdaptiveBuffer(device, r.view.buffer, {
587
+ usage: BufferUsage.VERTEX,
588
+ ...(opts.label !== undefined ? { label: `${opts.label}.${r.name}` } : {}),
589
+ });
590
+ group = {
591
+ key, slot: nextSlot++, stride: r.stride, stepMode: r.stepMode,
592
+ view: r.view, bufferRes, members: [],
593
+ };
594
+ vbGroups.set(key, group);
595
+ }
596
+ group.members.push(r);
597
+ vertexBuffers.set(r.name, group.bufferRes);
598
+ vertexViews.set(r.name, r.view);
599
+ }
600
+ const vertexLayouts: GPUVertexBufferLayout[] = [];
601
+ for (const g of vbGroups.values()) {
602
+ vertexLayouts[g.slot] = {
603
+ arrayStride: g.stride,
604
+ stepMode: g.stepMode,
605
+ attributes: g.members.map((m) => ({
606
+ shaderLocation: m.shaderLocation,
607
+ offset: m.offset,
608
+ format: m.format,
609
+ })),
591
610
  };
592
611
  }
612
+ // The IR-side `vertexBindings.slot` was the per-attribute shader
613
+ // location. The runtime needs the GPU buffer-slot index. Build a
614
+ // parallel array keyed by binding name so the per-frame
615
+ // `setVertexBuffer` loop calls the right slot once per group.
616
+ const groupSlotByBindingName = new Map<string, number>();
617
+ for (const g of vbGroups.values()) {
618
+ for (const m of g.members) groupSlotByBindingName.set(m.name, g.slot);
619
+ }
620
+ // Build the unique-per-slot binding list the prepared object uses
621
+ // at draw time. Pick the first member of each group to represent
622
+ // the buffer; per-attribute view offsets live in the layout.
623
+ const drawTimeBindings: VertexBindingInfo[] = [];
624
+ for (const g of vbGroups.values()) {
625
+ const head = g.members[0]!;
626
+ drawTimeBindings.push({
627
+ name: head.name,
628
+ slot: g.slot,
629
+ format: head.format,
630
+ byteSize: 0,
631
+ });
632
+ }
633
+ vertexBindings = drawTimeBindings;
593
634
 
594
- // Index buffer — `indexFormat` from BufferView wins; defaults to uint32.
635
+ // Index buffer — `elementType` (u16 / u32) determines `GPUIndexFormat`.
636
+ // Structural fields are eager on BufferView, so no force needed.
595
637
  let indexBuffer: AdaptiveResource<GPUBuffer> | undefined;
596
638
  let indexFormat: GPUIndexFormat | undefined;
597
639
  if (obj.indices !== undefined) {
598
- const bufAval = obj.indices.map(v => v.buffer);
599
- indexBuffer = prepareAdaptiveBuffer(device, bufAval, {
640
+ indexBuffer = prepareAdaptiveBuffer(device, obj.indices.buffer, {
600
641
  usage: BufferUsage.INDEX,
601
642
  ...(opts.label !== undefined ? { label: `${opts.label}.indices` } : {}),
602
643
  });
603
- // Construction-time read to discover the index format. The
604
- // BufferView aval is expected to settle on a stable format —
605
- // changing index format would require a fresh PreparedRO.
606
- const initial = obj.indices.force();
607
- indexFormat = initial.indexFormat
608
- ?? (initial.format === "uint16" ? "uint16" : "uint32");
644
+ indexFormat = ElementType.toIndexFormat(obj.indices.elementType);
609
645
  }
610
646
 
611
647
  const slotOf = (e: { group: number; slot: number }) => e.slot;
@@ -695,8 +731,8 @@ export function prepareRenderObject(
695
731
  );
696
732
  }
697
733
 
698
- function emptyAttrMap(): HashMap<string, aval<BufferView>> {
699
- return HashMap.empty<string, aval<BufferView>>();
734
+ function emptyAttrMap(): HashMap<string, BufferView> {
735
+ return HashMap.empty<string, BufferView>();
700
736
  }
701
737
 
702
738
  /**
@@ -1,20 +1,44 @@
1
- // runFrame — requestAnimationFrame-driven loop integrated with
2
- // wombat.adaptive transactions. Each tick:
3
- // 1. Bumps the canvas attachment's frame counter so its
4
- // framebuffer aval invalidates and the next read returns the
5
- // current swap-chain texture.
6
- // 2. Calls the user's `frame(token)` callback. The user runs
7
- // their `IRenderTask` here.
1
+ // runFrame — adaptivity-driven render loop. Mirrors Aardvark.Rendering's
2
+ // approach: an AVal wraps the user's frame callback, the frame's
3
+ // dependency set comes from whatever the callback reads via the
4
+ // adaptive `token`, and a marking callback on the wrapper schedules
5
+ // the next `requestAnimationFrame`. Idle scenes (no avals fired
6
+ // since last render) consume zero CPU per rAF.
8
7
  //
9
- // Returns a `stop()` to cancel the loop.
8
+ // The standard time-ticking pattern (which the wombat.dom
9
+ // `<RenderControl>` follows) is:
10
+ // 1. The user's frame callback calls `task.run(token)` which
11
+ // reads its inputs (uniforms, alists, …) via the token.
12
+ // Those reads register as inputs to the wrapping AVal.
13
+ // 2. After `task.run`, the callback ticks a global `time` cval
14
+ // inside a `transact(...)`.
15
+ // 3. If anything in the scene (e.g. an `OrbitController`'s
16
+ // damping aval, a custom animation aval) reads `time`, the
17
+ // mark cascades into the wrapper → marking callback fires →
18
+ // next rAF scheduled. If *nothing* depends on time, the mark
19
+ // dies in midair and the loop sleeps.
20
+ // 4. Canvas resize / explicit cval edits / scene-graph deltas
21
+ // mark their respective avals → cascade → wake-up.
22
+ //
23
+ // Returns a `stop()` to cancel the loop and clear the subscription.
10
24
 
11
- import type { AdaptiveToken } from "@aardworx/wombat.adaptive";
12
- import { AdaptiveToken as Tok } from "@aardworx/wombat.adaptive";
25
+ import type { aval, AdaptiveToken, IDisposable } from "@aardworx/wombat.adaptive";
26
+ import { AVal, transact } from "@aardworx/wombat.adaptive";
13
27
  import type { CanvasAttachment } from "./canvas.js";
14
28
 
15
29
  export interface RunFrameOptions {
16
30
  /** Stop after N frames. Useful for tests + finite animations. */
17
31
  readonly maxFrames?: number;
32
+ /**
33
+ * Hook called after each frame's eval completes — typically used
34
+ * to tick a `time` cval (so any aval that depends on time gets
35
+ * marked AFTER the frame, waking the next rAF). Fired in a fresh
36
+ * transaction. Returning without ticking (`onAfterFrame: () => {}`,
37
+ * or omitting) means the loop won't advance unless something else
38
+ * marks an aval that the frame reads. That's the correct idle
39
+ * behavior.
40
+ */
41
+ readonly onAfterFrame?: () => void;
18
42
  }
19
43
 
20
44
  export type FrameCallback = (token: AdaptiveToken, info: FrameInfo) => void;
@@ -34,29 +58,68 @@ export function runFrame(
34
58
  let frameNo = 0;
35
59
  let lastTime = performance.now();
36
60
  let rafId = 0;
61
+ let pending = false;
37
62
 
38
- const tick = (now: number) => {
39
- if (stopped) return;
63
+ // Wrap the frame callback in an AVal so the adaptive system tracks
64
+ // its dependency set. Each `force()` re-reads the inputs and runs
65
+ // the frame; subsequent input marks fire the marking callback.
66
+ const renderAval: aval<number> = AVal.custom((token) => {
67
+ const now = performance.now();
40
68
  const delta = now - lastTime;
41
69
  lastTime = now;
42
70
  attachment.markFrame();
43
71
  try {
44
- frame(Tok.top, { frame: frameNo, timestampMs: now, deltaMs: delta });
72
+ frame(token, { frame: frameNo, timestampMs: now, deltaMs: delta });
45
73
  } catch (e) {
46
74
  stopped = true;
47
75
  console.error("runFrame: error in frame callback", e);
48
76
  throw e;
49
77
  }
50
78
  frameNo++;
51
- if (opts.maxFrames !== undefined && frameNo >= opts.maxFrames) return;
52
- rafId = requestAnimationFrame(tick);
79
+ return frameNo;
80
+ });
81
+
82
+ const tick = (): void => {
83
+ if (stopped) return;
84
+ pending = false;
85
+ // The single force-on-render-path. Pulls `renderAval`, which
86
+ // re-evaluates the user's frame callback (making it up-to-date
87
+ // for the next mark). Adaptivity-driven equivalent of "run one
88
+ // frame on this rAF."
89
+ renderAval.force(/* allow-force */);
90
+ // Post-frame hook in a fresh transaction. Marks fired here
91
+ // propagate AFTER the eval, so they actually reach
92
+ // `renderAval`'s marking callback and wake the next rAF.
93
+ // Inside the eval the same mark would race the
94
+ // `outOfDate=false` reset and animation would stall after one
95
+ // frame.
96
+ if (opts.onAfterFrame !== undefined) {
97
+ transact(opts.onAfterFrame);
98
+ }
99
+ if (opts.maxFrames !== undefined && frameNo >= opts.maxFrames) {
100
+ stopped = true;
101
+ sub.dispose();
102
+ }
53
103
  };
54
104
 
105
+ const sub: IDisposable = (renderAval as unknown as {
106
+ addMarkingCallback(cb: () => void): IDisposable;
107
+ }).addMarkingCallback(() => {
108
+ if (pending || stopped) return;
109
+ pending = true;
110
+ rafId = requestAnimationFrame(tick);
111
+ });
112
+
113
+ // Initial render. Schedules subsequent frames only when adaptive
114
+ // marks reach `renderAval` (via the marking callback above).
115
+ pending = true;
55
116
  rafId = requestAnimationFrame(tick);
117
+
56
118
  return {
57
119
  stop() {
58
120
  stopped = true;
59
121
  cancelAnimationFrame(rafId);
122
+ sub.dispose();
60
123
  },
61
124
  };
62
125
  }