@multiplekex/shallot 0.1.7 → 0.1.9

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.
@@ -1,17 +1,58 @@
1
1
  import TinySDF from "@mapbox/tiny-sdf";
2
- import { MAX_ENTITIES, resource, type Plugin, type State, type System } from "../../core";
2
+ import {
3
+ MAX_ENTITIES,
4
+ resource,
5
+ registerPostLoadHook,
6
+ type Plugin,
7
+ type State,
8
+ type System,
9
+ type PostLoadContext,
10
+ } from "../../core";
3
11
  import { setTraits, type FieldAccessor } from "../../core/component";
12
+ import { Compute, ComputePlugin } from "../../standard/compute";
4
13
  import {
5
- Compute,
6
- ComputePlugin,
7
- type ComputeNode,
8
- type ExecutionContext,
9
- } from "../../standard/compute";
10
- import { Render, RenderPlugin, DEPTH_FORMAT } from "../../standard/render";
14
+ Render,
15
+ RenderPlugin,
16
+ DEPTH_FORMAT,
17
+ registerDrawContributor,
18
+ type DrawContributor,
19
+ type DrawContext,
20
+ } from "../../standard/render";
11
21
  import { Transform } from "../../standard/transforms";
12
22
 
13
23
  const MAX_GLYPHS = 50000;
14
24
  const GLYPH_FLOATS = 16;
25
+ const SDF_EXPONENT = 9;
26
+ const SDF_CUTOFF = 0.5;
27
+
28
+ let customFontFamily: string | null = null;
29
+ let customFontWeight: string = "normal";
30
+
31
+ function encodeExponentialSdf(linearData: Uint8Array): Uint8Array {
32
+ const encoded = new Uint8Array(linearData.length);
33
+ for (let i = 0; i < linearData.length; i++) {
34
+ // TinySDF outputs: 0=outside, 255=inside, cutoff*255=edge
35
+ // Convert to signed distance: positive=outside, negative=inside
36
+ const raw = linearData[i] / 255;
37
+ const signedDist = (SDF_CUTOFF - raw) / SDF_CUTOFF;
38
+
39
+ // Apply exponential encoding (Troika formula)
40
+ const absDist = Math.min(1, Math.abs(signedDist));
41
+ let alpha = Math.pow(1 - absDist, SDF_EXPONENT) / 2;
42
+ if (signedDist < 0) {
43
+ alpha = 1 - alpha;
44
+ }
45
+ encoded[i] = Math.round(Math.max(0, Math.min(255, alpha * 255)));
46
+ }
47
+ return encoded;
48
+ }
49
+
50
+ export async function setTextFont(fontFamily: string, weight: number = 400): Promise<void> {
51
+ const fontSpec = `${weight} 128px "${fontFamily}"`;
52
+ await document.fonts.load(fontSpec);
53
+ customFontFamily = fontFamily;
54
+ customFontWeight = weight >= 600 ? "bold" : "normal";
55
+ }
15
56
 
16
57
  export const TextData = {
17
58
  data: new Float32Array(MAX_ENTITIES * 12),
@@ -83,22 +124,102 @@ function colorProxy(): TextProxy {
83
124
  });
84
125
  }
85
126
 
86
- export const Text: {
87
- fontSize: TextProxy;
88
- opacity: TextProxy;
89
- visible: TextProxy;
90
- anchorX: TextProxy;
91
- anchorY: TextProxy;
92
- color: TextProxy;
93
- } = {
127
+ function colorChannelProxy(channelIndex: number): TextProxy {
128
+ const data = TextData.data;
129
+
130
+ function getValue(eid: number): number {
131
+ return data[eid * 12 + 8 + channelIndex];
132
+ }
133
+
134
+ function setValue(eid: number, value: number): void {
135
+ data[eid * 12 + 8 + channelIndex] = value;
136
+ }
137
+
138
+ return new Proxy([] as unknown as TextProxy, {
139
+ get(_, prop) {
140
+ if (prop === "get") return getValue;
141
+ if (prop === "set") return setValue;
142
+ const eid = Number(prop);
143
+ if (Number.isNaN(eid)) return undefined;
144
+ return getValue(eid);
145
+ },
146
+ set(_, prop, value) {
147
+ const eid = Number(prop);
148
+ if (Number.isNaN(eid)) return false;
149
+ setValue(eid, value);
150
+ return true;
151
+ },
152
+ });
153
+ }
154
+
155
+ const textContent = new Map<number, string>();
156
+
157
+ interface TextContentProxy {
158
+ [eid: number]: string | undefined;
159
+ }
160
+
161
+ function contentProxy(): TextContentProxy {
162
+ return new Proxy({} as TextContentProxy, {
163
+ get(_, prop) {
164
+ const eid = Number(prop);
165
+ if (Number.isNaN(eid)) return undefined;
166
+ return textContent.get(eid);
167
+ },
168
+ set(_, prop, value) {
169
+ const eid = Number(prop);
170
+ if (Number.isNaN(eid)) return false;
171
+ if (value === undefined || value === null) {
172
+ textContent.delete(eid);
173
+ } else {
174
+ textContent.set(eid, value);
175
+ }
176
+ return true;
177
+ },
178
+ });
179
+ }
180
+
181
+ export const Text = {
182
+ content: contentProxy(),
94
183
  fontSize: textProxy(0),
95
184
  opacity: textProxy(1),
96
185
  visible: textProxy(2),
97
186
  anchorX: textProxy(3),
98
187
  anchorY: textProxy(4),
99
188
  color: colorProxy(),
189
+ colorR: colorChannelProxy(0),
190
+ colorG: colorChannelProxy(1),
191
+ colorB: colorChannelProxy(2),
100
192
  };
101
193
 
194
+ interface PendingText {
195
+ readonly eid: number;
196
+ readonly content: string;
197
+ }
198
+
199
+ let pendingTextContent: PendingText[] = [];
200
+
201
+ function parseTextAttrs(attrs: Record<string, string>): Record<string, string> {
202
+ if (attrs._value) {
203
+ const parsed: Record<string, string> = {};
204
+ for (const part of attrs._value.split(";")) {
205
+ const colonIdx = part.indexOf(":");
206
+ if (colonIdx === -1) continue;
207
+ const key = part.slice(0, colonIdx).trim();
208
+ const value = part.slice(colonIdx + 1).trim();
209
+ if (key && value) parsed[key] = value;
210
+ }
211
+ return parsed;
212
+ }
213
+ return attrs;
214
+ }
215
+
216
+ function finalizePendingText(_state: State, _context: PostLoadContext): void {
217
+ for (const pending of pendingTextContent) {
218
+ Text.content[pending.eid] = pending.content;
219
+ }
220
+ pendingTextContent = [];
221
+ }
222
+
102
223
  setTraits(Text, {
103
224
  defaults: () => ({
104
225
  fontSize: 1,
@@ -108,6 +229,35 @@ setTraits(Text, {
108
229
  anchorY: 0,
109
230
  color: 0xffffff,
110
231
  }),
232
+ adapter: (attrs: Record<string, string>, eid: number) => {
233
+ const parsed = parseTextAttrs(attrs);
234
+ const result: Record<string, number> = {};
235
+
236
+ if (parsed.content) {
237
+ pendingTextContent.push({ eid, content: parsed.content });
238
+ }
239
+
240
+ if (parsed["font-size"]) result.fontSize = parseFloat(parsed["font-size"]);
241
+ if (parsed.fontSize) result.fontSize = parseFloat(parsed.fontSize);
242
+ if (parsed.opacity) result.opacity = parseFloat(parsed.opacity);
243
+ if (parsed.visible) result.visible = parseFloat(parsed.visible);
244
+ if (parsed["anchor-x"]) result.anchorX = parseFloat(parsed["anchor-x"]);
245
+ if (parsed.anchorX) result.anchorX = parseFloat(parsed.anchorX);
246
+ if (parsed["anchor-y"]) result.anchorY = parseFloat(parsed["anchor-y"]);
247
+ if (parsed.anchorY) result.anchorY = parseFloat(parsed.anchorY);
248
+ if (parsed.color) {
249
+ const colorStr = parsed.color;
250
+ if (colorStr.startsWith("0x") || colorStr.startsWith("0X")) {
251
+ result.color = parseInt(colorStr, 16);
252
+ } else if (colorStr.startsWith("#")) {
253
+ result.color = parseInt(colorStr.slice(1), 16);
254
+ } else {
255
+ result.color = parseInt(colorStr, 10);
256
+ }
257
+ }
258
+
259
+ return result;
260
+ },
111
261
  accessors: {
112
262
  fontSize: Text.fontSize,
113
263
  opacity: Text.opacity,
@@ -115,6 +265,9 @@ setTraits(Text, {
115
265
  anchorX: Text.anchorX,
116
266
  anchorY: Text.anchorY,
117
267
  color: Text.color,
268
+ colorR: Text.colorR,
269
+ colorG: Text.colorG,
270
+ colorB: Text.colorB,
118
271
  },
119
272
  });
120
273
 
@@ -146,9 +299,9 @@ interface GlyphAtlas {
146
299
  }
147
300
 
148
301
  function createGlyphAtlas(device: GPUDevice): GlyphAtlas {
149
- const width = 1024;
150
- const height = 1024;
151
- const fontSize = 32;
302
+ const width = 2048;
303
+ const height = 2048;
304
+ const fontSize = 128;
152
305
 
153
306
  const texture = device.createTexture({
154
307
  size: { width, height },
@@ -159,11 +312,11 @@ function createGlyphAtlas(device: GPUDevice): GlyphAtlas {
159
312
 
160
313
  const sdf = new TinySDF({
161
314
  fontSize,
162
- fontFamily: "monospace",
163
- fontWeight: "normal",
315
+ fontFamily: customFontFamily ?? "system-ui, sans-serif",
316
+ fontWeight: customFontWeight,
164
317
  fontStyle: "normal",
165
- buffer: 3,
166
- radius: 8,
318
+ buffer: 16,
319
+ radius: 48,
167
320
  cutoff: 0.5,
168
321
  });
169
322
 
@@ -197,15 +350,14 @@ function ensureGlyph(device: GPUDevice, atlas: GlyphAtlas, char: string): GlyphM
197
350
  throw new Error("Glyph atlas full");
198
351
  }
199
352
 
200
- const glyphData = new Uint8Array(glyph.data.length);
201
- glyphData.set(glyph.data);
353
+ const glyphData = encodeExponentialSdf(new Uint8Array(glyph.data));
202
354
 
203
355
  device.queue.writeTexture(
204
356
  {
205
357
  texture: atlas.texture,
206
358
  origin: { x: atlas.cursorX, y: atlas.cursorY },
207
359
  },
208
- glyphData,
360
+ glyphData as Uint8Array<ArrayBuffer>,
209
361
  { bytesPerRow: glyph.width },
210
362
  { width: glyph.width, height: glyph.height }
211
363
  );
@@ -242,6 +394,8 @@ interface LayoutGlyph {
242
394
  y: number;
243
395
  width: number;
244
396
  height: number;
397
+ texelWidth: number;
398
+ texelHeight: number;
245
399
  u0: number;
246
400
  v0: number;
247
401
  u1: number;
@@ -277,6 +431,8 @@ function layoutText(text: string, atlas: GlyphAtlas, fontSize: number): LayoutRe
277
431
  y,
278
432
  width: glyphW,
279
433
  height: glyphH,
434
+ texelWidth: metrics.width,
435
+ texelHeight: metrics.height,
280
436
  u0: metrics.u0,
281
437
  v0: metrics.v0,
282
438
  u1: metrics.u1,
@@ -298,6 +454,12 @@ const textShader = /* wgsl */ `
298
454
  struct Scene {
299
455
  viewProj: mat4x4<f32>,
300
456
  cameraWorld: mat4x4<f32>,
457
+ ambientColor: vec4<f32>,
458
+ sunDirection: vec4<f32>,
459
+ sunColor: vec4<f32>,
460
+ cameraMode: f32,
461
+ cameraSize: f32,
462
+ viewport: vec2<f32>,
301
463
  }
302
464
 
303
465
  struct GlyphInstance {
@@ -307,7 +469,8 @@ struct GlyphInstance {
307
469
  entityId: u32,
308
470
  width: f32,
309
471
  height: f32,
310
- _pad1: vec2<f32>,
472
+ texelWidth: f32,
473
+ texelHeight: f32,
311
474
  u0: f32,
312
475
  v0: f32,
313
476
  u1: f32,
@@ -325,10 +488,10 @@ struct VertexOutput {
325
488
  @builtin(position) position: vec4<f32>,
326
489
  @location(0) uv: vec2<f32>,
327
490
  @location(1) color: vec4<f32>,
491
+ @location(2) localUV: vec2<f32>,
492
+ @location(3) texelSize: vec2<f32>,
328
493
  }
329
494
 
330
- const SDF_SMOOTHING: f32 = 0.1;
331
-
332
495
  @vertex
333
496
  fn vs(@builtin(vertex_index) vid: u32) -> VertexOutput {
334
497
  let glyphIdx = vid / 6u;
@@ -383,23 +546,52 @@ fn vs(@builtin(vertex_index) vid: u32) -> VertexOutput {
383
546
  out.position = scene.viewProj * worldPos;
384
547
  out.uv = uv;
385
548
  out.color = glyph.color;
549
+ out.localUV = localPos;
550
+ out.texelSize = vec2(glyph.texelWidth, glyph.texelHeight);
386
551
  return out;
387
552
  }
388
553
 
554
+ struct FragmentOutput {
555
+ @location(0) color: vec4<f32>,
556
+ @location(1) mask: f32,
557
+ }
558
+
389
559
  @fragment
390
- fn fs(input: VertexOutput) -> @location(0) vec4<f32> {
560
+ fn fs(input: VertexOutput) -> FragmentOutput {
391
561
  let sdfValue = textureSample(atlasTexture, atlasSampler, input.uv).r;
392
- let alpha = smoothstep(0.5 - SDF_SMOOTHING, 0.5 + SDF_SMOOTHING, sdfValue);
562
+
563
+ // Decode exponential SDF (inverse of Troika-style encoding)
564
+ let sdfExponent = 9.0;
565
+ let isOutside = sdfValue < 0.5;
566
+ let processedAlpha = select(1.0 - sdfValue, sdfValue, isOutside);
567
+ let normalizedDist = 1.0 - pow(2.0 * processedAlpha, 1.0 / sdfExponent);
568
+ let signedDist = select(-normalizedDist, normalizedDist, isOutside);
569
+
570
+ // Troika-style AA using screen-space derivatives
571
+ let texelFootprint = fwidth(input.localUV * input.texelSize);
572
+ let aaRadius = length(texelFootprint) * 0.5;
573
+ let smoothing = clamp(aaRadius * 0.5, 0.01, 0.25);
574
+
575
+ // Edge at signedDist = 0, positive offset = thicker text
576
+ let edgeOffset = 0.02;
577
+ let alpha = smoothstep(edgeOffset + smoothing, edgeOffset - smoothing, signedDist);
393
578
 
394
579
  if alpha < 0.01 {
395
580
  discard;
396
581
  }
397
582
 
398
- return vec4(input.color.rgb, input.color.a * alpha);
583
+ var out: FragmentOutput;
584
+ out.color = vec4(input.color.rgb, input.color.a * alpha);
585
+ out.mask = select(0.0, 1.0, alpha > 0.01);
586
+ return out;
399
587
  }
400
588
  `;
401
589
 
402
- function createTextPipeline(device: GPUDevice, format: GPUTextureFormat): GPURenderPipeline {
590
+ function createTextPipeline(
591
+ device: GPUDevice,
592
+ format: GPUTextureFormat,
593
+ maskFormat: GPUTextureFormat
594
+ ): GPURenderPipeline {
403
595
  const module = device.createShaderModule({ code: textShader });
404
596
 
405
597
  return device.createRenderPipeline({
@@ -427,6 +619,10 @@ function createTextPipeline(device: GPUDevice, format: GPUTextureFormat): GPURen
427
619
  },
428
620
  },
429
621
  },
622
+ {
623
+ format: maskFormat,
624
+ writeMask: GPUColorWrite.RED,
625
+ },
430
626
  ],
431
627
  },
432
628
  primitive: {
@@ -450,29 +646,24 @@ export interface TextConfig {
450
646
  getCount: () => number;
451
647
  }
452
648
 
453
- export function createTextNode(config: TextConfig): ComputeNode {
649
+ function createTextContributor(config: TextConfig): DrawContributor {
454
650
  let pipeline: GPURenderPipeline | null = null;
455
651
  let bindGroup: GPUBindGroup | null = null;
456
652
 
457
653
  return {
458
654
  id: "text",
459
- phase: "transparent",
460
- inputs: [],
461
- outputs: [],
655
+ order: 2,
462
656
 
463
- execute(ctx: ExecutionContext) {
657
+ draw(pass: GPURenderPassEncoder, ctx: DrawContext) {
464
658
  const count = config.getCount();
465
659
  if (count === 0) return;
466
660
 
467
- const { device, encoder, format } = ctx;
468
- const targetView = ctx.getTextureView("scene") ?? ctx.canvasView;
469
-
470
661
  if (!pipeline) {
471
- pipeline = createTextPipeline(device, format);
662
+ pipeline = createTextPipeline(ctx.device, ctx.format, ctx.maskFormat);
472
663
  }
473
664
 
474
665
  if (!bindGroup) {
475
- bindGroup = device.createBindGroup({
666
+ bindGroup = ctx.device.createBindGroup({
476
667
  layout: pipeline.getBindGroupLayout(0),
477
668
  entries: [
478
669
  { binding: 0, resource: { buffer: config.scene } },
@@ -484,33 +675,14 @@ export function createTextNode(config: TextConfig): ComputeNode {
484
675
  });
485
676
  }
486
677
 
487
- const depthView = ctx.getTextureView("depth")!;
488
-
489
- const pass = encoder.beginRenderPass({
490
- colorAttachments: [
491
- {
492
- view: targetView,
493
- loadOp: "load" as const,
494
- storeOp: "store" as const,
495
- },
496
- ],
497
- depthStencilAttachment: {
498
- view: depthView,
499
- depthLoadOp: "load" as const,
500
- depthStoreOp: "store" as const,
501
- },
502
- });
503
-
504
678
  pass.setPipeline(pipeline);
505
679
  pass.setBindGroup(0, bindGroup);
506
680
  pass.draw(count * 6);
507
- pass.end();
508
681
  },
509
682
  };
510
683
  }
511
684
 
512
685
  export interface TextState {
513
- content: Map<number, string>;
514
686
  atlas: GlyphAtlas;
515
687
  sampler: GPUSampler;
516
688
  buffer: GPUBuffer;
@@ -520,17 +692,6 @@ export interface TextState {
520
692
 
521
693
  export const TextResource = resource<TextState>("text");
522
694
 
523
- export function setTextContent(state: State, eid: number, content: string): void {
524
- const text = TextResource.from(state);
525
- if (!text) return;
526
- text.content.set(eid, content);
527
- }
528
-
529
- export function getTextContent(state: State, eid: number): string | undefined {
530
- const text = TextResource.from(state);
531
- return text?.content.get(eid);
532
- }
533
-
534
695
  const TextSystem: System = {
535
696
  group: "draw",
536
697
 
@@ -540,7 +701,7 @@ const TextSystem: System = {
540
701
  if (!compute || !text) return;
541
702
 
542
703
  const { device } = compute;
543
- const { atlas, staging, content } = text;
704
+ const { atlas, staging } = text;
544
705
  const stagingU32 = new Uint32Array(staging.buffer);
545
706
 
546
707
  let glyphCount = 0;
@@ -548,13 +709,13 @@ const TextSystem: System = {
548
709
  for (const eid of state.query([Text, Transform])) {
549
710
  if (!Text.visible[eid]) continue;
550
711
 
551
- const textContent = content.get(eid);
552
- if (!textContent) continue;
712
+ const content = textContent.get(eid);
713
+ if (!content) continue;
553
714
 
554
- ensureString(device, atlas, textContent);
715
+ ensureString(device, atlas, content);
555
716
 
556
717
  const fontSize = Text.fontSize[eid];
557
- const layout = layoutText(textContent, atlas, fontSize);
718
+ const layout = layoutText(content, atlas, fontSize);
558
719
 
559
720
  const anchorX = Text.anchorX[eid];
560
721
  const anchorY = Text.anchorY[eid];
@@ -579,8 +740,8 @@ const TextSystem: System = {
579
740
 
580
741
  staging[offset + 4] = glyph.width;
581
742
  staging[offset + 5] = glyph.height;
582
- staging[offset + 6] = 0;
583
- staging[offset + 7] = 0;
743
+ staging[offset + 6] = glyph.texelWidth;
744
+ staging[offset + 7] = glyph.texelHeight;
584
745
 
585
746
  staging[offset + 8] = glyph.u0;
586
747
  staging[offset + 9] = glyph.v0;
@@ -616,6 +777,8 @@ export const TextPlugin: Plugin = {
616
777
  dependencies: [ComputePlugin, RenderPlugin],
617
778
 
618
779
  initialize(state: State) {
780
+ registerPostLoadHook(finalizePendingText);
781
+
619
782
  const compute = Compute.from(state);
620
783
  const render = Render.from(state);
621
784
  if (!compute || !render) return;
@@ -629,7 +792,6 @@ export const TextPlugin: Plugin = {
629
792
  });
630
793
 
631
794
  const textState: TextState = {
632
- content: new Map(),
633
795
  atlas,
634
796
  sampler,
635
797
  buffer: device.createBuffer({
@@ -643,8 +805,9 @@ export const TextPlugin: Plugin = {
643
805
 
644
806
  state.setResource(TextResource, textState);
645
807
 
646
- compute.graph.add(
647
- createTextNode({
808
+ registerDrawContributor(
809
+ state,
810
+ createTextContributor({
648
811
  scene: render.scene,
649
812
  glyphs: textState.buffer,
650
813
  atlas: atlas.textureView,
@@ -1,5 +1,6 @@
1
1
  import { resource, type Plugin, type State, type System } from "../../core";
2
2
  import { ComputeGraph, type ExecutionContext, type ResourceId } from "./graph";
3
+ import { createTimingCollector, type FrameTiming } from "./timing";
3
4
 
4
5
  export * from "./graph";
5
6
  export * from "./inspect";
@@ -42,7 +43,13 @@ export async function requestGPU(): Promise<GPUDevice> {
42
43
  throw new Error("No GPU adapter found");
43
44
  }
44
45
 
45
- return adapter.requestDevice();
46
+ const maxTextureDimension2D = adapter.limits.maxTextureDimension2D;
47
+
48
+ return adapter.requestDevice({
49
+ requiredLimits: {
50
+ maxTextureDimension2D,
51
+ },
52
+ });
46
53
  }
47
54
 
48
55
  export interface ComputeResources {
@@ -57,6 +64,8 @@ export interface ComputeState {
57
64
  readonly format: GPUTextureFormat;
58
65
  readonly graph: ComputeGraph;
59
66
  readonly resources: ComputeResources;
67
+ lastFrameTiming: FrameTiming | null;
68
+ frameIndex: number;
60
69
  }
61
70
 
62
71
  export const Compute = resource<ComputeState>("compute");
@@ -116,11 +125,18 @@ export const ComputeSystem: System = {
116
125
  },
117
126
  };
118
127
 
128
+ const collector = createTimingCollector();
129
+
119
130
  for (const node of plan.sorted) {
131
+ collector.beginNode(node.id);
120
132
  node.execute(ctx);
133
+ collector.endNode(node.id);
121
134
  }
122
135
 
123
136
  device.queue.submit([encoder.finish()]);
137
+
138
+ compute.lastFrameTiming = collector.finish(compute.frameIndex);
139
+ compute.frameIndex++;
124
140
  },
125
141
 
126
142
  dispose() {
@@ -159,6 +175,14 @@ export const ComputePlugin: Plugin = {
159
175
  buffers: new Map(),
160
176
  };
161
177
 
162
- state.setResource(Compute, { device, context, format, graph, resources });
178
+ state.setResource(Compute, {
179
+ device,
180
+ context,
181
+ format,
182
+ graph,
183
+ resources,
184
+ lastFrameTiming: null,
185
+ frameIndex: 0,
186
+ });
163
187
  },
164
188
  };
@@ -1,4 +1,5 @@
1
1
  import type { ComputeNode, NodeId, Phase, ResourceId, ResourceRef } from "./graph";
2
+ import type { FrameTiming } from "./timing";
2
3
 
3
4
  export interface NodeInfo {
4
5
  readonly id: NodeId;
@@ -147,12 +148,13 @@ function topoSortIds(nodes: readonly ComputeNode[]): NodeId[] {
147
148
  return sorted;
148
149
  }
149
150
 
150
- export function formatGraph(info: GraphInspection): string {
151
+ export function formatGraph(info: GraphInspection, timing?: FrameTiming): string {
151
152
  const lines: string[] = [];
152
153
  lines.push("=== Compute Graph ===");
153
154
  lines.push("");
154
155
 
155
156
  const nodeMap = new Map(info.nodes.map((n) => [n.id, n]));
157
+ const timingMap = new Map(timing?.nodes.map((t) => [t.nodeId, t]));
156
158
 
157
159
  for (const phase of PHASE_ORDER) {
158
160
  const nodeIds = info.byPhase.get(phase) ?? [];
@@ -173,6 +175,18 @@ export function formatGraph(info: GraphInspection): string {
173
175
  lines.push(` ${i + 1}. ${id}`);
174
176
  });
175
177
 
178
+ if (timing) {
179
+ lines.push("");
180
+ lines.push("Timing:");
181
+ for (const nodeId of info.executionOrder) {
182
+ const nodeTiming = timingMap.get(nodeId);
183
+ if (nodeTiming) {
184
+ lines.push(` ${nodeId}: ${nodeTiming.cpuMs.toFixed(2)}ms`);
185
+ }
186
+ }
187
+ lines.push(` Total: ${timing.totalCpuMs.toFixed(2)}ms`);
188
+ }
189
+
176
190
  return lines.join("\n");
177
191
  }
178
192
 
@@ -79,13 +79,15 @@ export function uploadCamera(
79
79
  device: GPUDevice,
80
80
  buffer: GPUBuffer,
81
81
  eid: number,
82
- aspect: number
82
+ width: number,
83
+ height: number
83
84
  ): void {
84
85
  const color = unpackColor(Camera.clearColor[eid]);
85
86
  clearColor.r = color.r;
86
87
  clearColor.g = color.g;
87
88
  clearColor.b = color.b;
88
89
 
90
+ const aspect = width / height;
89
91
  const proj =
90
92
  Camera.mode[eid] === CameraMode.Orthographic
91
93
  ? orthographic(Camera.size[eid], aspect, Camera.near[eid], Camera.far[eid])
@@ -96,4 +98,9 @@ export function uploadCamera(
96
98
 
97
99
  device.queue.writeBuffer(buffer, 0, viewProj as Float32Array<ArrayBuffer>);
98
100
  device.queue.writeBuffer(buffer, 64, world as Float32Array<ArrayBuffer>);
101
+ device.queue.writeBuffer(
102
+ buffer,
103
+ 176,
104
+ new Float32Array([Camera.mode[eid], Camera.size[eid], width, height])
105
+ );
99
106
  }