@solidrt/2d 0.0.55 → 0.0.56

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 CHANGED
@@ -18,31 +18,46 @@ moved subtrees in Rust, and picking walks the core BVH.
18
18
  `setGroupTransition` - plain objects, no signals, usable without
19
19
  components), the records layer (records.ts: `createRecordLayer` - the
20
20
  raw escape hatch, below), and the component face (components.tsx:
21
- `SpriteLayer`/`Sprite`/`Group` over context).
21
+ `SpriteLayer`/`Sprite`/`Group`/`Camera2d` over context).
22
22
  - Node layer ownership split, two instance-buffer slots on one pipeline:
23
23
  slot 0 is the POSE buffer `[x, y, angle, sx, sy]` written ONLY by the
24
24
  core (each sprite node's Pose2D record sink; one coalesced buffer write
25
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.
26
+ `[u0, v0, u1, v1, tint rgba, renderOrder]`, JS-owned, published through the
27
+ zero-copy write lease. NEVER write the pose buffer from JS - the core's
28
+ staging mirror owns it and will overwrite.
29
29
  - Sprites hold FIXED instance slots: draw order is slot order, removal
30
30
  zeroes the pose (zero scale = nothing drawn) and recycles the slot to
31
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).
32
+ opaque-or-transparent pixel art never notices, and a scene that needs a
33
+ real draw order says so with `orderBy` ("y" or "renderOrder" - see below).
34
34
  - Growth (past `capacity`, doubling): pose sinks move in ONE core
35
35
  `retargetRecords` call (full republish next flush), style re-uploads,
36
36
  `setDraw({ instanceBuffers })` swaps both, old buffers destroyed.
37
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.
38
+ rotated-rect via the node's local box) and returns ALL hits topmost
39
+ first (highest slot) - the all-hits shape of @solidrt/3d's pick, so
40
+ `pick(x, y)[0]` is the topmost; `pickRect` is the BVH overlap query
41
+ (exact for rotated sprites, the marquee), unordered. Both filter to the
42
+ layer's own nodes - the arena is shared with e.g. a 3d scene.
42
43
  - Groups (`addGroup`/`<Group>`) are plain arena nodes (x, y, rotation,
43
44
  UNIFORM scale - a group is a frame, never a sprite size; sprite w/h
44
45
  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
+ sprites). Child sprite pose fields are local to the group. `removeGroup`
47
+ removes the SUBTREE: child sprites and groups die with it (remove means
48
+ destroy here, as for removeSprite; re-parent a child out first to keep
49
+ it). @solidrt/3d's `remove` instead DETACHES a re-addable subtree - its
50
+ nodes exist outside a scene, sprites cannot exist outside their layer.
51
+ A handle's x/y are local to its group; `worldPosition(sprite | group)`
52
+ reads the layer-pixel position composed through every enclosing group
53
+ from the core's world matrix (@solidrt/3d's worldPosition).
54
+ - `visible` (@solidrt/3d's setVisible, in the setSprite/setGroup bag;
55
+ default true) is core node visibility: hidden, the pose slot zeroes
56
+ (nothing drawn) and pick/pickRect skip the node; a group's flag hides
57
+ its WHOLE subtree while each child keeps its own. Handle, slot, style
58
+ records and transition state stay, so showing restores the sprite as
59
+ it was. Node layer only - on a record sprite it throws (hide those by
60
+ zeroing w or h).
46
61
  - `flipX`/`flipY` mirror on the UV SIDE: the style/record write swaps
47
62
  u0/u1 (v0/v1), so w/h stay the drawn size, a scale transition never sees
48
63
  a flip, and picking is unchanged (the vertex stage still carries no
@@ -54,19 +69,82 @@ moved subtrees in Rust, and picking walks the core BVH.
54
69
  costs zero, the same demand-gate story as the rest of the platform.
55
70
  - The records layer (`createRecordLayer`) keeps the old model whole: 13
56
71
  JS-owned floats per sprite `[cx, cy, w, h, u0, v0, u1, v1, rot, tint
57
- rgba]` (`FLOATS_PER_SPRITE`), draw order = insertion order, remove
72
+ rgba]` (`FLOATS_PER_SPRITE`), draw order = insertion order (or key
73
+ order with `orderBy` - see below), remove
58
74
  shifts, `layer.records` + `touch()` raw writes, JS pick walk. It is the
59
75
  escape hatch for motion only JS can compute at scale (measured 30k
60
- sprites: 12.9ms raw records vs 30.8ms via setSprite) - the axis is
76
+ sprites: 12.9ms raw records vs 30.8ms via setSprite; both figures are
77
+ the WRITE path only - whatever computes the motion is excluded and is
78
+ usually the dominant cost, e.g. a 24k-particle sim measured ~25ms with
79
+ a near-free publish) - the axis is
61
80
  WHERE MOTION IS COMPUTED, not retained-vs-dynamic. The sprite functions
62
81
  (addSprite/setSprite/getSprite/removeSprite) work on both layer kinds;
63
82
  record sprites have `node: null` and no groups.
64
83
  - Layer space is pixels, top-left origin, y-down - the render tree's frame.
65
84
  The pipeline's clip space is y-down too (core gpu.ts pixel contract), so
66
85
  the vertex stage carries NO flip anywhere. Do not add one.
67
- - The camera (`setCamera`/the `camera` prop) is a shared-params write
68
- (`uCamera`: offset + zoom), one call however many sprites exist. Picking
69
- undoes it, so events arrive in world (layer) pixels.
86
+ - The camera (`setCamera`/the `camera` prop) is ONE `CameraUpdate` type
87
+ across both layers: offset, zoom, and rotation about a pivot (camera.ts
88
+ documents the semantics and the heading-upward convention,
89
+ `rotation = -h - pi/2`). On the sprite layer it is a shared-params
90
+ write (`uCamera` + `uCameraRot`, the rotation in-shader), one call
91
+ however many sprites exist; `projectCamera`/`unprojectCamera` export
92
+ the world <-> screen mapping as pure functions, and pointer dispatch
93
+ undoes the camera with the latter, so events arrive in world (layer)
94
+ pixels. Picking itself works in world space and never sees the camera.
95
+ `layer.camera()` reads the camera back (every field present - the
96
+ `CameraState` snapshot, @solidrt/3d's scene.camera()) and
97
+ `layer.project(x, y)` / `layer.unproject(x, y)` are the two pure
98
+ functions over it.
99
+ - Camera control: `createCamera2d(layer | layers, { viewport: () =>
100
+ ({ width, height }), world?: { width, height }, min/maxZoom?, pivot?,
101
+ deadZone?: { width, height }, zoomSpeed?, followSpeed?, inertia?,
102
+ x?, y?, zoom?, rotation? })` - Godot's Camera2D and Three's
103
+ MapControls in one control over the shared CameraUpdate: drag to pan
104
+ with inertia on release, wheel and pinch zoom about the pointer, eased
105
+ `glideTo`/`fit`, `follow(x, y)` through a dead zone with damping,
106
+ rotation about the pivot, and world bounds that CONTAIN the view (an
107
+ axis whose view is wider than the world centers; limits ignore
108
+ rotation, Godot's rule). The pose is "world point at the pivot", the
109
+ pivot a viewport fraction defaulting to the center, so `camera().x/y`
110
+ is the view center and glideTo/follow land there (without a `world`,
111
+ the default pose puts world 0,0 at the pivot: a fill layer that wants
112
+ world = screen at rest takes `pivot: { x: 0, y: 0 }`). The first
113
+ argument is anything with the layers' `setCamera` (a sprite layer, a
114
+ record layer, both at once, or a signal setter feeding `<TileLayer
115
+ camera>`). Input runs on core's `createTransform` recognizer like the
116
+ 3d orbit camera (arena arbitration, slop swallowed, one delta per
117
+ FRAME - a synthetic drag under a frozen clock pans nothing until frames
118
+ step). Two ways in: `cam.attach(layer)` listens at the layer's root, so
119
+ the camera sees exactly the presses the sprites let through (a sprite
120
+ that stops its down keeps the camera out of that drag; a wheel
121
+ anywhere zooms) and pushes the pose synchronously on input; the raw
122
+ `cam.handlers` spread onto a leaf without a dispatch (a tile world on
123
+ its own). Call `cam.update(dt)` from a frame loop (it pushes one
124
+ setCamera per driven layer when the pose changed and reports that),
125
+ gated on the reactive `cam.active()` (true while a glide, fling, fit
126
+ or follow needs frames, false at rest), read `cam.camera()` for
127
+ projectCamera. The camera has NO tap of its own: taps are the
128
+ dispatch's (`onTap` on the root with `e.sprite` null is "tap on empty
129
+ space"). `<Camera2d>` inside `<SpriteLayer>` is all of that wired
130
+ through context, the 3d `<OrbitCamera>` shape: options read at mount,
131
+ `viewport` defaulting to the layer's size, frames only while
132
+ `active()`. Anchors and deltas arrive in the leaf's own frame (core's
133
+ recognizers measure in the node's frames), so a leaf under a
134
+ designSize fit pans and zooms correctly. The motion is
135
+ camera-motion.ts, pure; checks/camera2d-check.ts pins the clamp,
136
+ anchoring, glides, follow and inertia headless, examples/camera.tsx
137
+ (function face, attach) and examples/pick.tsx (`<Camera2d>`) are the
138
+ live guards. Not yet, all additive: two-finger twist rotation,
139
+ rotation glides, a contain origin other than center, live option
140
+ props on `<Camera2d>` (the motion reads them once; remount for new
141
+ bounds).
142
+ - Layer tint (`setTint(rgba)`/the `tint` option and prop, both layer
143
+ kinds): one `uTint` shared-params write multiplied over every sprite's
144
+ own tint - day/night, a dimmed parallax plane, a fade-in. Cheap to
145
+ animate (no record touches), unlike TileLayer's, which re-renders
146
+ resident chunks. The same contract as TileLayer.setTint, so one signal
147
+ drives a whole scene across layer kinds.
70
148
  - Retargeted motion is NATIVE: `setSpriteTransition(sprite, { position:
71
149
  { duration: 700, bounce: 0.3 }, ... })` (or the `transition` prop) makes
72
150
  setSprite writes TARGETS the core animates toward - position/scale
@@ -75,7 +153,11 @@ moved subtrees in Rust, and picking walks the core BVH.
75
153
  retargets). JS costs one write per target CHANGE, zero per frame; the
76
154
  running tracks drive frame demand themselves, and settled sprites cost
77
155
  nothing (bench: 400 retargets ~4ms, once a second - vs ~5ms per FRAME
78
- moving the same population imperatively). Mount poses always snap (the
156
+ moving the same population imperatively). Retargeting every frame is
157
+ also a legitimate pattern, not an abuse: rewriting a spring's TARGET
158
+ each frame to chase a moving point (a follow-camera trailing a moving
159
+ sprite) rides the spring's smoothing for free - a spring keeps its
160
+ velocity through retargets, so the chase stays fluid. Mount poses always snap (the
79
161
  component declares the transition after the first pose sync; the
80
162
  function face sets it after addSprite). Each natural settle calls the
81
163
  handle's `onTransitionEnd` (plain field, or the `<Sprite>`/`<Group>` prop)
@@ -88,8 +170,21 @@ moved subtrees in Rust, and picking walks the core BVH.
88
170
  past that use the records layer). Signals carry structure and slow
89
171
  state - a `<Sprite x={sig()}>` re-running 60 times a second works but
90
172
  re-runs an effect per sprite per frame for nothing.
91
- - frames.ts and pick.ts are pure (no GPU imports) BY DESIGN so they can be
92
- checked headless; keep them that way.
173
+ - Frame animation (animation.ts): `createAnimation(frames, fps, { loop })`
174
+ is a clip with a shared wall-clock timer stepping every attached sprite
175
+ (`anim.add(sprite)` / `remove`; `play`/`pause`; `loop: false` holds the
176
+ last frame and fires `onFinish`, the mixer's name for it in
177
+ @solidrt/3d). One timer per playing clip, one setSprite
178
+ per sprite per STEP - an 8fps cycle is 8 publishes a second regardless of
179
+ display rate, and a paused clip costs nothing. A sprite belongs to at
180
+ most one animation (add detaches the previous); removed sprites prune
181
+ lazily on the next step. Works on both layer kinds; with `<Sprite>`,
182
+ attach via `ref` and leave the `frame` prop off - the clip owns that
183
+ field (the prop effect passes absent props as undefined, which setSprite
184
+ keeps, so other props stay reactive).
185
+ - frames.ts, pick.ts, camera.ts, camera-motion.ts and dispatch.ts are pure
186
+ (no GPU imports) BY DESIGN so they can be checked headless; keep them
187
+ that way.
93
188
 
94
189
  ## The baked tile layer (tiles.ts)
95
190
 
@@ -108,39 +203,86 @@ memory, not `maxTextureSize`. `setTile` batches to a microtask whose flush
108
203
  publishes and re-bakes ONLY dirty chunks. After that the layer is static
109
204
  textures: zero per-frame cost however many tiles exist.
110
205
 
111
- Scrolling never re-bakes: the `<TileLayer>` camera prop (`TileCamera`) is
112
- a transform on the composited world view - the world point (x, y) pinned
113
- to the viewport point (pivotX, pivotY), scaled by zoom, ROTATED by
114
- rotation about the pivot. Pivot (0,0) makes `{x, y, zoom}` mean what the
115
- sprite layer's camera means, so one signal drives both; rotation is the
116
- ship-flies-over-the-map camera and costs the same transform write. The
117
- grid shape is creation-fixed (recreate to resize). Tiles are data, not
206
+ Scrolling never re-bakes: the `<TileLayer>` camera prop (`TileCamera`, an
207
+ alias of the shared `CameraUpdate`) is a transform on the composited world
208
+ view - the world point (x, y) pinned to the viewport point (pivotX,
209
+ pivotY), scaled by zoom, ROTATED by rotation about the pivot. The type IS
210
+ the sprite layer's camera type, so one signal drives a whole rotating
211
+ scene across both layers (sprites ride the same rotation in-shader);
212
+ rotation is the ship-flies-over-the-map camera and costs the same
213
+ transform write. A tile
214
+ world is FINITE, sized at creation - the contract, not a provisional
215
+ limit (recreate to resize; a huge sparse world just picks big numbers,
216
+ empty chunks cost nothing). Tiles are data, not
118
217
  children: there is no `<Tile>` component on purpose - write cells through
119
- `ref` with `setTile`. Not built yet: camera-driven residency (bake far
120
- chunks on approach, evict) - okf/backlog/2d-baked-layers.md.
218
+ `ref` with `setTile`.
219
+
220
+ Tinting, two levels: `setTile(col, row, frame, { tint })` writes the
221
+ cell's record tint (same `[r, g, b, a]` 0..1 semantics as a sprite's
222
+ tint; absent keys keep their values, a cell set from empty starts at
223
+ `[1, 1, 1, 1]`), and `setTint(rgba)` / the `tint` option and prop tints
224
+ the whole layer through the shared `uTint` uniform, multiplied over the
225
+ per-cell tints - day/night, a dimmed parallax plane. A layer-tint write
226
+ is not a record write (chunks re-render GPU-side, nothing re-uploads),
227
+ but it does re-render every resident chunk: drive it from slow state,
228
+ not per frame. Not built yet: camera-driven residency (bake far chunks
229
+ on approach, evict) - okf/backlog/2d-baked-layers.md.
121
230
 
122
231
  ## Components
123
232
 
124
233
  | Component | Props |
125
234
  |---|---|
126
- | `SpriteLayer` | width, height (layer pixels), atlas (TextureId), capacity?, clearColor?, camera?, oversample?, label?, ref?, output?, events? |
127
- | `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? |
128
- | `Group` | x?, y?, rotation?, scale? (uniform, scales the subtree), transition?, ref? |
129
- | `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), clearColor?, filter?, chunkTiles?, oversample?, camera? (TileCamera: x, y, zoom, rotation, pivotX, pivotY), label?, ref? |
235
+ | `SpriteLayer` | width?, height? (layer pixels - both, or neither = FILL: the leaf lays out at 100% of its sized parent and the layer follows its box, so layer pixels are the leaf's own coordinates; mount-fixed, `output` requires explicit sizes, matching `<Scene>` in @solidrt/3d), atlas (TextureId), capacity?, clearColor?, camera?, tint? ([r,g,b,a] 0..1, over the whole layer), oversample?, maxOversample?, orderBy?, label?, ref?, output?, events?, onPointer{Down,Move,Up}?, onWheel?, onTap? (the root of the walk: `event.sprite` is the hit sprite or null over empty space) |
236
+ | `Sprite` | x, y (center; local to the enclosing `<Group>`), w, h, frame?, rotation? (radians, clockwise), tint? ([r,g,b,a] 0..1), visible?, transition?, onPointer{Down,Move,Up,Enter,Leave}?, onWheel?, onTap?, ref? |
237
+ | `Group` | x?, y?, rotation?, scale? (uniform, scales the subtree), visible? (the whole subtree), transition?, onPointer{Down,Move,Up}?, onWheel?, onTap? (bubbled from hit child sprites), ref? |
238
+ | `Camera2d` | createCamera2d's options minus `viewport` (world?, min/maxZoom?, pivot?, deadZone?, zoomSpeed?, followSpeed?, inertia?, x?, y?, zoom?, rotation?), viewport? (`() => { width, height }`, default: the layer's size), ref? - a `<SpriteLayer>` child driving its camera from the layer's root; read at mount |
239
+ | `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), chunkClearColor?, filter?, chunkTiles?, tint? ([r,g,b,a] 0..1, over the whole layer), oversample?, maxOversample?, camera? (TileCamera: x, y, zoom, rotation, pivotX, pivotY), label?, ref? |
130
240
 
131
241
  `SpriteLayer` owns the layer and renders the built-in `<texture>` leaf
132
242
  carrying the layer's pointer handlers (opt out with `events={false}`; compose
133
- yourself with `output`, then spread `useSpriteLayer().handlers` onto your
134
- leaf). `Sprite` renders nothing - it allocates a record through context and
135
- syncs props into it.
243
+ yourself with `output`, then spread `useSpriteLayer().layer.handlers` onto
244
+ your leaf). `useSpriteLayer()` returns `{ layer, parent }` - the same shape
245
+ as `useScene()` in `@solidrt/3d` - where `parent` is the enclosing
246
+ `<Group>`'s handle (null at the layer root), so imperative
247
+ `addSprite(layer, { parent })` mounts where the JSX sits. `Sprite` renders
248
+ nothing - it allocates a record through context and syncs props into it.
136
249
  `GroupContext` is `createContext<SpriteGroup | null>(null)` on purpose: an
137
250
  optional parent needs a non-undefined default, since Solid 2 throws on a
138
251
  resolved `undefined` even when one was passed as the default.
139
252
 
140
- Pointer events: exact rotated-rect containment, topmost sprite first, capture
141
- per pointerId (a drag keeps delivering to the grabbed sprite with live
142
- coordinates), enter/leave paired per pointer. No bubbling - the sprite list
143
- is flat. Event x/y are layer pixels with the camera undone.
253
+ Pointer events (dispatch.ts, both layer kinds): the DOM event model one
254
+ tree deeper, with the LAYER as the root of the walk. Exact rotated-rect
255
+ containment, topmost sprite first; down/move/up/wheel dispatch on the hit
256
+ sprite, bubble through its enclosing groups and END AT THE LAYER's
257
+ listeners (`layer.listen({...})`, the `<SpriteLayer onPointer*/onWheel/
258
+ onTap>` props) - over empty space the walk is the layer alone, with
259
+ `event.sprite` null. `event.sprite` stays the hit, `currentTarget` the
260
+ handle whose handler runs (the layer at the end), `stopPropagation()`
261
+ stops the walk, and a stopped DOWN claims the whole press: that pointer's
262
+ move, up and tap never reach the layer either (the chain still bubbles).
263
+ That one rule is how a sprite drags itself under a `<Camera2d>` without
264
+ the view panning: stop the down, own the captured moves. Capture is per
265
+ pointerId to the press target, the layer included (a drag from empty
266
+ space keeps delivering to the root as it crosses sprites; a drag from a
267
+ sprite keeps naming it with live coordinates). Enter/leave fire on the
268
+ sprite alone - a group never receives them - while the root sees every
269
+ move, so "hovering empty space" is a root move with `sprite` null. Taps
270
+ are synthesized by the dispatch (DOM click, Unity's click handler):
271
+ `onTap` fires after the up when the press released on the target it
272
+ pressed within the slop (8 window px, core's recognizer slop, so a press
273
+ is never both a tap and a pan), was the only pointer down for its whole
274
+ press (a pinch never taps), with `tapCount` counting repeats within 300
275
+ ms and 20 px on the same target (a double tap is `tapCount === 2`).
276
+ Wheel walks like a move with `deltaX/deltaY`. Every event carries
277
+ `native`, the leaf's element event, for core's recognizers (`createPan`
278
+ to drag a sprite with slop, `createTransform` under the camera); a plain
279
+ `onPointerMove` also fires on hover, so a sprite drag gates on its own
280
+ pressed flag from down to up (`native.button` is set on down/up only).
281
+ Root listeners all run, in registration order (the root is the last
282
+ stop, nothing is left to claim); a record layer has no groups, so the
283
+ chain is the sprite then the root. Event x/y are layer pixels with the
284
+ camera undone. checks/dispatch-check.ts pins the walk, claiming, capture,
285
+ hover, wheel and tap rules headless.
144
286
 
145
287
  ## Traps
146
288
 
@@ -149,7 +291,10 @@ is flat. Event x/y are layer pixels with the camera undone.
149
291
  createAtlas registers with the owning scope like every core texture).
150
292
  - `capacity` is a reservation, not a limit, on both layer kinds; reserve
151
293
  realistically to skip the growth copies. On the records layer, do not
152
- cache `layer.records` across addSprite - growth replaces the array.
294
+ cache `layer.records` across addSprite - growth replaces the array and
295
+ a hoisted reference becomes a dead copy whose writes publish nothing.
296
+ `layer.withRecords(fn)` is the hoist-proof read; a bare `layer.records`
297
+ at use time is equally live.
153
298
  - Node layer: `sprite.node` is public FOR BINDING PRODUCERS, not for
154
299
  lifecycle - never destroyNode it yourself (removeSprite owns that), and
155
300
  a transform written through flux:spatial directly bypasses the sprite's
@@ -158,8 +303,8 @@ is flat. Event x/y are layer pixels with the camera undone.
158
303
  - With a transition set, the sprite's fields (and getSprite) read the
159
304
  TARGET, not the mid-flight pose - the JS mirror is what setSprite
160
305
  composes partial writes from, and targets are the right thing to
161
- compose. Picking and the pose buffer see the actual mid-flight pose
162
- (what is on screen). Clearing the transition (null) keeps the
306
+ compose. Picking, the pose buffer and `worldPosition` see the actual
307
+ mid-flight pose (what is on screen). Clearing the transition (null) keeps the
163
308
  mid-flight pose on the node while the mirror still holds the old
164
309
  target: the next setSprite write snaps to whatever it says.
165
310
  - Node layer picking reads the index as of the last core flush; `pick`/
@@ -173,6 +318,23 @@ is flat. Event x/y are layer pixels with the camera undone.
173
318
  memcpy, microseconds at 10k; the node layer's style publish is the same
174
319
  whole-prefix shape. Dirty ranges were deliberately not built until a
175
320
  measurement asks.
321
+ - `orderBy` on BOTH layers: the core gathers publishes into key order
322
+ (gpu `instanceOrder`, radix sort + one extra memcpy, no per-record JS),
323
+ so a y-sorted crowd costs the same flush as an unsorted one. Records
324
+ and slots stay stably addressed; ties keep slot order; `pick()` still
325
+ resolves overlap by slot/record order. Records layer keys: "y" or a raw
326
+ `{ field, descending? }` offset into the 13-float record. Node layer
327
+ keys: `"y"` - WORLD y from the core-written pose buffer, so sprites
328
+ moved by native transitions (or any core producer) re-sort with zero JS
329
+ per frame - or `"renderOrder"` - the app-owned per-sprite `renderOrder`
330
+ field (style record float 8, default 0; @solidrt/3d's name for the same
331
+ knob), the explicit-layering key for painter-order scenes: raise a
332
+ dragged piece with `setSprite(hit, { renderOrder: ++top })`, back to 0
333
+ to restore. Either way the core gathers
334
+ pose AND style under ONE permutation and republishes the sibling buffer
335
+ itself when the key buffer re-orders (the multi-buffer stage of
336
+ okf/backlog/gpu-instance-order.md). `renderOrder` on a record-layer
337
+ sprite throws - its 13-float record has no key field.
176
338
  - The node layer's STYLE slots are not compacted: a removed sprite leaves
177
339
  its style floats in place (invisible - the pose is zeroed) until the
178
340
  slot recycles. Do not read style truth from the buffer; getSprite reads
@@ -187,10 +349,14 @@ is flat. Event x/y are layer pixels with the camera undone.
187
349
  an atlas uploaded from straight-alpha pixels (`decodeImage(bytes, { alpha:
188
350
  "straight" })` + `createTexture`) draws color under transparent texels
189
351
  as opaque - the classic "keyed-out backdrop becomes a wash" symptom.
190
- - `pointInSprite` in pick.ts and the vertex stage's rotation must agree on
191
- direction (clockwise, y-down). The differential check (pick-check.ts)
192
- guards the math against an oracle but NOT against the shader - if you
193
- touch one rotation, touch both.
352
+ - Every rotation must agree on direction (clockwise, y-down):
353
+ `pointInSprite` in pick.ts with the vertex stage's `iRot`, and
354
+ `projectCamera` in camera.ts with `uCameraRot` and `<TileLayer>`'s view
355
+ transform. The differential checks (pick-check.ts, camera-check.ts)
356
+ guard the JS math against oracles but NOT against the shader - if you
357
+ touch one rotation, touch all, then run examples/camera-probe.tsx (the
358
+ live guard: shader vs projectCamera, node/record parity, the pointer
359
+ round trip) and watch for CAMERA-OK.
194
360
  - Sprite handles go inert on removal (`sprite.layer === null`); setSprite on
195
361
  an inert handle is a silent no-op (matching the throw-in-dev policy would
196
362
  mean throwing, but removal racing a queued pointer event is routine, not
@@ -209,9 +375,14 @@ is flat. Event x/y are layer pixels with the camera undone.
209
375
  Both are LAYOUT components: neither can live inside a d-* subtree (the
210
376
  insert throws); for a detached parent, `<SpriteLayer output>` hands out
211
377
  the texture id for a `<d-texture>` of your own.
212
- - `clearColor` is PER CHUNK: never-written regions have no chunk and render
213
- nothing, so a full-bleed ground color belongs on the container behind the
214
- layer (a `d-rect` under it), not on clearColor.
378
+ - `chunkClearColor` is PER CHUNK, as named: never-written regions have no
379
+ chunk and render nothing, so a full-bleed ground color belongs on the
380
+ container behind the layer (a `d-rect` under it), not here. The flip side is FREE
381
+ TRANSPARENCY: with the default `[0,0,0,0]` clear, a mostly-unwritten
382
+ tile layer stacked over another composites with no mask, no alpha pass,
383
+ no shader - a sparse upper world (floating clouds over a sea) just
384
+ works, and per-cell alpha in the written cells carries through the
385
+ chunk (transparent clear + alpha blend).
215
386
  - Two samplers, two jobs. The ATLAS sampler (createAtlas `filter`) decides
216
387
  whether texels are hard blocks ("nearest", pixel art) or smooth
217
388
  ("linear"). The LAYER's output sampler (the sprite layer's target, the
@@ -224,12 +395,24 @@ is flat. Event x/y are layer pixels with the camera undone.
224
395
  (nearest inside keeps blocks square), the linear composite spreads the
225
396
  fraction over one device pixel at block edges. The components pick n
226
397
  every layout from their leaf's window box (`getBoundingBoxViewport` x
227
- `displayScale()`, which composes designSize fits and camera zoom), within
398
+ `displayScale()`, which composes designSize fits and camera zoom; the
399
+ tile layer divides its camera rotation's AABB swell back out - rotation
400
+ is not a resolution factor - and shrinks only past a margin so an
401
+ oscillating measurement cannot re-bake in a loop), within
228
402
  a budget of the window's own device pixel count; the
229
403
  primitives default to 1 and take `{ oversample }` / `setOversample(n)`
230
404
  - with `output` on `<SpriteLayer>` there is no built-in leaf, so set it
231
- yourself. Never fix a shimmer by snapping the fit to an integer: the
232
- scene should fill its box at any ratio.
405
+ yourself with the exported `fitOversample`. The window budget bounds one
406
+ TARGET, which never binds for chunk-sized tile targets: a tile world's
407
+ texture memory is resident chunks x n squared, and `maxOversample` is
408
+ the cap that bounds it (auto-pick only; a 2x display otherwise picks 16x
409
+ the memory of n = 1, silently). A layer whose oversample changes more
410
+ than a few times in a second warns in the console (thrash: every change
411
+ resizes and redraws the targets, a tile layer re-bakes every resident
412
+ chunk) - pin `oversample` or set `maxOversample` when an animated
413
+ transform or camera legitimately sweeps the scale. Never fix a shimmer by
414
+ snapping the fit to an integer: the scene should fill its box at any
415
+ ratio.
233
416
  - Retro scrolling habits are the app's, not the layer's: keep the camera
234
417
  fractional (rounding it to design pixels makes motion step at the
235
418
  rounding rate, not the frame rate), and a game that wants whole-pixel
@@ -242,8 +425,9 @@ is flat. Event x/y are layer pixels with the camera undone.
242
425
  default size) and every resident chunk keeps a composited leaf. Bounded
243
426
  worlds only; streaming/infinite is stage B2 in
244
427
  okf/backlog/2d-baked-layers.md.
245
- - The sprite layer's camera cannot rotate (uCamera is offset + zoom); a
246
- sprite layer riding a rotating TileCamera needs
247
- okf/backlog/2d-sprite-camera-rotation.md first. Rotating the sprite
248
- layer's OUTPUT leaf instead is wrong - it is viewport-sized, the corners
249
- cut.
428
+ - The sprite layer's camera rotation is IN-SHADER (uCameraRot) on
429
+ purpose: rotating the layer's OUTPUT leaf instead is wrong - the output
430
+ is viewport-sized with the camera already applied, so a leaf transform
431
+ spins the cropped viewport and the corners cut. The tile layer gets
432
+ away with the transform-on-the-leaf camera only because its composited
433
+ view is the WORLD, not a viewport of it.
@@ -14,8 +14,25 @@ copy one and adapt it.
14
14
  under it via the `<TileLayer>` camera prop - transform writes, never a
15
15
  re-bake), and a timer editing tiles to show that a `setTile` batch
16
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.
17
+ - `anim.tsx` - frame animation with `createAnimation`: three sprites share
18
+ one looping clip (one clock, stepped by wall time independent of display
19
+ rate) and a one-shot clip holds its last frame and fires `onEnd`.
20
+ Self-asserting - watch the logs for ANIM-OK.
21
+ - `camera.tsx` - the 2d camera (`createCamera2d`) over a world larger than
22
+ the window, attached at the layer's root (`cam.attach(layer)`): drag
23
+ empty space to pan with inertia, wheel/pinch to zoom about the pointer,
24
+ tap empty space to glide there (the layer's `onTap` with `e.sprite`
25
+ null), tap a sprite to select it, drag a sprite to move it (the sprite
26
+ stops its down, so the camera never pans under it), F to follow a
27
+ roaming sprite through a dead zone, R to spin the view, Space to fit the
28
+ world. The LAYER's `handlers` spread onto its leaf; `update(dt)` from
29
+ `onFrame` is the only per-frame call. Debug commands `camera`, `mode`,
30
+ `selected` and `first` drive it headless.
31
+ - `pick.tsx` - the event model through the component face: exact
32
+ rotated-rect hit testing topmost-first, `onTap` (the dispatch's own
33
+ click, no slop bookkeeping), a claimed press dragging a sprite under a
34
+ `<Camera2d>` that pans empty space and zooms on wheel, the layer's own
35
+ `onTap` seeing misses with `e.sprite` null (and `tapCount` for double
36
+ taps), and removal through a signal so `<For>` unmounts the `<Sprite>`
37
+ and the layer recycles the slot. The `state` debug command reads the
38
+ camera and sprite positions back.
@@ -0,0 +1,86 @@
1
+ // Frame animation via createAnimation: a row of sprites sharing one looping
2
+ // clip (one clock, one setSprite per sprite per STEP - an app's whole crowd
3
+ // animates off a handful of timers, no onFrame loop), plus a one-shot clip
4
+ // that holds its last frame and fires onFinish. Self-asserting: samples the
5
+ // sprites' frames against the wall clock and logs ANIM-OK / ANIM-FAIL.
6
+ //
7
+ // The atlas is the core logo sliced 2x2 by grid(): a 4-frame "clip". Real
8
+ // art would slice a sheet the same way and pass a consecutive slice of the
9
+ // frames array per animation.
10
+ import { render } from "@solidrt/core"
11
+ import { addSprite, createAnimation, createAtlas, createSpriteLayer, getSprite, grid } from "@solidrt/2d"
12
+ import type { Frame, SpriteHandle } from "@solidrt/2d"
13
+ import logoBytes from "./logo.png" with { type: "binary" }
14
+
15
+ const W = 520
16
+ const H = 200
17
+ const SPRITE = 96
18
+ // Slow enough that timer jitter cannot blur a sampled frame boundary: the
19
+ // checks sample mid-frame, half a period from either edge.
20
+ const LOOP_FPS = 2
21
+ const SHOT_FPS = 5
22
+
23
+ function App() {
24
+ let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
25
+ let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
26
+ let layer = createSpriteLayer(W, H, atlas.texture, {
27
+ clearColor: [0.05, 0.05, 0.09, 1],
28
+ label: "anim",
29
+ })
30
+
31
+ // Three sprites share the looping clip - one clock steps all of them.
32
+ let looping = createAnimation(frames, LOOP_FPS)
33
+ let looped: SpriteHandle[] = []
34
+ for (let i = 0; i < 3; i++) {
35
+ let sprite = addSprite(layer, { x: 80 + i * 120, y: H / 2, w: SPRITE, h: SPRITE })
36
+ looping.add(sprite)
37
+ looped.push(sprite)
38
+ }
39
+
40
+ // The one-shot: plays through once, holds the last frame, fires onFinish.
41
+ let shot = createAnimation(frames, SHOT_FPS, { loop: false })
42
+ let ended = 0
43
+ shot.onFinish = () => ended++
44
+ let shotSprite = addSprite(layer, { x: 440, y: H / 2, w: SPRITE, h: SPRITE })
45
+ shot.add(shotSprite)
46
+
47
+ let failures: string[] = []
48
+ function check(ok: boolean, what: string) {
49
+ if (!ok) failures.push(what)
50
+ }
51
+ function indexOf(frame: Frame): number {
52
+ return frames.findIndex(f => Math.abs(f.u0 - frame.u0) < 1e-6 && Math.abs(f.v0 - frame.v0) < 1e-6)
53
+ }
54
+ function shownIndex(sprite: SpriteHandle): number {
55
+ return indexOf(getSprite(sprite)!.frame)
56
+ }
57
+
58
+ // Sample mid-frame against the wall clock. At LOOP_FPS = 2 a frame is
59
+ // 500ms; the clip steps within ~250ms of a boundary (tick at half a
60
+ // period), so mid-frame samples have ~250ms of margin either way.
61
+ let period = 1000 / LOOP_FPS
62
+ setTimeout(() => {
63
+ check(shownIndex(looped[0]!) === 0, "loop shows frame 0 in its first period")
64
+ }, period / 2)
65
+ setTimeout(() => {
66
+ check(shownIndex(looped[1]!) === 1, "loop advanced to frame 1")
67
+ check(looping.frame === shownIndex(looped[1]!), "anim.frame matches the sprite's shown frame")
68
+ }, period * 1.5)
69
+ setTimeout(() => {
70
+ check(shownIndex(looped[2]!) === 0, "loop wrapped back to frame 0 after a full cycle")
71
+ // One-shot: 4 frames at 5fps end at 800ms - long since done here.
72
+ check(shownIndex(shotSprite) === frames.length - 1, "one-shot holds its last frame")
73
+ check(!shot.playing, "one-shot stopped playing")
74
+ check(ended === 1, `onFinish fired once, got ${ended}`)
75
+ check(looping.playing, "the looping clip is still playing")
76
+ console.log(failures.length === 0 ? "ANIM-OK" : `ANIM-FAIL: ${failures.join("; ")}`)
77
+ }, period * 4.5)
78
+
79
+ return (
80
+ <window alignItems="center" justifyContent="center">
81
+ <texture src={layer.texture} width={W} height={H} />
82
+ </window>
83
+ )
84
+ }
85
+
86
+ render(() => <App />)