@multiplekex/shallot 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplekex/shallot",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -13,7 +13,7 @@
13
13
  "rust/transforms/pkg"
14
14
  ],
15
15
  "scripts": {
16
- "build:wasm": "./rust/transforms/build.sh",
16
+ "build:wasm": "bash ./rust/transforms/build.sh",
17
17
  "test": "bun test"
18
18
  },
19
19
  "dependencies": {
@@ -2,6 +2,7 @@ import { State } from "./state";
2
2
  import type { Plugin, Loading } from "./types";
3
3
  import { toposort, type System } from "./scheduler";
4
4
  import { registerComponent } from "./component";
5
+ import { initRuntime } from "./runtime";
5
6
 
6
7
  export class StateBuilder {
7
8
  static defaultPlugins: readonly Plugin[] = [];
@@ -118,7 +119,6 @@ export class StateBuilder {
118
119
  }
119
120
 
120
121
  if (this._scenes.length > 0) {
121
- const { initRuntime } = await import("./runtime");
122
122
  const { loadSceneFile } = await import("./xml");
123
123
  const runtime = await initRuntime();
124
124
  for (const scenePath of this._scenes) {
@@ -7,7 +7,7 @@ import {
7
7
  type ComputeNode,
8
8
  type ExecutionContext,
9
9
  } from "../../standard/compute";
10
- import { Render, RenderPlugin } from "../../standard/render";
10
+ import { Render, RenderPlugin, DEPTH_FORMAT } from "../../standard/render";
11
11
  import { Transform } from "../../standard/transforms";
12
12
  import { Line, Lines, LinesPlugin } from "../lines";
13
13
 
@@ -125,65 +125,72 @@ fn vs(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> Ver
125
125
  let line = lines[eid];
126
126
  let transform = matrices[eid];
127
127
 
128
+ // Extract scale from transform matrix
129
+ let scaleX = length(transform[0].xyz);
130
+ let scaleY = length(transform[1].xyz);
131
+ let scaleZ = length(transform[2].xyz);
132
+ let avgScale = (scaleX + scaleY + scaleZ) / 3.0;
133
+
134
+ // Skip rendering if scale is near zero
135
+ if avgScale < 0.001 {
136
+ var out: VertexOutput;
137
+ out.position = vec4(0.0, 0.0, -2.0, 1.0);
138
+ out.color = vec4(0.0);
139
+ return out;
140
+ }
141
+
128
142
  let start = transform[3].xyz;
129
143
  let rotation = mat3x3<f32>(transform[0].xyz, transform[1].xyz, transform[2].xyz);
130
144
  let end = start + rotation * line.offset;
131
145
 
132
- // Project both points to clip space
133
- let startClip = scene.viewProj * vec4(start, 1.0);
134
- let endClip = scene.viewProj * vec4(end, 1.0);
135
-
136
- // Convert to NDC
137
- let startNDC = startClip.xy / startClip.w;
138
- let endNDC = endClip.xy / endClip.w;
139
-
140
- // Direction in screen space
141
- let dir = endNDC - startNDC;
142
- let len = length(dir);
143
- let normDir = select(vec2(1.0, 0.0), dir / len, len > 0.0001);
146
+ // Line direction in world space
147
+ let lineVec = end - start;
148
+ let lineLen = length(lineVec);
149
+ let lineDir = select(vec3(1.0, 0.0, 0.0), lineVec / lineLen, lineLen > 0.0001);
144
150
 
145
- // Perpendicular in screen space (this naturally faces camera)
146
- let perp = vec2(-normDir.y, normDir.x);
151
+ // Camera position and direction to arrow
152
+ let cameraPos = scene.cameraWorld[3].xyz;
153
+ let anchorWorld = select(start, end, isEnd);
154
+ let toCamera = normalize(cameraPos - anchorWorld);
155
+
156
+ // Billboard perpendicular: perpendicular to both line and view direction
157
+ var perp = cross(lineDir, toCamera);
158
+ let perpLen = length(perp);
159
+ if perpLen < 0.001 {
160
+ // Line pointing at camera, pick arbitrary perpendicular
161
+ let up = select(vec3(0.0, 1.0, 0.0), vec3(1.0, 0.0, 0.0), abs(lineDir.y) > 0.9);
162
+ perp = normalize(cross(lineDir, up));
163
+ } else {
164
+ perp = perp / perpLen;
165
+ }
147
166
 
148
167
  // Arrow size scales with sqrt of line thickness and world-space distance to camera
149
- let cameraPos = scene.cameraWorld[3].xyz;
150
- let midpoint = (start + end) * 0.5;
151
- let distToCamera = length(cameraPos - midpoint);
168
+ let distToCamera = length(cameraPos - anchorWorld);
152
169
  let refDist = 15.0;
153
170
  let rawScale = distToCamera / refDist;
154
171
  let maxScale = 3.0;
155
172
  let zoomScale = max(1.0, rawScale / (1.0 + rawScale / maxScale));
156
- let baseSize = sqrt(line.thickness) * 0.008 * zoomScale;
157
- let arrowLength = arrow.size * baseSize * 3.0;
158
- let arrowWidth = arrow.size * baseSize * 1.5;
159
-
160
- // Pick anchor position and direction based on which end
161
- var anchorNDC: vec2<f32>;
162
- var anchorDepth: f32;
163
- var arrowDir: vec2<f32>;
164
-
165
- if isEnd {
166
- anchorNDC = endNDC;
167
- anchorDepth = endClip.z / endClip.w;
168
- arrowDir = normDir;
169
- } else {
170
- anchorNDC = startNDC;
171
- anchorDepth = startClip.z / startClip.w;
172
- arrowDir = -normDir;
173
- }
173
+ let baseSize = sqrt(line.thickness) * 0.1 * zoomScale;
174
+ let arrowLength = arrow.size * baseSize * 2.0;
175
+ let arrowWidth = arrow.size * baseSize * 0.8;
174
176
 
175
- // Build triangle centered on anchor (tip extends forward, base extends back)
176
- let halfLen = arrowLength * 0.5;
177
- var pos: vec2<f32>;
177
+ // Arrow direction along line
178
+ let arrowDir = select(-lineDir, lineDir, isEnd);
179
+
180
+ // Build triangle vertices in world space
181
+ var worldPos: vec3<f32>;
178
182
  switch vid {
179
- case 0u: { pos = anchorNDC + arrowDir * halfLen; }
180
- case 1u: { pos = anchorNDC - arrowDir * halfLen + perp * arrowWidth; }
181
- case 2u: { pos = anchorNDC - arrowDir * halfLen - perp * arrowWidth; }
182
- default: { pos = anchorNDC; }
183
+ case 0u: { worldPos = anchorWorld + arrowDir * arrowLength * 0.5; }
184
+ case 1u: { worldPos = anchorWorld - arrowDir * arrowLength * 0.5 + perp * arrowWidth; }
185
+ case 2u: { worldPos = anchorWorld - arrowDir * arrowLength * 0.5 - perp * arrowWidth; }
186
+ default: { worldPos = anchorWorld; }
183
187
  }
184
188
 
189
+ // Project to clip space
190
+ let clipPos = scene.viewProj * vec4(worldPos, 1.0);
191
+
185
192
  var out: VertexOutput;
186
- out.position = vec4(pos, anchorDepth, 1.0);
193
+ out.position = clipPos;
187
194
  out.color = vec4(line.color.rgb, line.color.a * line.opacity);
188
195
  return out;
189
196
  }
@@ -230,6 +237,11 @@ export function createArrowsPipeline(
230
237
  primitive: {
231
238
  topology: "triangle-list",
232
239
  },
240
+ depthStencil: {
241
+ format: DEPTH_FORMAT,
242
+ depthCompare: "less",
243
+ depthWriteEnabled: false,
244
+ },
233
245
  });
234
246
  }
235
247
 
@@ -239,7 +251,7 @@ export function createArrowsNode(config: ArrowsConfig): ComputeNode {
239
251
 
240
252
  return {
241
253
  id: "arrows",
242
- phase: "overlay",
254
+ phase: "transparent",
243
255
  inputs: [],
244
256
  outputs: [],
245
257
 
@@ -267,6 +279,8 @@ export function createArrowsNode(config: ArrowsConfig): ComputeNode {
267
279
  });
268
280
  }
269
281
 
282
+ const depthView = ctx.getTextureView("depth")!;
283
+
270
284
  const pass = encoder.beginRenderPass({
271
285
  colorAttachments: [
272
286
  {
@@ -275,6 +289,11 @@ export function createArrowsNode(config: ArrowsConfig): ComputeNode {
275
289
  storeOp: "store" as const,
276
290
  },
277
291
  ],
292
+ depthStencilAttachment: {
293
+ view: depthView,
294
+ depthLoadOp: "load" as const,
295
+ depthStoreOp: "store" as const,
296
+ },
278
297
  });
279
298
 
280
299
  pass.setPipeline(pipeline);
@@ -7,7 +7,7 @@ import {
7
7
  type ComputeNode,
8
8
  type ExecutionContext,
9
9
  } from "../../standard/compute";
10
- import { Render, RenderPlugin } from "../../standard/render";
10
+ import { Render, RenderPlugin, DEPTH_FORMAT } from "../../standard/render";
11
11
  import { Transform } from "../../standard/transforms";
12
12
 
13
13
  export const LineData = {
@@ -245,6 +245,11 @@ export function createLinesPipeline(
245
245
  primitive: {
246
246
  topology: "triangle-list",
247
247
  },
248
+ depthStencil: {
249
+ format: DEPTH_FORMAT,
250
+ depthCompare: "less",
251
+ depthWriteEnabled: false,
252
+ },
248
253
  });
249
254
  }
250
255
 
@@ -254,7 +259,7 @@ export function createLinesNode(config: LinesConfig): ComputeNode {
254
259
 
255
260
  return {
256
261
  id: "lines",
257
- phase: "overlay",
262
+ phase: "transparent",
258
263
  inputs: [],
259
264
  outputs: [],
260
265
 
@@ -281,6 +286,8 @@ export function createLinesNode(config: LinesConfig): ComputeNode {
281
286
  });
282
287
  }
283
288
 
289
+ const depthView = ctx.getTextureView("depth")!;
290
+
284
291
  const pass = encoder.beginRenderPass({
285
292
  colorAttachments: [
286
293
  {
@@ -289,6 +296,11 @@ export function createLinesNode(config: LinesConfig): ComputeNode {
289
296
  storeOp: "store" as const,
290
297
  },
291
298
  ],
299
+ depthStencilAttachment: {
300
+ view: depthView,
301
+ depthLoadOp: "load" as const,
302
+ depthStoreOp: "store" as const,
303
+ },
292
304
  });
293
305
 
294
306
  pass.setPipeline(pipeline);
@@ -1,10 +1,16 @@
1
1
  import { setTraits } from "../../core/component";
2
- import { clamp, lookAt, type State, type System, type Plugin } from "../../core";
2
+ import { clamp, lookAt, type State, type System, type Plugin, type MouseState } from "../../core";
3
3
  import { Transform } from "../../standard/transforms";
4
4
  import { Input, InputPlugin } from "../../standard/input";
5
5
 
6
6
  const Tau = Math.PI * 2;
7
7
 
8
+ export const OrbitButton = {
9
+ Left: 0,
10
+ Middle: 1,
11
+ Right: 2,
12
+ } as const;
13
+
8
14
  export const Orbit = {
9
15
  target: [] as number[],
10
16
  yaw: [] as number[],
@@ -20,6 +26,7 @@ export const Orbit = {
20
26
  smoothness: [] as number[],
21
27
  sensitivity: [] as number[],
22
28
  zoomSpeed: [] as number[],
29
+ button: [] as number[],
23
30
  };
24
31
 
25
32
  setTraits(Orbit, {
@@ -38,6 +45,7 @@ setTraits(Orbit, {
38
45
  smoothness: 0.3,
39
46
  sensitivity: 0.005,
40
47
  zoomSpeed: 0.025,
48
+ button: OrbitButton.Right,
41
49
  }),
42
50
  });
43
51
 
@@ -54,6 +62,12 @@ function angleDiff(from: number, to: number): number {
54
62
  return diff > Math.PI ? diff - Tau : diff;
55
63
  }
56
64
 
65
+ function isOrbitButton(mouse: Readonly<MouseState>, button: number): boolean {
66
+ if (button === OrbitButton.Left) return mouse.left;
67
+ if (button === OrbitButton.Middle) return mouse.middle;
68
+ return mouse.right;
69
+ }
70
+
57
71
  export const OrbitSystem: System = {
58
72
  group: "simulation",
59
73
 
@@ -70,7 +84,7 @@ export const OrbitSystem: System = {
70
84
  const maxDistance = Orbit.maxDistance[eid];
71
85
  const smoothness = Orbit.smoothness[eid];
72
86
 
73
- if (input?.mouse.right) {
87
+ if (input && isOrbitButton(input.mouse, Orbit.button[eid])) {
74
88
  Orbit.targetYaw[eid] -= input.mouse.deltaX * sensitivity;
75
89
  Orbit.targetPitch[eid] = clamp(
76
90
  Orbit.targetPitch[eid] + input.mouse.deltaY * sensitivity,
@@ -7,7 +7,7 @@ import {
7
7
  type ComputeNode,
8
8
  type ExecutionContext,
9
9
  } from "../../standard/compute";
10
- import { Render, RenderPlugin } from "../../standard/render";
10
+ import { Render, RenderPlugin, DEPTH_FORMAT } from "../../standard/render";
11
11
  import { Transform } from "../../standard/transforms";
12
12
 
13
13
  const MAX_GLYPHS = 50000;
@@ -304,7 +304,7 @@ struct GlyphInstance {
304
304
  posX: f32,
305
305
  posY: f32,
306
306
  posZ: f32,
307
- _pad0: f32,
307
+ entityId: u32,
308
308
  width: f32,
309
309
  height: f32,
310
310
  _pad1: vec2<f32>,
@@ -319,6 +319,7 @@ struct GlyphInstance {
319
319
  @group(0) @binding(1) var<storage, read> glyphs: array<GlyphInstance>;
320
320
  @group(0) @binding(2) var atlasTexture: texture_2d<f32>;
321
321
  @group(0) @binding(3) var atlasSampler: sampler;
322
+ @group(0) @binding(4) var<storage, read> matrices: array<mat4x4<f32>>;
322
323
 
323
324
  struct VertexOutput {
324
325
  @builtin(position) position: vec4<f32>,
@@ -369,14 +370,17 @@ fn vs(@builtin(vertex_index) vid: u32) -> VertexOutput {
369
370
  }
370
371
  }
371
372
 
372
- let worldPos = vec3(
373
+ let localPos3 = vec3(
373
374
  glyph.posX + localPos.x * glyph.width,
374
375
  glyph.posY + localPos.y * glyph.height,
375
376
  glyph.posZ
376
377
  );
377
378
 
379
+ let transform = matrices[glyph.entityId];
380
+ let worldPos = transform * vec4(localPos3, 1.0);
381
+
378
382
  var out: VertexOutput;
379
- out.position = scene.viewProj * vec4(worldPos, 1.0);
383
+ out.position = scene.viewProj * worldPos;
380
384
  out.uv = uv;
381
385
  out.color = glyph.color;
382
386
  return out;
@@ -429,6 +433,11 @@ function createTextPipeline(device: GPUDevice, format: GPUTextureFormat): GPURen
429
433
  topology: "triangle-list",
430
434
  cullMode: "none",
431
435
  },
436
+ depthStencil: {
437
+ format: DEPTH_FORMAT,
438
+ depthCompare: "less",
439
+ depthWriteEnabled: false,
440
+ },
432
441
  });
433
442
  }
434
443
 
@@ -437,6 +446,7 @@ export interface TextConfig {
437
446
  glyphs: GPUBuffer;
438
447
  atlas: GPUTextureView;
439
448
  sampler: GPUSampler;
449
+ matrices: GPUBuffer;
440
450
  getCount: () => number;
441
451
  }
442
452
 
@@ -446,7 +456,7 @@ export function createTextNode(config: TextConfig): ComputeNode {
446
456
 
447
457
  return {
448
458
  id: "text",
449
- phase: "overlay",
459
+ phase: "transparent",
450
460
  inputs: [],
451
461
  outputs: [],
452
462
 
@@ -469,10 +479,13 @@ export function createTextNode(config: TextConfig): ComputeNode {
469
479
  { binding: 1, resource: { buffer: config.glyphs } },
470
480
  { binding: 2, resource: config.atlas },
471
481
  { binding: 3, resource: config.sampler },
482
+ { binding: 4, resource: { buffer: config.matrices } },
472
483
  ],
473
484
  });
474
485
  }
475
486
 
487
+ const depthView = ctx.getTextureView("depth")!;
488
+
476
489
  const pass = encoder.beginRenderPass({
477
490
  colorAttachments: [
478
491
  {
@@ -481,6 +494,11 @@ export function createTextNode(config: TextConfig): ComputeNode {
481
494
  storeOp: "store" as const,
482
495
  },
483
496
  ],
497
+ depthStencilAttachment: {
498
+ view: depthView,
499
+ depthLoadOp: "load" as const,
500
+ depthStoreOp: "store" as const,
501
+ },
484
502
  });
485
503
 
486
504
  pass.setPipeline(pipeline);
@@ -523,6 +541,7 @@ const TextSystem: System = {
523
541
 
524
542
  const { device } = compute;
525
543
  const { atlas, staging, content } = text;
544
+ const stagingU32 = new Uint32Array(staging.buffer);
526
545
 
527
546
  let glyphCount = 0;
528
547
 
@@ -542,10 +561,6 @@ const TextSystem: System = {
542
561
  const offsetX = -layout.width * anchorX;
543
562
  const offsetY = -layout.height * anchorY;
544
563
 
545
- const baseX = Transform.posX[eid] + offsetX;
546
- const baseY = Transform.posY[eid] + offsetY;
547
- const baseZ = Transform.posZ[eid];
548
-
549
564
  const color = Text.color[eid];
550
565
  const r = ((color >> 16) & 0xff) / 255;
551
566
  const g = ((color >> 8) & 0xff) / 255;
@@ -557,10 +572,10 @@ const TextSystem: System = {
557
572
 
558
573
  const offset = glyphCount * GLYPH_FLOATS;
559
574
 
560
- staging[offset + 0] = baseX + glyph.x;
561
- staging[offset + 1] = baseY + glyph.y;
562
- staging[offset + 2] = baseZ;
563
- staging[offset + 3] = 0;
575
+ staging[offset + 0] = offsetX + glyph.x;
576
+ staging[offset + 1] = offsetY + glyph.y;
577
+ staging[offset + 2] = 0;
578
+ stagingU32[offset + 3] = eid;
564
579
 
565
580
  staging[offset + 4] = glyph.width;
566
581
  staging[offset + 5] = glyph.height;
@@ -634,6 +649,7 @@ export const TextPlugin: Plugin = {
634
649
  glyphs: textState.buffer,
635
650
  atlas: atlas.textureView,
636
651
  sampler,
652
+ matrices: render.matrices,
637
653
  getCount: () => textState.count,
638
654
  })
639
655
  );
@@ -1,10 +1,11 @@
1
1
  import { CycleError } from "../../core";
2
+ import { inspect as inspectGraph, type GraphInspection } from "./inspect";
2
3
 
3
4
  export type ResourceId = string;
4
5
  export type NodeId = string;
5
- export type Phase = "render" | "overlay" | "postprocess";
6
+ export type Phase = "opaque" | "transparent" | "postprocess";
6
7
 
7
- const PHASE_ORDER: Phase[] = ["render", "overlay", "postprocess"];
8
+ const PHASE_ORDER: Phase[] = ["opaque", "transparent", "postprocess"];
8
9
 
9
10
  export interface ResourceRef {
10
11
  id: ResourceId;
@@ -21,6 +22,9 @@ export interface ExecutionContext {
21
22
  getTexture(id: ResourceId): GPUTexture | null;
22
23
  getTextureView(id: ResourceId): GPUTextureView | null;
23
24
  getBuffer(id: ResourceId): GPUBuffer | null;
25
+ setTexture(id: ResourceId, texture: GPUTexture): void;
26
+ setTextureView(id: ResourceId, view: GPUTextureView): void;
27
+ setBuffer(id: ResourceId, buffer: GPUBuffer): void;
24
28
  }
25
29
 
26
30
  export interface ComputeNode {
@@ -115,7 +119,7 @@ function compile(nodes: ComputeNode[]): ExecutionPlan {
115
119
  }
116
120
 
117
121
  for (const node of nodes) {
118
- const phase = node.phase ?? "render";
122
+ const phase = node.phase ?? "opaque";
119
123
  byPhase.get(phase)!.push(node);
120
124
  }
121
125
 
@@ -162,4 +166,8 @@ export class ComputeGraph {
162
166
  }
163
167
  return this._plan;
164
168
  }
169
+
170
+ inspect(): GraphInspection {
171
+ return inspectGraph(Array.from(this.nodes.values()));
172
+ }
165
173
  }
@@ -2,6 +2,9 @@ import { resource, type Plugin, type State, type System } from "../../core";
2
2
  import { ComputeGraph, type ExecutionContext, type ResourceId } from "./graph";
3
3
 
4
4
  export * from "./graph";
5
+ export * from "./inspect";
6
+ export * from "./readback";
7
+ export * from "./timing";
5
8
 
6
9
  const MIN_CANVAS_SIZE = 1;
7
10
 
@@ -102,6 +105,15 @@ export const ComputeSystem: System = {
102
105
  getBuffer(id: ResourceId) {
103
106
  return resources.buffers.get(id) ?? null;
104
107
  },
108
+ setTexture(id: ResourceId, texture: GPUTexture) {
109
+ resources.textures.set(id, texture);
110
+ },
111
+ setTextureView(id: ResourceId, view: GPUTextureView) {
112
+ resources.textureViews.set(id, view);
113
+ },
114
+ setBuffer(id: ResourceId, buffer: GPUBuffer) {
115
+ resources.buffers.set(id, buffer);
116
+ },
105
117
  };
106
118
 
107
119
  for (const node of plan.sorted) {
@@ -0,0 +1,205 @@
1
+ import type { ComputeNode, NodeId, Phase, ResourceId, ResourceRef } from "./graph";
2
+
3
+ export interface NodeInfo {
4
+ readonly id: NodeId;
5
+ readonly phase: Phase;
6
+ readonly inputs: readonly ResourceRef[];
7
+ readonly outputs: readonly ResourceRef[];
8
+ }
9
+
10
+ export interface EdgeInfo {
11
+ readonly from: NodeId;
12
+ readonly to: NodeId;
13
+ readonly resource: ResourceId;
14
+ }
15
+
16
+ export interface GraphInspection {
17
+ readonly nodes: readonly NodeInfo[];
18
+ readonly edges: readonly EdgeInfo[];
19
+ readonly executionOrder: readonly NodeId[];
20
+ readonly byPhase: ReadonlyMap<Phase, readonly NodeId[]>;
21
+ }
22
+
23
+ const PHASE_ORDER: Phase[] = ["opaque", "transparent", "postprocess"];
24
+
25
+ export function inspect(nodes: readonly ComputeNode[]): GraphInspection {
26
+ const nodeInfos: NodeInfo[] = nodes.map((n) => ({
27
+ id: n.id,
28
+ phase: n.phase ?? "opaque",
29
+ inputs: n.inputs,
30
+ outputs: n.outputs,
31
+ }));
32
+
33
+ const producers = new Map<ResourceId, NodeId>();
34
+ for (const n of nodes) {
35
+ for (const out of n.outputs) {
36
+ producers.set(out.id, n.id);
37
+ }
38
+ }
39
+
40
+ const edges: EdgeInfo[] = [];
41
+ for (const n of nodes) {
42
+ for (const inp of n.inputs) {
43
+ const producer = producers.get(inp.id);
44
+ if (producer) {
45
+ edges.push({
46
+ from: producer,
47
+ to: n.id,
48
+ resource: inp.id,
49
+ });
50
+ }
51
+ }
52
+ }
53
+
54
+ const byPhase = new Map<Phase, NodeId[]>();
55
+ for (const phase of PHASE_ORDER) {
56
+ byPhase.set(phase, []);
57
+ }
58
+ for (const n of nodeInfos) {
59
+ byPhase.get(n.phase)!.push(n.id);
60
+ }
61
+
62
+ const executionOrder = topoSortIds(nodes);
63
+
64
+ return { nodes: nodeInfos, edges, executionOrder, byPhase };
65
+ }
66
+
67
+ function topoSortIds(nodes: readonly ComputeNode[]): NodeId[] {
68
+ if (nodes.length === 0) return [];
69
+
70
+ const producers = new Map<ResourceId, ComputeNode>();
71
+ for (const node of nodes) {
72
+ for (const output of node.outputs) {
73
+ producers.set(output.id, node);
74
+ }
75
+ }
76
+
77
+ const adjacency = new Map<ComputeNode, ComputeNode[]>();
78
+ const inDegree = new Map<ComputeNode, number>();
79
+
80
+ for (const node of nodes) {
81
+ adjacency.set(node, []);
82
+ inDegree.set(node, 0);
83
+ }
84
+
85
+ for (const node of nodes) {
86
+ for (const input of node.inputs) {
87
+ const producer = producers.get(input.id);
88
+ if (producer) {
89
+ adjacency.get(producer)!.push(node);
90
+ inDegree.set(node, inDegree.get(node)! + 1);
91
+ }
92
+ }
93
+ }
94
+
95
+ const byPhase = new Map<Phase, ComputeNode[]>();
96
+ for (const phase of PHASE_ORDER) {
97
+ byPhase.set(phase, []);
98
+ }
99
+ for (const node of nodes) {
100
+ const phase = node.phase ?? "opaque";
101
+ byPhase.get(phase)!.push(node);
102
+ }
103
+
104
+ const sorted: NodeId[] = [];
105
+ for (const phase of PHASE_ORDER) {
106
+ const phaseNodes = byPhase.get(phase)!;
107
+ const phaseInDegree = new Map<ComputeNode, number>();
108
+
109
+ for (const node of phaseNodes) {
110
+ let degree = 0;
111
+ for (const input of node.inputs) {
112
+ const producer = producers.get(input.id);
113
+ if (producer && phaseNodes.includes(producer)) {
114
+ degree++;
115
+ }
116
+ }
117
+ phaseInDegree.set(node, degree);
118
+ }
119
+
120
+ const queue: ComputeNode[] = [];
121
+ for (const node of phaseNodes) {
122
+ if (phaseInDegree.get(node) === 0) {
123
+ queue.push(node);
124
+ }
125
+ }
126
+
127
+ let i = 0;
128
+ while (i < queue.length) {
129
+ const node = queue[i++];
130
+ sorted.push(node.id);
131
+
132
+ for (const dep of phaseNodes) {
133
+ for (const input of dep.inputs) {
134
+ const producer = producers.get(input.id);
135
+ if (producer === node) {
136
+ const newDegree = phaseInDegree.get(dep)! - 1;
137
+ phaseInDegree.set(dep, newDegree);
138
+ if (newDegree === 0) {
139
+ queue.push(dep);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ return sorted;
148
+ }
149
+
150
+ export function formatGraph(info: GraphInspection): string {
151
+ const lines: string[] = [];
152
+ lines.push("=== Compute Graph ===");
153
+ lines.push("");
154
+
155
+ const nodeMap = new Map(info.nodes.map((n) => [n.id, n]));
156
+
157
+ for (const phase of PHASE_ORDER) {
158
+ const nodeIds = info.byPhase.get(phase) ?? [];
159
+ if (nodeIds.length === 0) continue;
160
+
161
+ lines.push(`[${phase}]`);
162
+ for (const id of nodeIds) {
163
+ const node = nodeMap.get(id)!;
164
+ const ins = node.inputs.map((r) => r.id).join(", ") || "none";
165
+ const outs = node.outputs.map((r) => r.id).join(", ") || "none";
166
+ lines.push(` ${id}: ${ins} -> ${outs}`);
167
+ }
168
+ lines.push("");
169
+ }
170
+
171
+ lines.push("Execution Order:");
172
+ info.executionOrder.forEach((id, i) => {
173
+ lines.push(` ${i + 1}. ${id}`);
174
+ });
175
+
176
+ return lines.join("\n");
177
+ }
178
+
179
+ export function toDot(info: GraphInspection): string {
180
+ const lines: string[] = ["digraph ComputeGraph {"];
181
+ lines.push(" rankdir=LR;");
182
+ lines.push(" node [shape=box];");
183
+ lines.push("");
184
+
185
+ for (const phase of PHASE_ORDER) {
186
+ const nodeIds = info.byPhase.get(phase) ?? [];
187
+ if (nodeIds.length === 0) continue;
188
+
189
+ lines.push(` subgraph cluster_${phase} {`);
190
+ lines.push(` label="${phase}";`);
191
+ for (const id of nodeIds) {
192
+ lines.push(` "${id}";`);
193
+ }
194
+ lines.push(" }");
195
+ }
196
+
197
+ lines.push("");
198
+
199
+ for (const edge of info.edges) {
200
+ lines.push(` "${edge.from}" -> "${edge.to}" [label="${edge.resource}"];`);
201
+ }
202
+
203
+ lines.push("}");
204
+ return lines.join("\n");
205
+ }
@@ -0,0 +1,88 @@
1
+ export async function readBuffer(
2
+ device: GPUDevice,
3
+ source: GPUBuffer,
4
+ size: number
5
+ ): Promise<ArrayBuffer> {
6
+ const staging = device.createBuffer({
7
+ label: "staging-readback",
8
+ size,
9
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
10
+ });
11
+
12
+ const encoder = device.createCommandEncoder();
13
+ encoder.copyBufferToBuffer(source, 0, staging, 0, size);
14
+ device.queue.submit([encoder.finish()]);
15
+
16
+ await staging.mapAsync(GPUMapMode.READ);
17
+ const data = staging.getMappedRange().slice(0);
18
+ staging.unmap();
19
+ staging.destroy();
20
+
21
+ return data;
22
+ }
23
+
24
+ export async function readFloat32(
25
+ device: GPUDevice,
26
+ buffer: GPUBuffer,
27
+ count: number
28
+ ): Promise<Float32Array> {
29
+ const data = await readBuffer(device, buffer, count * 4);
30
+ return new Float32Array(data);
31
+ }
32
+
33
+ export async function readUint32(
34
+ device: GPUDevice,
35
+ buffer: GPUBuffer,
36
+ count: number
37
+ ): Promise<Uint32Array> {
38
+ const data = await readBuffer(device, buffer, count * 4);
39
+ return new Uint32Array(data);
40
+ }
41
+
42
+ function nextPowerOf2(n: number): number {
43
+ const min = 256;
44
+ return Math.pow(2, Math.ceil(Math.log2(Math.max(n, min))));
45
+ }
46
+
47
+ export class StagingPool {
48
+ private readonly pool = new Map<number, GPUBuffer[]>();
49
+ private readonly device: GPUDevice;
50
+
51
+ constructor(device: GPUDevice) {
52
+ this.device = device;
53
+ }
54
+
55
+ acquire(size: number): GPUBuffer {
56
+ const bucketSize = nextPowerOf2(size);
57
+ const bucket = this.pool.get(bucketSize);
58
+
59
+ if (bucket && bucket.length > 0) {
60
+ return bucket.pop()!;
61
+ }
62
+
63
+ return this.device.createBuffer({
64
+ label: `staging-pool-${bucketSize}`,
65
+ size: bucketSize,
66
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
67
+ });
68
+ }
69
+
70
+ release(buffer: GPUBuffer): void {
71
+ const bucketSize = buffer.size;
72
+ let bucket = this.pool.get(bucketSize);
73
+ if (!bucket) {
74
+ bucket = [];
75
+ this.pool.set(bucketSize, bucket);
76
+ }
77
+ bucket.push(buffer);
78
+ }
79
+
80
+ dispose(): void {
81
+ for (const bucket of this.pool.values()) {
82
+ for (const buffer of bucket) {
83
+ buffer.destroy();
84
+ }
85
+ }
86
+ this.pool.clear();
87
+ }
88
+ }
@@ -0,0 +1,139 @@
1
+ import type { NodeId } from "./graph";
2
+
3
+ export interface NodeTiming {
4
+ readonly nodeId: NodeId;
5
+ readonly cpuMs: number;
6
+ readonly gpuNs?: number;
7
+ }
8
+
9
+ export interface FrameTiming {
10
+ readonly frameIndex: number;
11
+ readonly nodes: readonly NodeTiming[];
12
+ readonly totalCpuMs: number;
13
+ readonly totalGpuNs?: number;
14
+ }
15
+
16
+ export interface TimingConfig {
17
+ readonly enabled: boolean;
18
+ readonly gpuTimestamps: boolean;
19
+ readonly historySize: number;
20
+ }
21
+
22
+ export interface TimingState {
23
+ readonly config: TimingConfig;
24
+ readonly history: FrameTiming[];
25
+ readonly querySet?: GPUQuerySet;
26
+ readonly resolveBuffer?: GPUBuffer;
27
+ readonly readBuffer?: GPUBuffer;
28
+ }
29
+
30
+ export function supportsTimestampQuery(device: GPUDevice): boolean {
31
+ return device.features.has("timestamp-query");
32
+ }
33
+
34
+ export function createTimingState(
35
+ device: GPUDevice,
36
+ maxNodes: number,
37
+ historySize = 60
38
+ ): TimingState {
39
+ const gpuTimestamps = supportsTimestampQuery(device);
40
+
41
+ let querySet: GPUQuerySet | undefined;
42
+ let resolveBuffer: GPUBuffer | undefined;
43
+ let readBuffer: GPUBuffer | undefined;
44
+
45
+ if (gpuTimestamps) {
46
+ const queryCount = maxNodes * 2;
47
+ querySet = device.createQuerySet({
48
+ label: "timing-queries",
49
+ type: "timestamp",
50
+ count: queryCount,
51
+ });
52
+
53
+ resolveBuffer = device.createBuffer({
54
+ label: "timing-resolve",
55
+ size: queryCount * 8,
56
+ usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
57
+ });
58
+
59
+ readBuffer = device.createBuffer({
60
+ label: "timing-read",
61
+ size: queryCount * 8,
62
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
63
+ });
64
+ }
65
+
66
+ return {
67
+ config: { enabled: true, gpuTimestamps, historySize },
68
+ history: [],
69
+ querySet,
70
+ resolveBuffer,
71
+ readBuffer,
72
+ };
73
+ }
74
+
75
+ export async function readTimestamps(
76
+ device: GPUDevice,
77
+ timing: TimingState,
78
+ nodeCount: number
79
+ ): Promise<BigUint64Array | null> {
80
+ if (!timing.querySet || !timing.resolveBuffer || !timing.readBuffer) {
81
+ return null;
82
+ }
83
+
84
+ const encoder = device.createCommandEncoder();
85
+ encoder.resolveQuerySet(timing.querySet, 0, nodeCount * 2, timing.resolveBuffer, 0);
86
+ encoder.copyBufferToBuffer(timing.resolveBuffer, 0, timing.readBuffer, 0, nodeCount * 2 * 8);
87
+ device.queue.submit([encoder.finish()]);
88
+
89
+ await timing.readBuffer.mapAsync(GPUMapMode.READ);
90
+ const data = new BigUint64Array(timing.readBuffer.getMappedRange().slice(0));
91
+ timing.readBuffer.unmap();
92
+
93
+ return data;
94
+ }
95
+
96
+ export function disposeTimingState(timing: TimingState): void {
97
+ timing.querySet?.destroy();
98
+ timing.resolveBuffer?.destroy();
99
+ timing.readBuffer?.destroy();
100
+ }
101
+
102
+ export interface TimingCollector {
103
+ readonly nodeTimings: Map<NodeId, { start: number; end: number }>;
104
+ beginNode(nodeId: NodeId): void;
105
+ endNode(nodeId: NodeId): void;
106
+ finish(frameIndex: number): FrameTiming;
107
+ }
108
+
109
+ export function createTimingCollector(): TimingCollector {
110
+ const nodeTimings = new Map<NodeId, { start: number; end: number }>();
111
+
112
+ return {
113
+ nodeTimings,
114
+
115
+ beginNode(nodeId: NodeId): void {
116
+ nodeTimings.set(nodeId, { start: performance.now(), end: 0 });
117
+ },
118
+
119
+ endNode(nodeId: NodeId): void {
120
+ const timing = nodeTimings.get(nodeId);
121
+ if (timing) {
122
+ timing.end = performance.now();
123
+ }
124
+ },
125
+
126
+ finish(frameIndex: number): FrameTiming {
127
+ const nodes: NodeTiming[] = [];
128
+ let totalCpuMs = 0;
129
+
130
+ for (const [nodeId, timing] of nodeTimings) {
131
+ const cpuMs = timing.end - timing.start;
132
+ totalCpuMs += cpuMs;
133
+ nodes.push({ nodeId, cpuMs });
134
+ }
135
+
136
+ return { frameIndex, nodes, totalCpuMs };
137
+ },
138
+ };
139
+ }
@@ -30,6 +30,10 @@ const inputState: InputState = {
30
30
  };
31
31
 
32
32
  let canvas: HTMLCanvasElement | null = null;
33
+ let lastPointerX = 0;
34
+ let lastPointerY = 0;
35
+ let activePointerId: number | null = null;
36
+ let activeButton: number | null = null;
33
37
 
34
38
  function handleKeyDown(e: KeyboardEvent): void {
35
39
  if (!keys.has(e.code)) {
@@ -43,22 +47,55 @@ function handleKeyUp(e: KeyboardEvent): void {
43
47
  keysReleased.add(e.code);
44
48
  }
45
49
 
46
- function handleMouseDown(e: MouseEvent): void {
50
+ function setButtonState(button: number, pressed: boolean): void {
51
+ if (button === 0) mouse.left = pressed;
52
+ if (button === 1) mouse.middle = pressed;
53
+ if (button === 2) mouse.right = pressed;
54
+ }
55
+
56
+ function clearPointerState(): void {
57
+ if (activeButton !== null) {
58
+ setButtonState(activeButton, false);
59
+ }
60
+ activePointerId = null;
61
+ activeButton = null;
62
+ lastPointerX = 0;
63
+ lastPointerY = 0;
64
+ }
65
+
66
+ function handlePointerDown(e: PointerEvent): void {
47
67
  if (e.target !== canvas) return;
48
- if (e.button === 0) mouse.left = true;
49
- if (e.button === 1) mouse.middle = true;
50
- if (e.button === 2) mouse.right = true;
68
+ if (activePointerId === null) {
69
+ activePointerId = e.pointerId;
70
+ activeButton = e.button;
71
+ lastPointerX = e.clientX;
72
+ lastPointerY = e.clientY;
73
+ setButtonState(e.button, true);
74
+ canvas!.setPointerCapture(e.pointerId);
75
+ e.preventDefault();
76
+ }
77
+ }
78
+
79
+ function handlePointerUp(e: PointerEvent): void {
80
+ if (e.pointerId === activePointerId) {
81
+ canvas?.releasePointerCapture(e.pointerId);
82
+ clearPointerState();
83
+ }
51
84
  }
52
85
 
53
- function handleMouseUp(e: MouseEvent): void {
54
- if (e.button === 0) mouse.left = false;
55
- if (e.button === 1) mouse.middle = false;
56
- if (e.button === 2) mouse.right = false;
86
+ function handlePointerCancel(e: PointerEvent): void {
87
+ if (e.pointerId === activePointerId) {
88
+ clearPointerState();
89
+ }
57
90
  }
58
91
 
59
- function handleMouseMove(e: MouseEvent): void {
60
- mouse.deltaX += e.movementX;
61
- mouse.deltaY += e.movementY;
92
+ function handlePointerMove(e: PointerEvent): void {
93
+ if (e.pointerId !== activePointerId) return;
94
+ e.preventDefault();
95
+ mouse.deltaX += e.clientX - lastPointerX;
96
+ mouse.deltaY += e.clientY - lastPointerY;
97
+ lastPointerX = e.clientX;
98
+ lastPointerY = e.clientY;
62
99
  }
63
100
 
64
101
  function handleWheel(e: WheelEvent): void {
@@ -91,6 +128,10 @@ function clearAllState(): void {
91
128
  mouse.left = false;
92
129
  mouse.right = false;
93
130
  mouse.middle = false;
131
+ activePointerId = null;
132
+ activeButton = null;
133
+ lastPointerX = 0;
134
+ lastPointerY = 0;
94
135
  }
95
136
 
96
137
  export const InputSystem: System = {
@@ -99,12 +140,14 @@ export const InputSystem: System = {
99
140
  setup(state: State) {
100
141
  if (!state.canvas) return;
101
142
  canvas = state.canvas;
143
+ canvas.style.touchAction = "none";
102
144
 
103
145
  window.addEventListener("keydown", handleKeyDown);
104
146
  window.addEventListener("keyup", handleKeyUp);
105
- canvas.addEventListener("mousedown", handleMouseDown);
106
- window.addEventListener("mouseup", handleMouseUp);
107
- canvas.addEventListener("mousemove", handleMouseMove);
147
+ canvas.addEventListener("pointerdown", handlePointerDown);
148
+ window.addEventListener("pointerup", handlePointerUp);
149
+ window.addEventListener("pointercancel", handlePointerCancel);
150
+ window.addEventListener("pointermove", handlePointerMove);
108
151
  canvas.addEventListener("wheel", handleWheel, { passive: false });
109
152
  canvas.addEventListener("contextmenu", handleContextMenu);
110
153
 
@@ -115,9 +158,10 @@ export const InputSystem: System = {
115
158
  if (canvas) {
116
159
  window.removeEventListener("keydown", handleKeyDown);
117
160
  window.removeEventListener("keyup", handleKeyUp);
118
- canvas.removeEventListener("mousedown", handleMouseDown);
119
- window.removeEventListener("mouseup", handleMouseUp);
120
- canvas.removeEventListener("mousemove", handleMouseMove);
161
+ canvas.removeEventListener("pointerdown", handlePointerDown);
162
+ window.removeEventListener("pointerup", handlePointerUp);
163
+ window.removeEventListener("pointercancel", handlePointerCancel);
164
+ window.removeEventListener("pointermove", handlePointerMove);
121
165
  canvas.removeEventListener("wheel", handleWheel);
122
166
  canvas.removeEventListener("contextmenu", handleContextMenu);
123
167
  canvas = null;
@@ -1,7 +1,7 @@
1
1
  import { setTraits } from "../../core/component";
2
2
  import { WorldTransform } from "../transforms";
3
3
  import { clearColor } from "./forward";
4
- import { perspective, multiply, invert } from "./scene";
4
+ import { perspective, orthographic, multiply, invert } from "./scene";
5
5
 
6
6
  export const RenderMode = {
7
7
  Raster: 0,
@@ -16,6 +16,11 @@ export const DebugMode = {
16
16
  Hit: 4,
17
17
  } as const;
18
18
 
19
+ export const CameraMode = {
20
+ Perspective: 0,
21
+ Orthographic: 1,
22
+ } as const;
23
+
19
24
  export const Camera = {
20
25
  fov: [] as number[],
21
26
  near: [] as number[],
@@ -24,6 +29,8 @@ export const Camera = {
24
29
  clearColor: [] as number[],
25
30
  renderMode: [] as number[],
26
31
  debugMode: [] as number[],
32
+ mode: [] as number[],
33
+ size: [] as number[],
27
34
  };
28
35
 
29
36
  setTraits(Camera, {
@@ -35,6 +42,8 @@ setTraits(Camera, {
35
42
  clearColor: 0x1a1a1a,
36
43
  renderMode: RenderMode.Raster,
37
44
  debugMode: DebugMode.Color,
45
+ mode: CameraMode.Perspective,
46
+ size: 5,
38
47
  }),
39
48
  });
40
49
 
@@ -77,7 +86,10 @@ export function uploadCamera(
77
86
  clearColor.g = color.g;
78
87
  clearColor.b = color.b;
79
88
 
80
- const proj = perspective(Camera.fov[eid], aspect, Camera.near[eid], Camera.far[eid]);
89
+ const proj =
90
+ Camera.mode[eid] === CameraMode.Orthographic
91
+ ? orthographic(Camera.size[eid], aspect, Camera.near[eid], Camera.far[eid])
92
+ : perspective(Camera.fov[eid], aspect, Camera.near[eid], Camera.far[eid]);
81
93
  const world = WorldTransform.data.subarray(eid * 16, eid * 16 + 16);
82
94
  const view = invert(world);
83
95
  const viewProj = multiply(proj, view);
@@ -22,9 +22,9 @@ setTraits(DirectionalLight, {
22
22
  defaults: () => ({
23
23
  color: 0xffffff,
24
24
  intensity: 1.0,
25
- directionX: -0.5,
25
+ directionX: -0.6,
26
26
  directionY: -1.0,
27
- directionZ: -0.5,
27
+ directionZ: -0.8,
28
28
  }),
29
29
  });
30
30
 
@@ -6,26 +6,6 @@ struct VertexOutput {
6
6
  @location(0) uv: vec2f,
7
7
  }
8
8
 
9
- @vertex
10
- fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
11
- var positions = array<vec2f, 3>(
12
- vec2f(-1.0, -1.0),
13
- vec2f(3.0, -1.0),
14
- vec2f(-1.0, 3.0)
15
- );
16
-
17
- let pos = positions[vertexIndex];
18
-
19
- var output: VertexOutput;
20
- output.position = vec4f(pos, 0.0, 1.0);
21
- output.uv = (pos + 1.0) * 0.5;
22
- output.uv.y = 1.0 - output.uv.y;
23
- return output;
24
- }
25
-
26
- @group(0) @binding(0) var inputTexture: texture_2d<f32>;
27
- @group(0) @binding(1) var inputSampler: sampler;
28
-
29
9
  struct Uniforms {
30
10
  exposure: f32,
31
11
  vignetteStrength: f32,
@@ -37,12 +17,31 @@ struct Uniforms {
37
17
  _pad: u32,
38
18
  }
39
19
 
20
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
21
+ @group(0) @binding(1) var inputSampler: sampler;
40
22
  @group(0) @binding(2) var<uniform> uniforms: Uniforms;
41
23
 
42
24
  const FLAG_TONEMAP: u32 = 1u;
43
25
  const FLAG_FXAA: u32 = 2u;
44
26
  const FLAG_VIGNETTE: u32 = 4u;
45
27
 
28
+ @vertex
29
+ fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
30
+ var positions = array<vec2f, 3>(
31
+ vec2f(-1.0, -1.0),
32
+ vec2f(3.0, -1.0),
33
+ vec2f(-1.0, 3.0)
34
+ );
35
+
36
+ let pos = positions[vertexIndex];
37
+
38
+ var output: VertexOutput;
39
+ output.position = vec4f(pos, 0.0, 1.0);
40
+ output.uv = (pos + 1.0) * 0.5;
41
+ output.uv.y = 1.0 - output.uv.y;
42
+ return output;
43
+ }
44
+
46
45
  fn aces(x: vec3f) -> vec3f {
47
46
  let a = 2.51;
48
47
  let b = 0.03;
@@ -111,11 +110,11 @@ fn applyVignette(color: vec3f, uv: vec2f) -> vec3f {
111
110
  }
112
111
 
113
112
  @fragment
114
- fn fragmentMain(@location(0) uv: vec2f) -> @location(0) vec4f {
115
- var color = textureSample(inputTexture, inputSampler, uv).rgb;
113
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
114
+ var color = textureSample(inputTexture, inputSampler, input.uv).rgb;
116
115
 
117
116
  if (uniforms.flags & FLAG_FXAA) != 0u {
118
- color = applyFXAA(uv, color);
117
+ color = applyFXAA(input.uv, color);
119
118
  }
120
119
 
121
120
  if (uniforms.flags & FLAG_TONEMAP) != 0u {
@@ -123,7 +122,7 @@ fn fragmentMain(@location(0) uv: vec2f) -> @location(0) vec4f {
123
122
  }
124
123
 
125
124
  if (uniforms.flags & FLAG_VIGNETTE) != 0u {
126
- color = applyVignette(color, uv);
125
+ color = applyVignette(color, input.uv);
127
126
  }
128
127
 
129
128
  return vec4f(color, 1.0);
@@ -66,6 +66,18 @@ export function perspective(fov: number, aspect: number, near: number, far: numb
66
66
  ]);
67
67
  }
68
68
 
69
+ export function orthographic(
70
+ size: number,
71
+ aspect: number,
72
+ near: number,
73
+ far: number
74
+ ): Float32Array {
75
+ const lr = 1 / (size * aspect);
76
+ const bt = 1 / size;
77
+ const nf = 1 / (near - far);
78
+ return new Float32Array([lr, 0, 0, 0, 0, bt, 0, 0, 0, 0, nf, 0, 0, 0, near * nf, 1]);
79
+ }
80
+
69
81
  export function multiply(a: Float32Array, b: Float32Array): Float32Array {
70
82
  const out = new Float32Array(16);
71
83
  for (let i = 0; i < 4; i++) {
@@ -8,6 +8,6 @@ export {
8
8
  type TweenOptions,
9
9
  } from "./tween";
10
10
 
11
- export { Sequence, SequenceState, Pause, computeTweenDelays, finalizeSequences } from "./sequence";
11
+ export { Sequence, SequenceState, Pause, finalizeSequences } from "./sequence";
12
12
 
13
13
  export { EASING_FUNCTIONS, getEasing, getEasingIndex, type EasingFn } from "./easing";
@@ -64,6 +64,10 @@ function updateSequencePlayheads(state: State, dt: number): void {
64
64
  if (Sequence.state[seqEid] !== SequenceState.PLAYING) continue;
65
65
 
66
66
  const prevElapsed = Sequence.elapsed[seqEid] ?? 0;
67
+
68
+ if (prevElapsed === 0) {
69
+ computeTweenDelays(state, seqEid);
70
+ }
67
71
  const elapsed = prevElapsed + dt;
68
72
  Sequence.elapsed[seqEid] = elapsed;
69
73
 
@@ -135,7 +135,6 @@ export function finalizePendingTweens(state: State, context: ParseContext): void
135
135
  if (!binding) continue;
136
136
 
137
137
  state.addRelation(pending.tweenEid, TweenTarget, targetEid);
138
-
139
138
  Tween.to[pending.tweenEid] = parseFloat(pending.to);
140
139
  }
141
140
  pendingXmlTweens = [];