insomni 0.2.0-alpha.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.
@@ -0,0 +1,566 @@
1
+ import { n as resolveRoot } from "./root-CHradZKM.mjs";
2
+ import { a as ARC_FLOATS, c as CURVE_CAP_SQUARE, d as SHAPE_BYTES, f as SHAPE_CIRCLE, g as TRIANGLE_FLOATS, l as CURVE_FLOATS, m as SHAPE_RECT, o as CURVE_CAP_BUTT, p as SHAPE_ELLIPSE, s as CURVE_CAP_ROUND, u as SEGMENT_FLOATS } from "./assemble-BT3CXbSx.mjs";
3
+ import { $ as repackCurves, A as packCamera, B as HEAD_BYTES, C as COMPOSITE_UNIFORM_BYTES, Ct as FRAME_RING_CAPACITY, D as bakeViewProjection, Dt as convertRect, E as packFullScreenComposite, Et as captureSpaceMap, G as cullCommands, H as SLOT_BYTES, K as worldFrustumAabb, M as GLYPH_PARAMS_BYTES, N as GLYPH_PARAMS_FLOATS, O as createBakePipelines, P as createGlyphPipeline, Q as repackArcs, R as createOITPipelines, S as RetainedTier, St as RendererDebug, T as createCompositePipeline, Tt as summarize, U as buildSpatialGrid, W as queryRanges, _ as INSTANCE_DRAW_BYTES, a as blitBackbuffer, at as nextByteCapacity, b as createInstancePipelines, et as repackSegments, g as spriteOitEmitWgsl, h as createSpritePipelines, i as createTestRenderer, it as UberPack, j as runBakePass, k as createBakeTexture, m as createSpriteOitEmitPipeline, s as resolveGroupTransform, t as project, v as INSTANCE_DRAW_FLOATS, w as COMPOSITE_UNIFORM_FLOATS, wt as FrameRing, x as instanceOitEmitWgsl, y as createInstanceOitEmitPipeline, z as ABuffer } from "./space-CeDnj6eu.mjs";
4
+ import { l as SpriteSchema, s as ShapeSchema } from "./pipeline-BWCAZTKx.mjs";
5
+ import { i as padFrameRect } from "./frame-mHNdKRpF.mjs";
6
+ import { a as SPATIAL_HASH_WORKGROUP_SIZE, i as SPATIAL_HASH_PARAMS_WGSL, n as CLEAR_SPATIAL_HASH_WGSL, r as SPATIAL_HASH_PARAMS_BYTES, t as BUILD_SPATIAL_HASH_WGSL } from "./spatial-hash-C1crBjTo.mjs";
7
+ import { DirtyChannel, ManualScheduler, PointerRouter, RAFScheduler, RectSpace, SceneNode, SceneRoot, rectOverlaps } from "./spatial.mjs";
8
+ import "typegpu";
9
+ //#region src/shared/dynamic-buffer.ts
10
+ function nextPow2(value) {
11
+ if (value <= 1) return 1;
12
+ if (value > 1073741824) return 2 ** Math.ceil(Math.log2(value));
13
+ value--;
14
+ value |= value >> 1;
15
+ value |= value >> 2;
16
+ value |= value >> 4;
17
+ value |= value >> 8;
18
+ value |= value >> 16;
19
+ value++;
20
+ return value;
21
+ }
22
+ //#endregion
23
+ //#region src/pack/region-index.ts
24
+ /**
25
+ * Maps group identities to their byte spans within the `UberPack` buffer, and
26
+ * maintains an accumulated set of dirty byte ranges for the current frame.
27
+ *
28
+ * Default region granularity = one `Group` object. Callers that do not use
29
+ * explicit group tracking can pass a single sentinel object as `groupId`.
30
+ *
31
+ * Usage per frame:
32
+ * ```ts
33
+ * regions.reset();
34
+ * for (const [group, items] of scene.byGroup()) {
35
+ * regions.beginRegion(group, pack.byteLength);
36
+ * for (const item of items) pack.append(...);
37
+ * regions.endRegion(group, pack.byteLength);
38
+ * }
39
+ * // At mutation time:
40
+ * regions.markDirty(hoveredGroup);
41
+ * // Before upload:
42
+ * const ranges = regions.coalesced();
43
+ * regions.clearDirty();
44
+ * ```
45
+ */
46
+ var RegionIndex = class {
47
+ /** group identity → byte span in the current pack. */
48
+ _spans = /* @__PURE__ */ new Map();
49
+ /** Accumulated dirty ranges for this frame (may overlap / be out of order). */
50
+ _dirty = [];
51
+ /**
52
+ * Record the byte offset at which a group's instances BEGIN being packed.
53
+ * `byteOffset` is the value of `UberPack.byteLength` BEFORE the first
54
+ * `append` call for this group.
55
+ */
56
+ beginRegion(groupId, byteOffset) {
57
+ this._spans.set(groupId, {
58
+ start: byteOffset,
59
+ end: byteOffset
60
+ });
61
+ }
62
+ /**
63
+ * Record the byte offset at which a group's instances END being packed.
64
+ * `byteOffset` is the value of `UberPack.byteLength` AFTER the last
65
+ * `append` call for this group.
66
+ */
67
+ endRegion(groupId, byteOffset) {
68
+ const span = this._spans.get(groupId);
69
+ if (span !== void 0) span.end = byteOffset;
70
+ }
71
+ /**
72
+ * Reset all span records for a new frame. Call before re-packing. Does NOT
73
+ * clear dirty ranges (use `clearDirty` for that).
74
+ */
75
+ reset() {
76
+ this._spans.clear();
77
+ }
78
+ /**
79
+ * Mark a group's byte span as dirty. If the group has no recorded span
80
+ * (e.g. not packed this frame), this is a no-op.
81
+ */
82
+ markDirty(groupId) {
83
+ const span = this._spans.get(groupId);
84
+ if (span === void 0 || span.end === span.start) return;
85
+ this._dirty.push({
86
+ byteOffset: span.start,
87
+ byteLength: span.end - span.start
88
+ });
89
+ }
90
+ /**
91
+ * Return the minimal, sorted, non-overlapping set of dirty byte ranges.
92
+ *
93
+ * - Output is sorted ascending by `byteOffset`.
94
+ * - Adjacent or overlapping input ranges are merged into a single output range.
95
+ * - Non-destructive: does not mutate the internal `_dirty` list.
96
+ * - Returns an empty array when no ranges are dirty.
97
+ */
98
+ coalesced() {
99
+ if (this._dirty.length === 0) return [];
100
+ const sorted = this._dirty.slice().sort((a, b) => a.byteOffset - b.byteOffset);
101
+ const out = [{
102
+ byteOffset: sorted[0].byteOffset,
103
+ byteLength: sorted[0].byteLength
104
+ }];
105
+ for (let i = 1; i < sorted.length; i++) {
106
+ const last = out[out.length - 1];
107
+ const cur = sorted[i];
108
+ const lastEnd = last.byteOffset + last.byteLength;
109
+ if (cur.byteOffset <= lastEnd) {
110
+ const curEnd = cur.byteOffset + cur.byteLength;
111
+ if (curEnd > lastEnd) last.byteLength = curEnd - last.byteOffset;
112
+ } else out.push({
113
+ byteOffset: cur.byteOffset,
114
+ byteLength: cur.byteLength
115
+ });
116
+ }
117
+ return out;
118
+ }
119
+ /**
120
+ * Clear accumulated dirty ranges. Call after the GPU upload is complete.
121
+ * Does NOT reset span records — use `reset()` for that.
122
+ */
123
+ clearDirty() {
124
+ this._dirty.length = 0;
125
+ }
126
+ /**
127
+ * Return the recorded byte span for a group, or `undefined` if the group
128
+ * was not packed this frame. For testing / debugging only.
129
+ */
130
+ spanOf(groupId) {
131
+ const s = this._spans.get(groupId);
132
+ return s !== void 0 ? {
133
+ start: s.start,
134
+ end: s.end
135
+ } : void 0;
136
+ }
137
+ };
138
+ //#endregion
139
+ //#region src/tiers/tile-replay.ts
140
+ /**
141
+ * Row-major grid of command buckets over screen device-px space. The grid is
142
+ * built once per layout: every command is assigned to the tiles its dilated
143
+ * AABB touches, and a dirty-rect query returns the union of the touched tiles'
144
+ * buckets.
145
+ */
146
+ var TileGrid = class {
147
+ /** Tile edge length in device px (== {@link RendererConfig.tileSize}). */
148
+ cellSize;
149
+ cols;
150
+ rows;
151
+ /** Flat row-major array: `buckets[tileId]` = cmdIndex list (insertion order). */
152
+ buckets;
153
+ constructor(canvasW, canvasH, cellSize) {
154
+ if (cellSize <= 0) throw new RangeError(`TileGrid: cellSize must be positive, got ${cellSize}`);
155
+ this.cellSize = cellSize;
156
+ this.cols = Math.max(1, Math.ceil(canvasW / cellSize));
157
+ this.rows = Math.max(1, Math.ceil(canvasH / cellSize));
158
+ this.buckets = Array.from({ length: this.cols * this.rows }, () => []);
159
+ }
160
+ /** Row-major tile id for a `(col, row)` cell. */
161
+ tileId(col, row) {
162
+ return row * this.cols + col;
163
+ }
164
+ /**
165
+ * Rasterize an AABB (device px, half-open `[min, max)`) to the tile ids it
166
+ * touches. The AABB is dilated by +1px on every side before rasterizing so a
167
+ * shape's analytic anti-aliased fringe (which spills ≤1px past the geometric
168
+ * edge) is conservatively included — a shape sitting on a tile seam lands in
169
+ * both tiles. Out-of-range cells are clamped to the grid; a fully off-grid
170
+ * AABB yields an empty list.
171
+ */
172
+ tilesForAabb(minX, minY, maxX, maxY) {
173
+ const s = this.cellSize;
174
+ const c0 = Math.max(0, Math.floor((minX - 1) / s));
175
+ const r0 = Math.max(0, Math.floor((minY - 1) / s));
176
+ const c1 = Math.min(this.cols - 1, Math.floor((maxX + 1) / s));
177
+ const r1 = Math.min(this.rows - 1, Math.floor((maxY + 1) / s));
178
+ const ids = [];
179
+ for (let r = r0; r <= r1; r++) for (let c = c0; c <= c1; c++) ids.push(this.tileId(c, r));
180
+ return ids;
181
+ }
182
+ /**
183
+ * Rasterize a {@link FrameRect} (CSS px) to a tile-id set. The rect is first
184
+ * padded by 1px — the SAME `padFrameRect(rect, 1)` the T22 clear-quad step
185
+ * applies — then converted to device px via `dpr`, then handed to
186
+ * {@link tilesForAabb} (which adds its own +1px AA dilation). The query rect
187
+ * thus covers at least the pixels the per-rect erase will actually repaint.
188
+ */
189
+ tilesForRect(rect, dpr) {
190
+ const padded = padFrameRect(rect, 1);
191
+ const minX = padded.x * dpr;
192
+ const minY = padded.y * dpr;
193
+ const maxX = (padded.x + padded.width) * dpr;
194
+ const maxY = (padded.y + padded.height) * dpr;
195
+ return new Set(this.tilesForAabb(minX, minY, maxX, maxY));
196
+ }
197
+ /**
198
+ * Assign a command (by its index in the renderer's command array) to every
199
+ * tile its screen AABB intersects. The same +1px AA dilation as
200
+ * {@link tilesForAabb} applies, so a command straddling a tile boundary is
201
+ * dropped into every touched bucket — the dirty-rect query then issues it
202
+ * exactly once per region pass (via {@link commandsForTiles} dedup).
203
+ */
204
+ assign(cmdIndex, aabb) {
205
+ const ids = this.tilesForAabb(aabb.minX, aabb.minY, aabb.maxX, aabb.maxY);
206
+ for (const id of ids) this.buckets[id].push(cmdIndex);
207
+ }
208
+ /**
209
+ * Union of command indices across a set of tile ids, deduplicated and sorted
210
+ * ascending. Ascending = original scene/submission order, so the caller's
211
+ * opaque-reverse sweep (`for i = n-1 … 0`) and transparent-forward sweep match
212
+ * v1's painter stacking. A command in two dirty tiles appears once.
213
+ */
214
+ commandsForTiles(tileIds) {
215
+ const seen = /* @__PURE__ */ new Set();
216
+ const result = [];
217
+ for (const id of tileIds) {
218
+ const bucket = this.buckets[id];
219
+ if (bucket === void 0) continue;
220
+ for (const cmdIndex of bucket) if (!seen.has(cmdIndex)) {
221
+ seen.add(cmdIndex);
222
+ result.push(cmdIndex);
223
+ }
224
+ }
225
+ return result.sort((a, b) => a - b);
226
+ }
227
+ /** Total tile count (`cols × rows`). */
228
+ get totalTiles() {
229
+ return this.cols * this.rows;
230
+ }
231
+ };
232
+ /**
233
+ * Full-frame fallback threshold. When the dirty-tile fraction exceeds this,
234
+ * {@link dirtyTileSet} returns `null` so the caller skips bucket replay and runs
235
+ * a single full-frame pass instead — thousands of scissor switches across most
236
+ * of the canvas cost more than one unscissored re-render.
237
+ */
238
+ const FULL_FRAME_TILE_FRACTION = .5;
239
+ /**
240
+ * Map damage regions to the set of tile ids to replay. Unions the tiles each
241
+ * region touches (via {@link TileGrid.tilesForRect}). Returns `null` — NOT an
242
+ * empty set — when the dirty fraction strictly exceeds
243
+ * {@link FULL_FRAME_TILE_FRACTION}, signalling "fall back to a full frame". An
244
+ * empty/zero-area region set returns an empty `Set` (nothing to replay), which
245
+ * the caller distinguishes from `null`.
246
+ */
247
+ function dirtyTileSet(grid, regions, dpr) {
248
+ const dirty = /* @__PURE__ */ new Set();
249
+ for (const region of regions) for (const id of grid.tilesForRect(region, dpr)) dirty.add(id);
250
+ if (dirty.size / grid.totalTiles > .5) return null;
251
+ return dirty;
252
+ }
253
+ //#endregion
254
+ //#region src/damage/spine.ts
255
+ const FULL_PLANE_INTEREST = [{
256
+ rect: {
257
+ x: -1e9,
258
+ y: -1e9,
259
+ w: 2e9,
260
+ h: 2e9
261
+ },
262
+ kind: "paint"
263
+ }];
264
+ /**
265
+ * Frame-loop primitive for v3. Owns a DirtyChannel<DirtyRegion> wired to a
266
+ * Scheduler. Callers mark damage via mark(); the channel coalesces and
267
+ * schedules a single flush that invokes onFlush with the union of all marked
268
+ * regions. Empty flushes (no mark since last pump) are suppressed by the
269
+ * DirtyChannel itself.
270
+ *
271
+ * T61's GpuSceneRoot owns one FrameSpine and delegates damage marking to it.
272
+ */
273
+ var FrameSpine = class {
274
+ channel;
275
+ scheduler;
276
+ #unsubscribe;
277
+ constructor({ scheduler, onFlush }) {
278
+ this.scheduler = scheduler ?? new RAFScheduler();
279
+ this.channel = new DirtyChannel(RectSpace, this.scheduler);
280
+ this.#unsubscribe = this.channel.subscribe(() => FULL_PLANE_INTEREST, (dirty) => onFlush(dirty));
281
+ }
282
+ /**
283
+ * Accumulate damage and schedule a flush. An empty regions array is a
284
+ * fast-path no-op — nothing is scheduled.
285
+ */
286
+ mark(regions) {
287
+ if (regions.length === 0) return;
288
+ this.channel.mark(regions);
289
+ }
290
+ /**
291
+ * Dispose the spine. Removes the subscriber and cancels any pending
292
+ * scheduled flush.
293
+ */
294
+ dispose() {
295
+ this.#unsubscribe();
296
+ this.scheduler.cancel?.();
297
+ }
298
+ };
299
+ /**
300
+ * Creates a FrameSpine backed by a ManualScheduler. Call pump() to drain
301
+ * any pending flush synchronously.
302
+ *
303
+ * @example
304
+ * const { spine, pump } = createTestSpine((r) => calls.push(r));
305
+ * spine.mark([{ rect: { x: 0, y: 0, w: 10, h: 10 }, kind: "paint" }]);
306
+ * pump(); // fires onFlush once
307
+ */
308
+ function createTestSpine(onFlush) {
309
+ const scheduler = new ManualScheduler();
310
+ return {
311
+ spine: new FrameSpine({
312
+ scheduler,
313
+ onFlush
314
+ }),
315
+ pump: () => scheduler.pump()
316
+ };
317
+ }
318
+ //#endregion
319
+ //#region src/damage/scene-root.ts
320
+ /**
321
+ * Type guard for {@link PaintCtx}. Guards the `paint(ctx: unknown)` downcast in
322
+ * {@link GpuSceneNode}: the structural contract is that the recursive descent
323
+ * always passes a live `PaintCtx` object — but the method signature is `unknown`
324
+ * to satisfy the base `SceneNode.paint` contract, which does not know about v3
325
+ * frame state. The guard makes the expectation explicit and avoids a bare `as`.
326
+ */
327
+ function isPaintCtx(ctx) {
328
+ return ctx !== null && typeof ctx === "object" && "layer" in ctx && "projectOpts" in ctx;
329
+ }
330
+ var GpuSceneNode = class extends SceneNode {
331
+ /**
332
+ * Paint this node's WHOLE subtree (self + every descendant), threading the
333
+ * SAME `ctx`. This is the uncull­ed entry used when a caller wants to force a
334
+ * subtree repaint regardless of damage. The damage-tracked frame path does NOT
335
+ * call this — it uses {@link GpuSceneRoot}'s recursive cull, which tests every
336
+ * node's bounds against the damage regions and prunes missed subtrees. Keeping
337
+ * the cull out of `paint` means a hit subtree still walks fully, while the cull
338
+ * decides which subtrees to enter.
339
+ */
340
+ paint(ctx) {
341
+ if (!isPaintCtx(ctx)) throw new Error("[insomni v3] GpuSceneNode.paint called with a non-PaintCtx argument. Only the v3 frame spine should call paint(); use drawSelf(ctx) directly for programmatic repaints.");
342
+ this.drawSelf(ctx);
343
+ for (const child of this.children) child.paint(ctx);
344
+ }
345
+ /**
346
+ * Project this node's bounds to a screen-pixel `FrameRect` via the T21
347
+ * `project` hook. Call this BEFORE `markDamaged` when a node authors in a
348
+ * non-`ui` space so the emitted damage rect already lives in the screen-pixel
349
+ * space the cull + scissor share. (For a plain `ui` scene graph the bounds are
350
+ * already CSS px and `markDamaged` may be called directly.)
351
+ *
352
+ * `space` defaults to `"ui"` — the natural space for a CSS-pixel scene graph
353
+ * whose `bounds` are authored in screen pixels.
354
+ */
355
+ projectBounds(ctx, space = "ui") {
356
+ const b = this.bounds;
357
+ return project(b.x, b.y, b.x + b.w, b.y + b.h, space, ctx.projectOpts);
358
+ }
359
+ /** Mark a visual-only change: repaint, no data/layout rebuild. */
360
+ invalidatePaint(rect) {
361
+ this.markDamaged("paint", rect);
362
+ }
363
+ /** Mark a layout change (bounds/children moved). Triggers doLayout + paint. */
364
+ invalidateLayout(rect) {
365
+ this.markDamaged("layout", rect);
366
+ }
367
+ /** Mark a data change (inputs replaced). Triggers rebuildData → layout → paint. */
368
+ invalidateData(rect) {
369
+ this.markDamaged("data", rect);
370
+ }
371
+ };
372
+ var GpuSceneRoot = class extends SceneRoot {
373
+ /** The scene layer that descendant nodes paint into. */
374
+ layer;
375
+ /** The underlying v3 WebGPU renderer. */
376
+ gpu;
377
+ /** Reverse-z pointer routing with per-pointerId capture/drag/hover. */
378
+ pointer;
379
+ /**
380
+ * When `true`, repaint every node on any damage and ignore the per-node cull
381
+ * (set from {@link GpuSceneRootOptions.fullFrame}, default `false`).
382
+ * Passed through to `endFrame()` so the renderer does a full repaint.
383
+ */
384
+ fullFrame;
385
+ _spine;
386
+ _projectOpts;
387
+ _groups;
388
+ _dataKey;
389
+ /** Our own copy of the timing hook — the base keeps `onFrameTiming` private,
390
+ * and we override `_renderFrame`, so we read it directly. */
391
+ _onFrameTiming;
392
+ /** Damage regions handed to the in-flight `beginFrame`; consumed by `endFrame`. */
393
+ _currentRegions = [];
394
+ constructor(gpu, layer, options) {
395
+ const inertRenderer = {
396
+ beginFrame() {},
397
+ endFrame() {}
398
+ };
399
+ const baseOpts = {
400
+ scheduler: options.scheduler,
401
+ onFrameTiming: options.onFrameTiming
402
+ };
403
+ super(inertRenderer, baseOpts);
404
+ this.gpu = gpu;
405
+ this.layer = layer;
406
+ this.fullFrame = options.fullFrame ?? false;
407
+ this._projectOpts = options.projectOpts;
408
+ this._groups = options.groups;
409
+ this._dataKey = options.dataKey;
410
+ this._onFrameTiming = options.onFrameTiming;
411
+ this._spine = new FrameSpine({
412
+ scheduler: options.scheduler,
413
+ onFlush: (dirty) => this._renderFrameV3(dirty)
414
+ });
415
+ this.pointer = new PointerRouter(this);
416
+ }
417
+ /** Stash the frame's damage regions and clear the scene layer. */
418
+ beginFrame(regions) {
419
+ this._currentRegions = regions;
420
+ this.layer.clear();
421
+ }
422
+ /**
423
+ * Submit one v3 GPU frame. Each damage `Rect` maps to ONE `Bounds2D` region
424
+ * (1:1 — never unioned), so the renderer's per-rect scissor path repaints each
425
+ * disjoint rect independently. `viewKey` folds the active group transforms +
426
+ * data key so a group move forces a full frame.
427
+ */
428
+ endFrame() {
429
+ this.gpu.render([this.layer], {
430
+ regions: this._currentRegions.map(rectToBounds),
431
+ fullFrame: this.fullFrame,
432
+ viewKey: this._viewKey()
433
+ });
434
+ }
435
+ /**
436
+ * Called by `SceneNode.markDamaged` (structural-type contract:
437
+ * `typeof root._emitDamage === "function"`). Routes the damage into the
438
+ * FrameSpine instead of the base SceneRoot's own channel, so a single
439
+ * coalesced flush per frame drives `_renderFrameV3`.
440
+ */
441
+ _emitDamage(damage) {
442
+ this._spine.mark([damage]);
443
+ }
444
+ /** Force a full repaint of the whole root bounds on the next scheduled frame. */
445
+ requestPaint() {
446
+ this._emitDamage({
447
+ rect: this.bounds,
448
+ kind: "paint"
449
+ });
450
+ }
451
+ /**
452
+ * Mark a paint-damage region directly on the root (CSS px). Useful when an
453
+ * external surface (a resize, a host overlay) damages a screen rect that is
454
+ * not owned by a single node. Routes through the spine like any node damage.
455
+ */
456
+ requestPaintRegion(rect) {
457
+ this._emitDamage({
458
+ rect,
459
+ kind: "paint"
460
+ });
461
+ }
462
+ /**
463
+ * Run one frame from a coalesced dirty set. Mirrors the base SceneRoot
464
+ * pipeline (rebuildData on data-damage, doLayout on non-paint damage) but
465
+ * replaces the one-level `_paintCulled` with the recursive cull and uses our
466
+ * own `beginFrame`/`endFrame`. `fullFrame` repaints the whole bounds.
467
+ */
468
+ _renderFrameV3(dirty) {
469
+ const timing = this._onFrameTiming;
470
+ const t0 = timing ? performance.now() : 0;
471
+ for (const d of dirty) {
472
+ const node = d.node;
473
+ if (d.kind === "data" && node?.rebuildData) node.rebuildData();
474
+ }
475
+ for (const d of dirty) {
476
+ const node = d.node;
477
+ if (d.kind !== "paint" && node?.doLayout) node.doLayout();
478
+ }
479
+ const regions = this.fullFrame ? [this.bounds] : dirty.map((d) => d.rect);
480
+ const t1 = timing ? performance.now() : 0;
481
+ this.beginFrame(regions);
482
+ const ctx = {
483
+ layer: this.layer,
484
+ projectOpts: this._projectOpts()
485
+ };
486
+ const paintedNodes = this._paintCulledRecursive(this, regions, ctx);
487
+ this.endFrame();
488
+ if (timing) timing({
489
+ layoutMs: t1 - t0,
490
+ paintMs: performance.now() - t1,
491
+ paintedNodes
492
+ });
493
+ }
494
+ /**
495
+ * Recursive damage cull — the v3 fix for v1's one-level cull. For each child
496
+ * of `node`: if its bounds miss EVERY region, prune the entire subtree (no
497
+ * `drawSelf`, no recursion into ITS children). On a hit, paint the child via
498
+ * `drawSelf(ctx)` and recurse — so the SAME per-region test applies at every
499
+ * depth, not just the root's direct children. A leaf deep in a large container
500
+ * therefore repaints only the nodes whose AABB actually touches a damage rect,
501
+ * and the cost scales with the damaged area, not the scene size.
502
+ *
503
+ * Pruning a missed subtree is sound: a node's bounds are expected to bound its
504
+ * own painted geometry (and, when it `clipsOverflow`, its descendants too), so
505
+ * a subtree whose AABB misses every damage rect contributes nothing inside any
506
+ * rect. (Nodes that paint outside their declared bounds must widen `bounds`.)
507
+ *
508
+ * Note: this does NOT call `GpuSceneNode.paint` (which would recurse
509
+ * unconditionally and defeat the deep cull) — it owns the descent itself.
510
+ */
511
+ _paintCulledRecursive(node, regions, ctx) {
512
+ let painted = 0;
513
+ for (const child of node.children) {
514
+ let hit = false;
515
+ for (const region of regions) if (rectOverlaps(region, child.bounds)) {
516
+ hit = true;
517
+ break;
518
+ }
519
+ if (!hit) continue;
520
+ child.drawSelf(ctx);
521
+ painted++;
522
+ painted += this._paintCulledRecursive(child, regions, ctx);
523
+ }
524
+ return painted;
525
+ }
526
+ /**
527
+ * Composite `viewKey`: fold every active group's compounded transform, then
528
+ * the data key, into one string. Handed to `render(..., { viewKey })`; a
529
+ * change flips the renderer's view fingerprint and forces a full frame.
530
+ */
531
+ _viewKey() {
532
+ let key = "";
533
+ const groups = this._groups?.();
534
+ if (groups) for (const g of groups) {
535
+ const m = resolveGroupTransform(g);
536
+ key += `${m[0]},${m[1]},${m[3]},${m[4]},${m[6]},${m[7]};`;
537
+ }
538
+ const dk = this._dataKey?.();
539
+ if (dk !== void 0) key += `|${dk}`;
540
+ return key;
541
+ }
542
+ /**
543
+ * Dispatch a pointer event through the {@link PointerRouter}: `down` hit-tests
544
+ * (reverse-z, last-child-wins, depth-first — the inherited `SceneRoot.hitTest`)
545
+ * and captures by `pointerId`; `move`/`up`/`cancel` route to the captured node
546
+ * (or, for an uncaptured `move`, re-hit). Returns the receiving node or null.
547
+ */
548
+ dispatchPointer(e) {
549
+ return this.pointer.dispatch(e);
550
+ }
551
+ /** Dispose the frame spine (removes the subscriber, cancels any pending flush). */
552
+ dispose() {
553
+ this._spine.dispose();
554
+ }
555
+ };
556
+ /** Convert a spatial `Rect` to the renderer's `Bounds2D` damage-region shape. */
557
+ function rectToBounds(r) {
558
+ return {
559
+ minX: r.x,
560
+ minY: r.y,
561
+ maxX: r.x + r.w,
562
+ maxY: r.y + r.h
563
+ };
564
+ }
565
+ //#endregion
566
+ export { ABuffer, ARC_FLOATS, BUILD_SPATIAL_HASH_WGSL, CLEAR_SPATIAL_HASH_WGSL, COMPOSITE_UNIFORM_BYTES, COMPOSITE_UNIFORM_FLOATS, CURVE_CAP_BUTT, CURVE_CAP_ROUND, CURVE_CAP_SQUARE, CURVE_FLOATS, FRAME_RING_CAPACITY, FULL_FRAME_TILE_FRACTION, FrameRing, FrameSpine, GLYPH_PARAMS_BYTES, GLYPH_PARAMS_FLOATS, GpuSceneNode, GpuSceneRoot, HEAD_BYTES, INSTANCE_DRAW_BYTES, INSTANCE_DRAW_FLOATS, RegionIndex, RendererDebug, RetainedTier, SEGMENT_FLOATS, SHAPE_BYTES, SHAPE_CIRCLE, SHAPE_ELLIPSE, SHAPE_RECT, SLOT_BYTES, SPATIAL_HASH_PARAMS_BYTES, SPATIAL_HASH_PARAMS_WGSL, SPATIAL_HASH_WORKGROUP_SIZE, ShapeSchema, SpriteSchema, TRIANGLE_FLOATS, TileGrid, UberPack, bakeViewProjection, blitBackbuffer, buildSpatialGrid, captureSpaceMap, convertRect, createBakePipelines, createBakeTexture, createCompositePipeline, createGlyphPipeline, createInstanceOitEmitPipeline, createInstancePipelines, createOITPipelines, createSpriteOitEmitPipeline, createSpritePipelines, createTestRenderer, createTestSpine, cullCommands, dirtyTileSet, instanceOitEmitWgsl, nextByteCapacity, nextPow2, packCamera, packFullScreenComposite, queryRanges, rectToBounds, repackArcs, repackCurves, repackSegments, resolveRoot, runBakePass, spriteOitEmitWgsl, summarize, worldFrustumAabb };
@@ -0,0 +1,15 @@
1
+ //#region src/core/logger.ts
2
+ let _logger = console;
3
+ /**
4
+ * Install the process-wide diagnostic logger. Pass `null`/`undefined` to reset
5
+ * back to the {@link console} default.
6
+ */
7
+ function setLogger(logger) {
8
+ _logger = logger ?? console;
9
+ }
10
+ /** The active diagnostic logger. Defaults to {@link console}. */
11
+ function getLogger() {
12
+ return _logger;
13
+ }
14
+ //#endregion
15
+ export { setLogger as n, getLogger as t };