@solidrt/2d 0.0.52

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/AGENTS.md ADDED
@@ -0,0 +1,219 @@
1
+ # @solidrt/2d - agent notes
2
+
3
+ An instanced sprite layer above `@solidrt/core/gpu`: one atlas texture, N
4
+ quads in ONE draw call, composited into the app as an ordinary `<texture>`
5
+ leaf. The live layer backs every sprite with a SPATIAL ARENA node (never a
6
+ rendertree element - `d-texture` sprites are the right tool up to the low
7
+ thousands; measured ~0.65us paint and ~15KB memory per NODE, and every
8
+ moved node is two setProperty FFI calls per frame). The node makes sprites
9
+ citizens of the spatial core: native producers (node transitions, animation
10
+ clips, physics) reach them through `sprite.node`, hierarchy recomputes
11
+ moved subtrees in Rust, and picking walks the core BVH.
12
+
13
+ ## The model
14
+
15
+ - THREE faces, layered: the node-backed live layer (layer.ts:
16
+ `createSpriteLayer`/`addSprite`/`setSprite`/`removeSprite` plus
17
+ `addGroup`/`setGroup`/`setSpriteParent`/`setSpriteTransition`/
18
+ `setGroupTransition` - plain objects, no signals, usable without
19
+ components), the records layer (records.ts: `createRecordLayer` - the
20
+ raw escape hatch, below), and the component face (components.tsx:
21
+ `SpriteLayer`/`Sprite`/`Group` over context).
22
+ - Node layer ownership split, two instance-buffer slots on one pipeline:
23
+ slot 0 is the POSE buffer `[x, y, angle, sx, sy]` written ONLY by the
24
+ core (each sprite node's Pose2D record sink; one coalesced buffer write
25
+ per flush however many nodes moved), slot 1 the STYLE buffer
26
+ `[u0, v0, u1, v1, tint rgba]`, JS-owned, published through the zero-copy
27
+ write lease. NEVER write the pose buffer from JS - the core's staging
28
+ mirror owns it and will overwrite.
29
+ - Sprites hold FIXED instance slots: draw order is slot order, removal
30
+ zeroes the pose (zero scale = nothing drawn) and recycles the slot to
31
+ the next add. No painter's-insertion-order guarantee across removals;
32
+ opaque-or-transparent pixel art never notices, z-ordered translucency is
33
+ the sort-key backlog item (okf/backlog/2d-sprite-sort-key.md).
34
+ - Growth (past `capacity`, doubling): pose sinks move in ONE core
35
+ `retargetRecords` call (full republish next flush), style re-uploads,
36
+ `setDraw({ instanceBuffers })` swaps both, old buffers destroyed.
37
+ - Picking is the core index: `pick` raycasts [x, y, -1] along +z (exact
38
+ rotated-rect via the node's local box), topmost = highest slot;
39
+ `pickRect` is the BVH overlap query (exact for rotated sprites, the
40
+ marquee). Both filter to the layer's own nodes - the arena is shared
41
+ with e.g. a 3d scene.
42
+ - Groups (`addGroup`/`<Group>`) are plain arena nodes (x, y, rotation,
43
+ UNIFORM scale - a group is a frame, never a sprite size; sprite w/h
44
+ lives in the sprite node's scale, which is why sprites cannot parent
45
+ sprites). Child sprite pose fields are local to the group.
46
+ - Mutations batch to a microtask: style lease publish + count setDraw +
47
+ `spatial.flush()`. No mutation, no publish, no frame: a static layer
48
+ costs zero, the same demand-gate story as the rest of the platform.
49
+ - The records layer (`createRecordLayer`) keeps the old model whole: 13
50
+ JS-owned floats per sprite `[cx, cy, w, h, u0, v0, u1, v1, rot, tint
51
+ rgba]` (`FLOATS_PER_SPRITE`), draw order = insertion order, remove
52
+ shifts, `layer.records` + `touch()` raw writes, JS pick walk. It is the
53
+ escape hatch for motion only JS can compute at scale (measured 30k
54
+ sprites: 12.9ms raw records vs 30.8ms via setSprite) - the axis is
55
+ WHERE MOTION IS COMPUTED, not retained-vs-dynamic. The sprite functions
56
+ (addSprite/setSprite/getSprite/removeSprite) work on both layer kinds;
57
+ record sprites have `node: null` and no groups.
58
+ - Layer space is pixels, top-left origin, y-down - the render tree's frame.
59
+ The pipeline's clip space is y-down too (core gpu.ts pixel contract), so
60
+ the vertex stage carries NO flip anywhere. Do not add one.
61
+ - The camera (`setCamera`/the `camera` prop) is a shared-params write
62
+ (`uCamera`: offset + zoom), one call however many sprites exist. Picking
63
+ undoes it, so events arrive in world (layer) pixels.
64
+ - Retargeted motion is NATIVE: `setSpriteTransition(sprite, { position:
65
+ { duration: 700, bounce: 0.3 }, ... })` (or the `transition` prop) makes
66
+ setSprite writes TARGETS the core animates toward - position/scale
67
+ (w/h) on the shared spring/tween math, rotation along the quaternion
68
+ geodesic (always the short arc; a spring keeps its velocity through
69
+ retargets). JS costs one write per target CHANGE, zero per frame; the
70
+ running tracks drive frame demand themselves, and settled sprites cost
71
+ nothing (bench: 400 retargets ~4ms, once a second - vs ~5ms per FRAME
72
+ moving the same population imperatively). Mount poses always snap (the
73
+ component declares the transition after the first pose sync; the
74
+ function face sets it after addSprite). Each natural settle calls the
75
+ handle's `onTransitionEnd` (plain field, or the `<Sprite>`/`<Group>` prop)
76
+ with `{ component }` - target-only, never on a cancel, snap or removal;
77
+ the raw "spatialTransitionEnd" engine event (srt:events, node =
78
+ sprite.node) stays for flux:spatial consumers. See examples/springs.tsx.
79
+ - Frame-rate motion only JS can compute (physics, flocking) bypasses the
80
+ declarative layer: `ref` the sprite, call `setSprite` from `onFrame` (a
81
+ ~7us core transform write per moved sprite - fine to a few thousand;
82
+ past that use the records layer). Signals carry structure and slow
83
+ state - a `<Sprite x={sig()}>` re-running 60 times a second works but
84
+ re-runs an effect per sprite per frame for nothing.
85
+ - frames.ts and pick.ts are pure (no GPU imports) BY DESIGN so they can be
86
+ checked headless; keep them that way.
87
+
88
+ ## The baked tile layer (tiles.ts)
89
+
90
+ Static 2D bulk as a few quads: on tiled GPUs the budget is primitive count
91
+ (core agents/performance.md), so a 100x100 tile world must not be 10,000
92
+ quads per frame. `createTileLayer(cols, rows, tileW, tileH, atlas)` bakes
93
+ the world into CHUNKED `render: "manual"` targets (default ~512px of tiles
94
+ per chunk, `chunkTiles` to tune), each chunk a small copy of the sprite
95
+ pipeline (shaders.ts) with FIXED record slots - an empty tile is a
96
+ zero-size quad, instance count is constant per chunk. Records hold WORLD
97
+ pixel coordinates; each chunk target's `uCamera` is its pixel origin, so
98
+ the shared vertex stage does the chunk-local mapping. Chunks allocate on
99
+ the first `setTile` that reaches them - an empty chunk costs nothing, a
100
+ sparse world is bounded by its content, and world size is bounded by
101
+ memory, not `maxTextureSize`. `setTile` batches to a microtask whose flush
102
+ publishes and re-bakes ONLY dirty chunks. After that the layer is static
103
+ textures: zero per-frame cost however many tiles exist.
104
+
105
+ Scrolling never re-bakes: the `<TileLayer>` camera prop (`TileCamera`) is
106
+ a transform on the composited world view - the world point (x, y) pinned
107
+ to the viewport point (pivotX, pivotY), scaled by zoom, ROTATED by
108
+ rotation about the pivot. Pivot (0,0) makes `{x, y, zoom}` mean what the
109
+ sprite layer's camera means, so one signal drives both; rotation is the
110
+ ship-flies-over-the-map camera and costs the same transform write. The
111
+ grid shape is creation-fixed (recreate to resize). Tiles are data, not
112
+ children: there is no `<Tile>` component on purpose - write cells through
113
+ `ref` with `setTile`. Not built yet: camera-driven residency (bake far
114
+ chunks on approach, evict) - okf/backlog/2d-baked-layers.md.
115
+
116
+ ## Components
117
+
118
+ | Component | Props |
119
+ |---|---|
120
+ | `SpriteLayer` | width, height (layer pixels), atlas (TextureId), capacity?, clearColor?, camera?, label?, ref?, output?, events? |
121
+ | `Sprite` | x, y (center; local to the enclosing `<Group>`), w, h, frame?, rotation? (radians, clockwise), tint? ([r,g,b,a] 0..1), transition?, onPointer{Down,Move,Up,Enter,Leave}?, ref? |
122
+ | `Group` | x?, y?, rotation?, scale? (uniform, scales the subtree), transition?, ref? |
123
+ | `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), clearColor?, filter?, chunkTiles?, camera? (TileCamera: x, y, zoom, rotation, pivotX, pivotY), label?, ref? |
124
+
125
+ `SpriteLayer` owns the layer and renders the built-in `<texture>` leaf
126
+ carrying the layer's pointer handlers (opt out with `events={false}`; compose
127
+ yourself with `output`, then spread `useSpriteLayer().handlers` onto your
128
+ leaf). `Sprite` renders nothing - it allocates a record through context and
129
+ syncs props into it.
130
+ `GroupContext` is `createContext<SpriteGroup | null>(null)` on purpose: an
131
+ optional parent needs a non-undefined default, since Solid 2 throws on a
132
+ resolved `undefined` even when one was passed as the default.
133
+
134
+ Pointer events: exact rotated-rect containment, topmost sprite first, capture
135
+ per pointerId (a drag keeps delivering to the grabbed sprite with live
136
+ coordinates), enter/leave paired per pointer. No bubbling - the sprite list
137
+ is flat. Event x/y are layer pixels with the camera undone.
138
+
139
+ ## Traps
140
+
141
+ - The atlas is NOT owned by the layer: layers come and go, atlases usually
142
+ live app-long. Dispose atlases yourself (or let the reactive owner do it -
143
+ createAtlas registers with the owning scope like every core texture).
144
+ - `capacity` is a reservation, not a limit, on both layer kinds; reserve
145
+ realistically to skip the growth copies. On the records layer, do not
146
+ cache `layer.records` across addSprite - growth replaces the array.
147
+ - Node layer: `sprite.node` is public FOR BINDING PRODUCERS, not for
148
+ lifecycle - never destroyNode it yourself (removeSprite owns that), and
149
+ a transform written through flux:spatial directly bypasses the sprite's
150
+ pose mirror, so a later setSprite with the old x wins (its compare sees
151
+ no change to skip, but partial writes compose from the mirror).
152
+ - With a transition set, the sprite's fields (and getSprite) read the
153
+ TARGET, not the mid-flight pose - the JS mirror is what setSprite
154
+ composes partial writes from, and targets are the right thing to
155
+ compose. Picking and the pose buffer see the actual mid-flight pose
156
+ (what is on screen). Clearing the transition (null) keeps the
157
+ mid-flight pose on the node while the mirror still holds the old
158
+ target: the next setSprite write snaps to whatever it says.
159
+ - Node layer picking reads the index as of the last core flush; `pick`/
160
+ `pickRect` run the layer's pending batch first, so write-then-pick in
161
+ one tick is coherent. Producers moving nodes between flushes are one
162
+ frame stale to picking, like every query.
163
+ - Records layer: record order is draw order: `removeSprite` shifts every
164
+ later sprite down one slot (copyWithin + index fixup, O(later
165
+ sprites)). Its flush publishes the WHOLE live prefix, not a dirty
166
+ range: one moved sprite re-publishes count x 52 bytes - a single
167
+ memcpy, microseconds at 10k; the node layer's style publish is the same
168
+ whole-prefix shape. Dirty ranges were deliberately not built until a
169
+ measurement asks.
170
+ - The node layer's STYLE slots are not compacted: a removed sprite leaves
171
+ its style floats in place (invisible - the pose is zeroed) until the
172
+ slot recycles. Do not read style truth from the buffer; getSprite reads
173
+ the JS mirror.
174
+ - `createImage` is the wrong loader for pixel-art atlases: it never forwards
175
+ sampler options, so it is always `filter: "linear"`. `createAtlas` decodes
176
+ bytes and passes `filter: "nearest"` through - use it, or `decodeImage` +
177
+ `createTexture` directly.
178
+ - Tint multiplies the sampled texel (`texture * tint`) and the pipeline
179
+ blends with `blend: "alpha"` in record order. The layer's OUTPUT obeys the
180
+ premultiplied-alpha contract to the extent the atlas does: PNG decode
181
+ yields straight alpha, and a translucent texel tinted translucent can
182
+ composite slightly wrong at the edges. Opaque-or-transparent pixel art
183
+ (the overwhelming case) is exact. A premultiply-on-decode option is the
184
+ fix if it ever matters; note it, do not silently add it.
185
+ - `pointInSprite` in pick.ts and the vertex stage's rotation must agree on
186
+ direction (clockwise, y-down). The differential check (pick-check.ts)
187
+ guards the math against an oracle but NOT against the shader - if you
188
+ touch one rotation, touch both.
189
+ - Sprite handles go inert on removal (`sprite.layer === null`); setSprite on
190
+ an inert handle is a silent no-op (matching the throw-in-dev policy would
191
+ mean throwing, but removal racing a queued pointer event is routine, not
192
+ a bug).
193
+ - The `<Sprite>` effect syncs ALL seven fields when ANY prop changes (one
194
+ effect, one tuple). Fine at component scale; if a profile ever blames it,
195
+ split the effects before inventing anything cleverer.
196
+ - `<TileLayer>`'s world view is WORLD sized (cols * tileW) and takes that
197
+ much layout space: put it inside a clipping container (`overflow="clip"`)
198
+ sized to the viewport, or camera panning shows the world hanging out of
199
+ the box.
200
+ - `clearColor` is PER CHUNK: never-written regions have no chunk and render
201
+ nothing, so a full-bleed ground color belongs on the container behind the
202
+ layer (a `d-rect` under it), not on clearColor.
203
+ - Tile layer zoom/rotation scale the BAKED chunk textures at composite time
204
+ (the sprite layer's zoom re-samples the atlas in-shader), so the tile
205
+ layer's `filter` option is what pixel art must set to "nearest" - on top
206
+ of the atlas's own nearest from createAtlas; they are different samplers.
207
+ - A dirty chunk flush publishes and re-bakes that chunk in full. Fine on
208
+ change-only cadence; per-frame setTile churn re-bakes chunks per frame -
209
+ that is sprite-layer work, not tile work.
210
+ - Chunk allocation is MONOTONIC: nothing evicts, so texture memory is
211
+ proportional to the touched area (~920KB per resident chunk at the
212
+ default size) and every resident chunk keeps a composited leaf. Bounded
213
+ worlds only; streaming/infinite is stage B2 in
214
+ okf/backlog/2d-baked-layers.md.
215
+ - The sprite layer's camera cannot rotate (uCamera is offset + zoom); a
216
+ sprite layer riding a rotating TileCamera needs
217
+ okf/backlog/2d-sprite-camera-rotation.md first. Rotating the sprite
218
+ layer's OUTPUT leaf instead is wrong - it is viewport-sized, the corners
219
+ cut.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @solidrt/2d
2
+
3
+ An instanced sprite layer for SolidRT: one atlas, one GPU buffer, thousands
4
+ of sprites in a single draw call, composited into your app as an ordinary
5
+ texture element.
6
+
7
+ *Status: experimental. Expect API churn.*
8
+
9
+ ```tsx
10
+ import { render } from "@solidrt/core"
11
+ import { createAtlas, grid, Sprite, SpriteLayer } from "@solidrt/2d"
12
+ import sheet from "./sheet.png" with { type: "binary" }
13
+
14
+ let atlas = createAtlas(sheet, { filter: "nearest" })
15
+ let frames = grid(4, 4, { width: atlas.width, height: atlas.height })
16
+
17
+ render(() => (
18
+ <window>
19
+ <SpriteLayer width={720} height={480} atlas={atlas.texture}>
20
+ <Sprite x={100} y={120} w={32} h={32} frame={frames[0]} />
21
+ <Sprite x={200} y={160} w={32} h={32} frame={frames[5]} rotation={0.4} />
22
+ </SpriteLayer>
23
+ </window>
24
+ ))
25
+ ```
26
+
27
+ The rendertree already handles 2D well: a `d-texture` with atlas sub-rects is
28
+ a sprite, with native transitions, gesture-arena pointer events, and
29
+ per-element inspectability, and it carries populations into the low
30
+ thousands. This package is for what lies beyond that: dense, per-frame
31
+ animated populations - entities, particles, bullets - where per-sprite
32
+ property writes through the JS boundary are the bottleneck. Sprite records
33
+ live in one `Float32Array` and publish to the GPU through SolidRT's zero-copy
34
+ buffer write lease; moving ten thousand sprites is ten thousand float stores
35
+ and one bulk publish, not twenty thousand FFI calls.
36
+
37
+ Two layers, like `@solidrt/3d`: an imperative core
38
+ (`createSpriteLayer` / `addSprite` / `setSprite` - per-frame motion calls
39
+ these from `onFrame`) and the component face (`SpriteLayer` / `Sprite`) for
40
+ structure and slow state. A static layer publishes nothing and costs nothing;
41
+ the paint-order story is the painter's algorithm (insertion order); pointer
42
+ events hit-test exact rotated rects, topmost first, with pointer capture.
43
+
44
+ v1 scope: atlas creation and slicing (`createAtlas`, `grid`, `namedFrames`),
45
+ the sprite layer with camera pan/zoom, sprite pointer events, and the
46
+ component face. Staged next: baked/tilemap layers (static worlds as one
47
+ quad), z-ordering, frame animation helpers, and the retro presets (pixel
48
+ canvas, palette and scanline passes) - see `okf/backlog/`.
49
+
50
+ The full model and the sharp edges are in [AGENTS.md](AGENTS.md); runnable
51
+ patterns in [examples/](examples/).
@@ -0,0 +1,21 @@
1
+ # @solidrt/2d examples
2
+
3
+ Single-concept sprite-layer patterns. Each file is a complete, runnable app
4
+ (`bunx srt run <file>` from the repo root) demonstrating exactly one thing -
5
+ copy one and adapt it.
6
+
7
+ - `sprites.tsx` - the layer at its natural scale: 500 sprites bouncing at
8
+ frame rate, moved imperatively with `setSprite` from `onFrame` while the
9
+ tree holds one texture leaf. Atlas from PNG bytes (`createAtlas`) sliced
10
+ 2x2 with `grid()`.
11
+ - `tiles.tsx` - the baked tile layer: a 128x128 world (6144px - bigger than
12
+ one texture may be) baked into lazily-allocated chunks, flown over by a
13
+ ship-style camera (fixed screen pivot, the world panning and ROTATING
14
+ under it via the `<TileLayer>` camera prop - transform writes, never a
15
+ re-bake), and a timer editing tiles to show that a `setTile` batch
16
+ re-bakes only the chunks it touches.
17
+ - `pick.tsx` - sprite pointer events through the component face: exact
18
+ rotated-rect hit testing topmost-first, pointer capture (drag a sprite and
19
+ the events keep naming it), click-vs-drag slop, and removal through a
20
+ signal so `<For>` unmounts the `<Sprite>` and the layer compacts its draw
21
+ order.
Binary file
@@ -0,0 +1,204 @@
1
+ // Node-vs-records parity probe: the same deterministic sprite population
2
+ // rendered through the node-backed live layer (spatial arena nodes, pose
3
+ // buffer core-written) and the records layer (13 JS-owned floats), side by
4
+ // side. Self-asserting: after the first frames it readTexture-compares the
5
+ // two outputs pixel by pixel (the pose round-trips through the core's
6
+ // Pose2D decomposition, so only alpha-edge pixels may flip), cross-checks
7
+ // picking at random points, exercises pickRect, groups and slot recycling,
8
+ // and benches addSprite plus a move-everything frame on both layers. Watch
9
+ // the logs for PARITY/BENCH lines ending in PARITY-OK.
10
+ import { onFrame, render } from "@solidrt/core"
11
+ import { readTexture } from "@solidrt/core/gpu"
12
+ import {
13
+ addGroup,
14
+ addSprite,
15
+ createAtlas,
16
+ createRecordLayer,
17
+ createSpriteLayer,
18
+ getSprite,
19
+ grid,
20
+ removeSprite,
21
+ setGroup,
22
+ setSprite,
23
+ } from "@solidrt/2d"
24
+ import type { SpriteHandle } from "@solidrt/2d"
25
+ import logoBytes from "./logo.png" with { type: "binary" }
26
+
27
+ const N = 200
28
+ const W = 360
29
+ const H = 360
30
+ const SEED = 0x2d2d
31
+
32
+ // Deterministic PRNG (mulberry32) so both layers see identical sprites.
33
+ function rng(seed: number): () => number {
34
+ let a = seed >>> 0
35
+ return () => {
36
+ a = (a + 0x6d2b79f5) >>> 0
37
+ let t = a
38
+ t = Math.imul(t ^ (t >>> 15), t | 1)
39
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
40
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
41
+ }
42
+ }
43
+
44
+ function App() {
45
+ let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
46
+ let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
47
+ let nodes = createSpriteLayer(W, H, atlas.texture, { capacity: 64, clearColor: [0.05, 0.05, 0.09, 1], label: "nodes" })
48
+ let records = createRecordLayer(W, H, atlas.texture, { capacity: 64, clearColor: [0.05, 0.05, 0.09, 1], label: "records" })
49
+
50
+ let fields = (r: () => number) => ({
51
+ x: 20 + r() * (W - 40),
52
+ y: 20 + r() * (H - 40),
53
+ w: 16 + r() * 48,
54
+ h: 16 + r() * 48,
55
+ rotation: r() * Math.PI * 2,
56
+ frame: frames[Math.floor(r() * 4) % 4]!,
57
+ tint: [0.5 + r() * 0.5, 0.5 + r() * 0.5, 0.5 + r() * 0.5, 1] as [number, number, number, number],
58
+ })
59
+
60
+ // Population (the capacity of 64 forces both growth paths, including the
61
+ // pose-buffer retarget).
62
+ let nodeSprites: SpriteHandle[] = []
63
+ let recSprites: SpriteHandle[] = []
64
+ let r1 = rng(SEED)
65
+ let t0 = performance.now()
66
+ for (let i = 0; i < N; i++) nodeSprites.push(addSprite(nodes, fields(r1)))
67
+ let tNodesAdd = performance.now() - t0
68
+ let r2 = rng(SEED)
69
+ t0 = performance.now()
70
+ for (let i = 0; i < N; i++) recSprites.push(addSprite(records, fields(r2)))
71
+ let tRecsAdd = performance.now() - t0
72
+ console.log(`BENCH addSprite x${N}: nodes ${tNodesAdd.toFixed(2)}ms, records ${tRecsAdd.toFixed(2)}ms`)
73
+
74
+ let failures: string[] = []
75
+ let check = (ok: boolean, what: string) => {
76
+ if (!ok) failures.push(what)
77
+ console.log(`PARITY ${ok ? "ok" : "FAIL"}: ${what}`)
78
+ }
79
+
80
+ // Slot recycling: remove one, add another, the layer stays coherent.
81
+ {
82
+ let extra = addSprite(nodes, { x: 10, y: 10, w: 8, h: 8 })
83
+ let slotBefore = extra._slot
84
+ removeSprite(extra)
85
+ let reused = addSprite(nodes, { x: -100, y: -100, w: 0, h: 0 })
86
+ check(reused._slot === slotBefore, "removed slot recycles to the next add")
87
+ check(getSprite(extra) === null, "removed handle is inert")
88
+ removeSprite(reused)
89
+ }
90
+
91
+ let pixelParity = () => {
92
+ let a = readTexture(nodes.texture)
93
+ let b = readTexture(records.texture)
94
+ if (a.width !== b.width || a.height !== b.height) {
95
+ check(false, `sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`)
96
+ return
97
+ }
98
+ let off = 0
99
+ for (let i = 0; i < a.data.length; i += 4) {
100
+ if (
101
+ Math.abs(a.data[i]! - b.data[i]!) > 8 ||
102
+ Math.abs(a.data[i + 1]! - b.data[i + 1]!) > 8 ||
103
+ Math.abs(a.data[i + 2]! - b.data[i + 2]!) > 8 ||
104
+ Math.abs(a.data[i + 3]! - b.data[i + 3]!) > 8
105
+ ) {
106
+ off++
107
+ }
108
+ }
109
+ let total = a.data.length / 4
110
+ let pct = (off / total) * 100
111
+ check(pct < 0.5, `pixels within tolerance (${off}/${total} off, ${pct.toFixed(3)}%)`)
112
+ }
113
+
114
+ let pickParity = () => {
115
+ let r = rng(SEED ^ 0xffff)
116
+ let byNode = new Map(nodeSprites.map((s, i) => [s, i]))
117
+ let byRec = new Map(recSprites.map((s, i) => [s, i]))
118
+ let mismatches = 0
119
+ for (let i = 0; i < 200; i++) {
120
+ let x = r() * W
121
+ let y = r() * H
122
+ let a = nodes.pick(x, y)
123
+ let b = records.pick(x, y)
124
+ let ai = a ? byNode.get(a) : null
125
+ let bi = b ? byRec.get(b) : null
126
+ if (ai !== bi) mismatches++
127
+ }
128
+ check(mismatches <= 1, `pick agrees at random points (${mismatches} mismatches)`)
129
+ }
130
+
131
+ let rectQuery = () => {
132
+ // Left half of the layer: every hit must at least touch it by AABB,
133
+ // every sprite fully inside it must be reported.
134
+ let hits = new Set(nodes.pickRect(0, 0, W / 2, H))
135
+ let missed = 0
136
+ let phantom = 0
137
+ for (let s of nodeSprites) {
138
+ let f = getSprite(s)!
139
+ let radius = (Math.hypot(f.w, f.h) / 2) * 1.001
140
+ let inside = f.x + radius <= W / 2 && f.x - radius >= 0 && f.y - radius >= 0 && f.y + radius <= H
141
+ let touches = f.x - radius <= W / 2
142
+ if (inside && !hits.has(s)) missed++
143
+ if (!touches && hits.has(s)) phantom++
144
+ }
145
+ check(missed === 0 && phantom === 0, `pickRect covers the left half (${hits.size} hits, ${missed} missed, ${phantom} phantom)`)
146
+ }
147
+
148
+ let groups = () => {
149
+ // A group at (100, 100) rotated 90deg clockwise: a child sprite at
150
+ // local (50, 0) lands at world (100, 150) in y-down space.
151
+ let g = addGroup(nodes, { x: 100, y: 100 })
152
+ let child = addSprite(nodes, { parent: g, x: 50, y: 0, w: 10, h: 10 })
153
+ setGroup(g, { rotation: Math.PI / 2 })
154
+ queueMicrotask(() => {
155
+ check(nodes.pick(100, 150) === child, "group rotation carries the child sprite")
156
+ check(nodes.pick(150, 100) !== child, "the ungrouped position no longer hits")
157
+ let f = getSprite(child)!
158
+ check(f.x === 50 && f.y === 0, "getSprite reads the local pose")
159
+ removeSprite(child)
160
+ // The group node stays for the layer to dispose.
161
+ })
162
+ }
163
+
164
+ let bench = () => {
165
+ let r = rng(SEED ^ 0xabcd)
166
+ let t = performance.now()
167
+ for (let i = 0; i < N; i++) setSprite(nodeSprites[i]!, { x: 20 + r() * (W - 40), y: 20 + r() * (H - 40) })
168
+ let tNodes = performance.now() - t
169
+ r = rng(SEED ^ 0xabcd)
170
+ t = performance.now()
171
+ for (let i = 0; i < N; i++) setSprite(recSprites[i]!, { x: 20 + r() * (W - 40), y: 20 + r() * (H - 40) })
172
+ let tRecs = performance.now() - t
173
+ console.log(`BENCH move x${N}: nodes ${tNodes.toFixed(2)}ms, records ${tRecs.toFixed(2)}ms`)
174
+ // Put both populations back so the parity compare still holds.
175
+ let ra = rng(SEED)
176
+ for (let i = 0; i < N; i++) setSprite(nodeSprites[i]!, fields(ra))
177
+ let rb = rng(SEED)
178
+ for (let i = 0; i < N; i++) setSprite(recSprites[i]!, fields(rb))
179
+ }
180
+
181
+ let frame = 0
182
+ onFrame(() => {
183
+ frame++
184
+ if (frame === 3) {
185
+ bench()
186
+ groups()
187
+ }
188
+ if (frame === 6) {
189
+ pixelParity()
190
+ pickParity()
191
+ rectQuery()
192
+ console.log(failures.length === 0 ? "PARITY-OK" : `PARITY-FAIL: ${failures.join("; ")}`)
193
+ }
194
+ })
195
+
196
+ return (
197
+ <window flexDirection="row" alignItems="center" justifyContent="center" gap={8}>
198
+ <texture src={nodes.texture} width={W} height={H} />
199
+ <texture src={records.texture} width={W} height={H} />
200
+ </window>
201
+ )
202
+ }
203
+
204
+ render(() => <App />)
@@ -0,0 +1,90 @@
1
+ // Sprite pointer events through the component face: the built-in leaf
2
+ // carries the layer's handlers, so <Sprite onPointer*> props receive events
3
+ // with LAYER-pixel coordinates and exact rotated-rect hit testing, topmost
4
+ // sprite first. Drag a sprite to move it - the layer captures the pointer on
5
+ // down, so the drag keeps delivering to the grabbed sprite even when the
6
+ // pointer outruns it. A click (press and release without moving) cycles the
7
+ // tint. Shift-click removes the sprite, exercising slot recycling (the
8
+ // freed pose slot zeroes and waits for the next add); structure lives in a
9
+ // signal, so <For> unmounts the removed <Sprite>.
10
+ import { createSignal, render, For } from "@solidrt/core"
11
+ import { createAtlas, grid, setSprite, Sprite, SpriteLayer } from "@solidrt/2d"
12
+ import type { Frame, SpriteHandle } from "@solidrt/2d"
13
+ import logoBytes from "./logo.png" with { type: "binary" }
14
+
15
+ const W = 720
16
+ const H = 720
17
+
18
+ const TINTS: [number, number, number, number][] = [
19
+ [1, 1, 1, 1],
20
+ [1, 0.5, 0.5, 1],
21
+ [0.5, 1, 0.6, 1],
22
+ [0.55, 0.7, 1, 1],
23
+ ]
24
+
25
+ type Item = { id: number; x: number; y: number; frame: Frame }
26
+
27
+ function App() {
28
+ let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
29
+ let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
30
+ let [items, setItems] = createSignal<Item[]>([
31
+ { id: 0, x: 200, y: 240, frame: frames[0]! },
32
+ { id: 1, x: 420, y: 300, frame: frames[1]! },
33
+ { id: 2, x: 300, y: 470, frame: frames[2]! },
34
+ { id: 3, x: 520, y: 500, frame: frames[3]! },
35
+ ])
36
+
37
+ return (
38
+ <window alignItems="center" justifyContent="center">
39
+ <SpriteLayer width={W} height={H} atlas={atlas.texture} capacity={64} clearColor={[0.05, 0.05, 0.09, 1]}>
40
+ <For each={items()}>
41
+ {item => {
42
+ let tintIndex = 0
43
+ let down = false
44
+ let moved = false
45
+ let downX = 0
46
+ let downY = 0
47
+ let handle: SpriteHandle | undefined
48
+ return (
49
+ <Sprite
50
+ ref={s => (handle = s)}
51
+ x={item.x}
52
+ y={item.y}
53
+ w={96}
54
+ h={96}
55
+ frame={item.frame}
56
+ onPointerDown={e => {
57
+ down = true
58
+ moved = false
59
+ downX = e.x
60
+ downY = e.y
61
+ }}
62
+ onPointerMove={e => {
63
+ // onPointerMove also fires on plain hover; only a captured
64
+ // move (button held since the down) drags. A few pixels of
65
+ // slop keep an ordinary click from registering as a drag.
66
+ if (!down) return
67
+ if (!moved && Math.hypot(e.x - downX, e.y - downY) < 4) return
68
+ moved = true
69
+ if (handle) setSprite(handle, { x: e.x, y: e.y })
70
+ }}
71
+ onPointerUp={e => {
72
+ down = false
73
+ if (moved) return
74
+ if (e.shiftKey) {
75
+ setItems(items().filter(other => other.id !== item.id))
76
+ return
77
+ }
78
+ tintIndex = (tintIndex + 1) % TINTS.length
79
+ if (handle) setSprite(handle, { tint: TINTS[tintIndex] })
80
+ }}
81
+ />
82
+ )
83
+ }}
84
+ </For>
85
+ </SpriteLayer>
86
+ </window>
87
+ )
88
+ }
89
+
90
+ render(() => <App />)
@@ -0,0 +1,69 @@
1
+ // Native node transitions at the sprite layer's scale: every sprite
2
+ // declares a spring, and the app writes TARGETS - one setSprite per sprite
3
+ // per shuffle (about once a second), ZERO JS per frame. The core
4
+ // integrates all the springs each frame and publishes one coalesced
5
+ // pose-buffer write; between shuffles the app runs no code at all (there
6
+ // is no onFrame here - the running tracks drive frame demand themselves,
7
+ // and once everything settles the layer costs nothing). Compare
8
+ // sprites.tsx, which moves the same population imperatively every frame:
9
+ // here the JS cost is proportional to target CHANGES, not frames. The
10
+ // console logs each shuffle's burst cost.
11
+ //
12
+ // Rotation targets ride the quaternion geodesic, so a spin to a new
13
+ // random angle always takes the short arc, and the position spring keeps
14
+ // its velocity when a shuffle lands mid-flight - retarget as fast as you
15
+ // like, the motion stays continuous.
16
+ import { render } from "@solidrt/core"
17
+ import { addSprite, createAtlas, createSpriteLayer, grid, setSprite, setSpriteTransition } from "@solidrt/2d"
18
+ import logoBytes from "./logo.png" with { type: "binary" }
19
+
20
+ const COLS = 20
21
+ const ROWS = 20
22
+ const COUNT = COLS * ROWS
23
+ const W = 720
24
+ const H = 720
25
+ const SPRITE = 30
26
+
27
+ function App() {
28
+ let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
29
+ let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
30
+ let layer = createSpriteLayer(W, H, atlas.texture, {
31
+ capacity: COUNT,
32
+ clearColor: [0.05, 0.05, 0.09, 1],
33
+ label: "springs",
34
+ })
35
+
36
+ let slotX = (slot: number) => ((slot % COLS) + 0.5) * (W / COLS)
37
+ let slotY = (slot: number) => (Math.floor(slot / COLS) + 0.5) * (H / ROWS)
38
+
39
+ // Sprite k sits at grid slot slots[k]; each shuffle re-deals the slots.
40
+ let slots = Array.from({ length: COUNT }, (_, i) => i)
41
+ let sprites = slots.map(slot =>
42
+ addSprite(layer, { x: slotX(slot), y: slotY(slot), w: SPRITE, h: SPRITE, frame: frames[slot % 4] }),
43
+ )
44
+ for (let sprite of sprites) {
45
+ setSpriteTransition(sprite, { position: { duration: 700, bounce: 0.3 }, rotation: { duration: 700 } })
46
+ }
47
+
48
+ let shuffle = () => {
49
+ for (let i = slots.length - 1; i > 0; i--) {
50
+ let j = Math.floor(Math.random() * (i + 1))
51
+ ;[slots[i], slots[j]] = [slots[j]!, slots[i]!]
52
+ }
53
+ let start = performance.now()
54
+ for (let k = 0; k < COUNT; k++) {
55
+ setSprite(sprites[k]!, { x: slotX(slots[k]!), y: slotY(slots[k]!), rotation: Math.random() * Math.PI * 2 })
56
+ }
57
+ let ms = performance.now() - start
58
+ console.log(`retarget x${COUNT}: ${ms.toFixed(2)} ms; JS idles until the next shuffle`)
59
+ }
60
+ setInterval(shuffle, 1200)
61
+
62
+ return (
63
+ <window alignItems="center" justifyContent="center">
64
+ <texture src={layer.texture} width={W} height={H} />
65
+ </window>
66
+ )
67
+ }
68
+
69
+ render(() => <App />)