@solidrt/2d 0.0.55 → 0.0.57

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
@@ -17,32 +17,80 @@ moved subtrees in Rust, and picking walks the core BVH.
17
17
  `addGroup`/`setGroup`/`setSpriteParent`/`setSpriteTransition`/
18
18
  `setGroupTransition` - plain objects, no signals, usable without
19
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).
20
+ raw escape hatch, below), and the component face (components/:
21
+ `SpriteLayer`/`Sprite`/`Group`/`View2d`/`Camera2d` over context).
22
+ - A layer holds sprites and renders NOTHING by itself: it shows through
23
+ its VIEWS (`layer.createView({ width, height, camera?, oversample?,
24
+ clearColor?, label? })`, `<View2d>`, or the `<SpriteLayer>`'s own leaf,
25
+ which is one), each a draw target with a camera, a size and pointer
26
+ dispatch of its own - a Unity scene renders only through Cameras, a
27
+ Godot World2D only through Viewports, and there is no privileged first
28
+ one. The window view, the minimap, two split-screen panes are the same
29
+ `ViewHandle`. Details under Views below.
22
30
  - Node layer ownership split, two instance-buffer slots on one pipeline:
23
31
  slot 0 is the POSE buffer `[x, y, angle, sx, sy]` written ONLY by the
24
32
  core (each sprite node's Pose2D record sink; one coalesced buffer write
25
33
  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.
34
+ `[u0, v0, u1, v1, tint rgba, renderOrder]`, JS-owned, published through the
35
+ zero-copy write lease. NEVER write the pose buffer from JS - the core's
36
+ staging mirror owns it and will overwrite.
29
37
  - Sprites hold FIXED instance slots: draw order is slot order, removal
30
38
  zeroes the pose (zero scale = nothing drawn) and recycles the slot to
31
39
  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).
40
+ opaque-or-transparent pixel art never notices, and a scene that needs a
41
+ real draw order says so with `orderBy` ("y" or "renderOrder" - see below).
34
42
  - Growth (past `capacity`, doubling): pose sinks move in ONE core
35
43
  `retargetRecords` call (full republish next flush), style re-uploads,
36
44
  `setDraw({ instanceBuffers })` swaps both, old buffers destroyed.
37
45
  - 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.
46
+ rotated-rect via the node's local box) and returns ALL hits topmost
47
+ first (highest slot) - the all-hits shape of @solidrt/3d's pick, so
48
+ `pick(x, y)[0]` is the topmost; `pickRect` is `overlap` over an
49
+ unrotated rect, sprites only (exact for rotated sprites, the marquee),
50
+ unordered. Every query passes the core a filter with the layer's root
51
+ node (sprites and groups without a parent hang off it), so the arena
52
+ being shared with e.g. a 3d scene costs nothing in JS.
53
+ - Spatial queries (`checks/collision-check.tsx` pins the contract): the
54
+ 3d scene's trio one dimension down, Godot's PhysicsDirectSpaceState2D
55
+ and CharacterBody2D in their names. `layer.raycast(x, y, dx, dy,
56
+ opts?)` is every shown sprite the ray strikes, nearest first, with
57
+ distance, edge point and edge normal; `layer.overlap(volume, opts?)`
58
+ every sprite a volume touches with its deepest contact `{ sprite,
59
+ point, normal, depth }`, unordered; `layer.sweep(volume, dx, dy,
60
+ opts?)` every sprite the moving volume first touches `{ sprite, time,
61
+ point, normal }`, earliest first; `layer.moveAndSlide(volume, dx, dy,
62
+ opts?)` the character mover over them, one core call per body per
63
+ frame (the depenetration, slide and floor-snap loop runs in the spatial
64
+ core, the same one @solidrt/3d's mover uses), returning `{ motion,
65
+ floor, wall, ceiling, hits }` with `up` defaulting to [0, -1] (y-down).
66
+ A `Volume` is a `Circle` `{ x, y, radius }`, a `Capsule` `{ ax, ay,
67
+ bx, by, radius }` or a `Rect` `{ x, y, width, height, rotation? }`;
68
+ `QueryOptions.sprites` is an include-list. Results name sprites, never
69
+ node ids, and every query runs the pending batch first, like pick.
70
+ The trap behind the design: a sprite is a COLUMN in the index
71
+ (`SPRITE_DEPTH` half-depth along z), not a flat quad. Against a flat
72
+ triangle the core's contact for a circle whose center lies over the
73
+ sprite is the plane normal (z) with depth = radius; against a column
74
+ every volume at z = 0 meets only side faces, so contacts, sweeps and
75
+ the mover stay in the plane with no 2d narrowphase of their own.
42
76
  - Groups (`addGroup`/`<Group>`) are plain arena nodes (x, y, rotation,
43
77
  UNIFORM scale - a group is a frame, never a sprite size; sprite w/h
44
78
  lives in the sprite node's scale, which is why sprites cannot parent
45
- sprites). Child sprite pose fields are local to the group.
79
+ sprites). Child sprite pose fields are local to the group. `removeGroup`
80
+ removes the SUBTREE: child sprites and groups die with it (remove means
81
+ destroy here, as for removeSprite; re-parent a child out first to keep
82
+ it). @solidrt/3d's `remove` instead DETACHES a re-addable subtree - its
83
+ nodes exist outside a scene, sprites cannot exist outside their layer.
84
+ A handle's x/y are local to its group; `worldPosition(sprite | group)`
85
+ reads the layer-pixel position composed through every enclosing group
86
+ from the core's world matrix (@solidrt/3d's worldPosition).
87
+ - `visible` (@solidrt/3d's setVisible, in the setSprite/setGroup bag;
88
+ default true) is core node visibility: hidden, the pose slot zeroes
89
+ (nothing drawn) and pick/pickRect skip the node; a group's flag hides
90
+ its WHOLE subtree while each child keeps its own. Handle, slot, style
91
+ records and transition state stay, so showing restores the sprite as
92
+ it was. Node layer only - on a record sprite it throws (hide those by
93
+ zeroing w or h).
46
94
  - `flipX`/`flipY` mirror on the UV SIDE: the style/record write swaps
47
95
  u0/u1 (v0/v1), so w/h stay the drawn size, a scale transition never sees
48
96
  a flip, and picking is unchanged (the vertex stage still carries no
@@ -54,19 +102,153 @@ moved subtrees in Rust, and picking walks the core BVH.
54
102
  costs zero, the same demand-gate story as the rest of the platform.
55
103
  - The records layer (`createRecordLayer`) keeps the old model whole: 13
56
104
  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
105
+ rgba]` (`FLOATS_PER_SPRITE`), draw order = insertion order (or key
106
+ order with `orderBy` - see below), remove
58
107
  shifts, `layer.records` + `touch()` raw writes, JS pick walk. It is the
59
108
  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
109
+ sprites: 12.9ms raw records vs 30.8ms via setSprite; both figures are
110
+ the WRITE path only - whatever computes the motion is excluded and is
111
+ usually the dominant cost, e.g. a 24k-particle sim measured ~25ms with
112
+ a near-free publish) - the axis is
61
113
  WHERE MOTION IS COMPUTED, not retained-vs-dynamic. The sprite functions
62
114
  (addSprite/setSprite/getSprite/removeSprite) work on both layer kinds;
63
115
  record sprites have `node: null` and no groups.
64
116
  - Layer space is pixels, top-left origin, y-down - the render tree's frame.
65
117
  The pipeline's clip space is y-down too (core gpu.ts pixel contract), so
66
118
  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.
119
+ - The camera (`view.setCamera`/the `camera` prop) is ONE `CameraUpdate`
120
+ type across both layer kinds' views: offset, zoom, and rotation about a
121
+ pivot (camera.ts documents the semantics and the heading-upward
122
+ convention, `rotation = -h - pi/2`). It is a shared-params write on
123
+ the view's target (`uCamera` + `uCameraRot`, the rotation in-shader),
124
+ one call however many sprites exist; `projectCamera`/`unprojectCamera`
125
+ export the world <-> screen mapping as pure functions, and pointer
126
+ dispatch undoes the view's camera with the latter, so events arrive in
127
+ world (layer) pixels. Picking itself works in world space and never
128
+ sees a camera. `view.camera()` reads the camera back (every field
129
+ present - the `CameraState` snapshot, @solidrt/3d's scene.camera())
130
+ and `view.project(x, y)` / `view.unproject(x, y)` are the two pure
131
+ functions over it.
132
+ - Camera control: `createCamera2d(view | views, { viewport: () =>
133
+ ({ width, height }), world?: { width, height }, min/maxZoom?, pivot?,
134
+ deadZone?: { width, height }, panSpeed?, zoomSpeed?, rollSpeed?,
135
+ followSpeed?, inertia?, x?, y?, zoom?, rotation? })` - Godot's Camera2D
136
+ and Three's MapControls in one control over the shared CameraUpdate:
137
+ pan with inertia on release, zoom about a point, eased `glideTo`/`fit`,
138
+ `follow(x, y)` through a dead zone with damping, roll about the pivot,
139
+ and world bounds that CONTAIN the view (an axis whose view is wider
140
+ than the world centers - so at the fit zoom a pan is a no-op, there is
141
+ nothing to pan; limits ignore rotation, Godot's rule). The pose is
142
+ "world point at the pivot", the pivot a viewport fraction defaulting to
143
+ the center, so `camera().x/y` is the view center and glideTo/follow
144
+ land there (without a `world`, the default pose puts world 0,0 at the
145
+ pivot: a fill layer that wants world = screen at rest takes `pivot: {
146
+ x: 0, y: 0 }`). The first argument is anything with a view's
147
+ `setCamera` (a view of a sprite layer, of a record layer, several at
148
+ once - one camera over a scene's layers - or a signal setter feeding
149
+ `<TileLayer camera>`).
150
+ Input, the rule (ARCHITECTURE.md): the control consumes a device-free
151
+ abstraction and never handles events itself. Its `axes` (core's
152
+ createAxes contract) are `pan` (vec2: a delta is finger travel in
153
+ viewport heights, applied as content travel - the world follows the
154
+ finger; a rate slides at one viewport height per second), `zoom` (axis,
155
+ octaves, positive in: a delta bracketed by a gesture - a pinch - applies
156
+ at once about its focal point, an unbracketed one - a wheel notch -
157
+ retargets an eased glide, which is how the control tells a finger from
158
+ an impulse; notches compound on the pending target) and `roll` (axis,
159
+ turns); a pan gesture's begin stops any glide (a finger landing on a
160
+ gliding view holds it) and its end flings with the drag's velocity. The
161
+ verbs, each pushing the pose at once: `panBy(dx, dy)` screen pixels,
162
+ `zoomAt(sx, sy, factor, { glide? })`, `rollBy(angle)`, `set(pose)`,
163
+ `glideTo`, `fit`, `follow`/`unfollow`, `interrupt`, `release`. An input
164
+ map (core AGENTS.md) drives the axes by name and the APP binds devices
165
+ to it: the view's pointer feed (`createPointerFeed()`, handed to
166
+ `<SpriteLayer pointer>` / `<View2d pointer>`, or bridged with
167
+ `feedPointer(view, feed)` for an imperative view - fed from the view's
168
+ ROOT, so a sprite that claims its press keeps the camera out of that
169
+ drag and a wheel anywhere zooms), a pad, the keyboard. Nothing binds
170
+ by default; `camera2dBindings({ pointer, gamepad?, keyboard? })` is the
171
+ standard set (drag and two fingers pan, pinch and wheel zoom, twist
172
+ rolls; the left stick and the arrows scroll the VIEW - bound through
173
+ invert(), since keys and sticks move the camera where a drag moves the
174
+ content - the triggers zoom, the right stick's x rolls) and
175
+ `camera2dActions` the declarations:
176
+
177
+ ```tsx
178
+ let pointer = createPointerFeed()
179
+ feedPointer(view, pointer)
180
+ let input = createInputMap(camera2dActions)
181
+ input.bind(camera2dBindings({ pointer, gamepad: gamepad() }))
182
+ input.drive(cam.axes)
183
+ ```
184
+
185
+ Call `cam.update(dt)` from a frame loop (it advances glides, follow,
186
+ inertia and the axis rates, pushes one setCamera per driven view when
187
+ the pose changed and reports that), gated on the reactive
188
+ `cam.active()` (true while a glide, fling, fit, follow or pan gesture
189
+ needs frames, or a rate drives; false at rest), read `cam.camera()` for
190
+ projectCamera. The camera has NO tap of its own: taps are the
191
+ dispatch's (`onTap` on the root with `e.sprite` null is "tap on empty
192
+ space"). `<Camera2d input={input}>` inside `<SpriteLayer>` is all of
193
+ that wired through context, the 3d `<OrbitCamera>` shape: options read
194
+ at mount, driving the nearest view (the `<SpriteLayer>`'s own or the
195
+ enclosing `<View2d>`), `viewport` defaulting to that view's size, the
196
+ map's `pan`/`zoom`/`roll` actions (or the names in `actions`) driving
197
+ the axes, frames only while `active()`. A view's feed normalizes a drag
198
+ by the leaf's own box, so a leaf under a designSize fit pans and zooms
199
+ correctly. camera2d.ts imports `@solidrt/core/input` only, so
200
+ checks/camera2d-check.ts pins the clamp, anchoring, glides, follow,
201
+ inertia and the axes headless; examples/camera.tsx (function face) and
202
+ examples/pick.tsx (`<Camera2d>`) are the live guards. The shared
203
+ vocabulary with @solidrt/3d, one kind and unit per word: `pan` vec2,
204
+ `zoom` axis (octaves), `roll` axis (turns) here; `rotate`, `look`,
205
+ `move`, `rise` there. The pose options are initial values; every other
206
+ option (world, zoom range, pivot, dead zone, the speeds, inertia) is
207
+ read where it applies, never snapshotted, so `<Camera2d>` props are
208
+ live - forwarded as getters, a bounds or pivot change re-clamping and
209
+ pushing the pose at once through `set({})` - and a function-face
210
+ caller mutates the options object it passed (the `<OrbitCamera>` rule,
211
+ pinned in the check's live-options case). Not yet, all additive:
212
+ rotation glides, a contain origin other than center.
213
+ - Layer tint (`setTint(rgba)`/the `tint` option and prop, both layer
214
+ kinds): one `uTint` shared-params write multiplied over every sprite's
215
+ own tint - day/night, a dimmed parallax plane, a fade-in. Cheap to
216
+ animate (no record touches), unlike TileLayer's, which re-renders
217
+ resident chunks. The same contract as TileLayer.setTint, so one signal
218
+ drives a whole scene across layer kinds.
219
+ - Views (`layer.createView({ width, height, camera?, oversample?,
220
+ clearColor?, label? })`, both layer kinds): the ONLY output a layer
221
+ has - the window-filling main view, a minimap, a radar strip, a zoomed
222
+ inset, two split-screen panes - @solidrt/3d's scene.createView one
223
+ dimension down, as Unity renders a scene only through Cameras and
224
+ Godot a World2D only through Viewports. Each view is one draw target
225
+ holding one entry over the layer's pipeline and instance buffers
226
+ (views.ts): no sprite is mirrored, no record is written twice, and key
227
+ order (`orderBy`) comes along, since the core gathers the buffers
228
+ themselves at publish - ONE live entry declares the order (the first
229
+ view's; when that view is disposed the next live view re-adds its
230
+ entry ordered), every other entry reads them sorted. Growth, the
231
+ instance count and the layer tint fan out to every view; the camera
232
+ and the viewport are the view's own params, so a view costs no
233
+ per-frame JS beyond its camera writes. The `ViewHandle` is the
234
+ viewport contract (texture, width/height/setSize,
235
+ oversample/setOversample, setCamera/camera/project/unproject,
236
+ listen/handlers/handlersFor, dispose - views also die with the layer);
237
+ the sprites stay the layer's (`addSprite`, `pick`, `setTint`,
238
+ `createView`, `dispose`). Pointer events on a view leaf run the
239
+ dispatch with the VIEW's camera undone over the layer's pick, the view
240
+ as the root of the walk: a sprite under a minimap gets its ordinary
241
+ handlers, `view.listen` is the last stop (a tap there with `e.x`/`e.y`
242
+ in world pixels is "glide the main camera here"), and
243
+ a camera bound to a feed `feedPointer(view, feed)` bridges drives any
244
+ view. `<View2d>` is the
245
+ component form; the `<SpriteLayer>`'s built-in leaf is a view too, and
246
+ `<SpriteLayer output={false}>` has none, showing only through its
247
+ `<View2d>` children (examples/split-screen.tsx). examples/views.tsx is
248
+ the live guard. Not yet, all additive: tile-layer views, a `layers`
249
+ bitmask (a markers-only minimap), `into` tiling, per-view tint, and a
250
+ viewport over SEVERAL layers under one camera (their scene-level
251
+ camera) - okf/backlog/2d-layer-views-additive.md.
70
252
  - Retargeted motion is NATIVE: `setSpriteTransition(sprite, { position:
71
253
  { duration: 700, bounce: 0.3 }, ... })` (or the `transition` prop) makes
72
254
  setSprite writes TARGETS the core animates toward - position/scale
@@ -75,7 +257,11 @@ moved subtrees in Rust, and picking walks the core BVH.
75
257
  retargets). JS costs one write per target CHANGE, zero per frame; the
76
258
  running tracks drive frame demand themselves, and settled sprites cost
77
259
  nothing (bench: 400 retargets ~4ms, once a second - vs ~5ms per FRAME
78
- moving the same population imperatively). Mount poses always snap (the
260
+ moving the same population imperatively). Retargeting every frame is
261
+ also a legitimate pattern, not an abuse: rewriting a spring's TARGET
262
+ each frame to chase a moving point (a follow-camera trailing a moving
263
+ sprite) rides the spring's smoothing for free - a spring keeps its
264
+ velocity through retargets, so the chase stays fluid. Mount poses always snap (the
79
265
  component declares the transition after the first pose sync; the
80
266
  function face sets it after addSprite). Each natural settle calls the
81
267
  handle's `onTransitionEnd` (plain field, or the `<Sprite>`/`<Group>` prop)
@@ -88,8 +274,21 @@ moved subtrees in Rust, and picking walks the core BVH.
88
274
  past that use the records layer). Signals carry structure and slow
89
275
  state - a `<Sprite x={sig()}>` re-running 60 times a second works but
90
276
  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.
277
+ - Frame animation (animation.ts): `createAnimation(frames, fps, { loop })`
278
+ is a clip with a shared wall-clock timer stepping every attached sprite
279
+ (`anim.add(sprite)` / `remove`; `play`/`pause`; `loop: false` holds the
280
+ last frame and fires `onFinish`, the mixer's name for it in
281
+ @solidrt/3d). One timer per playing clip, one setSprite
282
+ per sprite per STEP - an 8fps cycle is 8 publishes a second regardless of
283
+ display rate, and a paused clip costs nothing. A sprite belongs to at
284
+ most one animation (add detaches the previous); removed sprites prune
285
+ lazily on the next step. Works on both layer kinds; with `<Sprite>`,
286
+ attach via `ref` and leave the `frame` prop off - the clip owns that
287
+ field (the prop effect passes absent props as undefined, which setSprite
288
+ keeps, so other props stay reactive).
289
+ - frames.ts, pick.ts, camera.ts, camera-motion.ts and dispatch.ts are pure
290
+ (no GPU imports) BY DESIGN so they can be checked headless; keep them
291
+ that way.
93
292
 
94
293
  ## The baked tile layer (tiles.ts)
95
294
 
@@ -108,39 +307,122 @@ memory, not `maxTextureSize`. `setTile` batches to a microtask whose flush
108
307
  publishes and re-bakes ONLY dirty chunks. After that the layer is static
109
308
  textures: zero per-frame cost however many tiles exist.
110
309
 
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
310
+ Scrolling never re-bakes: the `<TileLayer>` camera prop (`TileCamera`, an
311
+ alias of the shared `CameraUpdate`) is a transform on the composited world
312
+ view - the world point (x, y) pinned to the viewport point (pivotX,
313
+ pivotY), scaled by zoom, ROTATED by rotation about the pivot. The type IS
314
+ the sprite layer's camera type, so one signal drives a whole rotating
315
+ scene across both layers (sprites ride the same rotation in-shader);
316
+ rotation is the ship-flies-over-the-map camera and costs the same
317
+ transform write. A tile
318
+ world is FINITE, sized at creation - the contract, not a provisional
319
+ limit (recreate to resize; a huge sparse world just picks big numbers,
320
+ empty chunks cost nothing). Tiles are data, not
118
321
  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.
322
+ `ref` with `setTile`.
323
+
324
+ Bulk writes: `setTiles(col, row, cols, rows, cells)` writes a rect at
325
+ once - `cells` row-major, `cols * rows` long, either frames (null clears)
326
+ or, with a `frames` table given at creation (`createTileLayer(..., {
327
+ frames })`, the `<TileLayer frames>` prop: a tileset, `grid()`'s array,
328
+ up to 65534 entries), indices into it (-1 clears; a Uint16Array holds -1
329
+ as 0xffff, the same bits) - Unity's SetTilesBlock, Phaser's putTilesAt
330
+ over the map's tileset. One locate and one dirty mark per chunk the rect
331
+ touches, the per-cell loop inside the layer, the same flush; a chunk the
332
+ rect only clears never allocates, so a whole-world write of a sparse
333
+ world allocates exactly the chunks the per-cell seed would. A generated
334
+ or worker-built world is a Uint16Array of indices, transferable, never an
335
+ array of frame objects; a fill is `new Uint16Array(n).fill(i)`. `tint`
336
+ in the options applies to every cell set. Every entry is validated before
337
+ the first write, so a bad one changes nothing; the table is copied at
338
+ creation. The rect form is the shape for worker transfer and chunk
339
+ re-fill, not a large speedup: the per-cell floor is the record write
340
+ itself, so a 16k-cell solid rect re-writes in ~14 ms against ~25 ms as a
341
+ setTile loop and a sparse rect is at parity with the loop, while a first
342
+ seed is dominated by chunk allocation at 1-2 ms per chunk
343
+ (probes/2d-tiles-bulk-bench.tsx). probes/2d-tiles-bulk-probe.tsx holds
344
+ the three seeds (setTile, index rect, frame rect) to the same cells,
345
+ chunks and bakes.
346
+
347
+ Tinting, two levels: `setTile(col, row, frame, { tint })` writes the
348
+ cell's record tint (same `[r, g, b, a]` 0..1 semantics as a sprite's
349
+ tint; absent keys keep their values, a cell set from empty starts at
350
+ `[1, 1, 1, 1]`), and `setTint(rgba)` / the `tint` option and prop tints
351
+ the whole layer through the shared `uTint` uniform, multiplied over the
352
+ per-cell tints - day/night, a dimmed parallax plane. A layer-tint write
353
+ is not a record write (chunks re-render GPU-side, nothing re-uploads),
354
+ but it does re-render every resident chunk: drive it from slow state,
355
+ not per frame. Not built yet: camera-driven residency (bake far chunks
356
+ on approach, evict) - okf/backlog/2d-baked-layers.md.
121
357
 
122
358
  ## Components
123
359
 
124
360
  | Component | Props |
125
361
  |---|---|
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? |
130
-
131
- `SpriteLayer` owns the layer and renders the built-in `<texture>` leaf
132
- 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.
362
+ | `SpriteLayer` | atlas (TextureId), capacity?, tint? ([r,g,b,a] 0..1, over the whole layer in every view), orderBy?, label?, ref?(layer) - the layer; plus its OWN VIEW, unless `output={false}`: width?, height? (view pixels - both, or neither = FILL: the leaf lays out at 100% of its sized parent and the view follows its box, so view pixels are the leaf's own coordinates; mount-fixed, a function `output` requires explicit sizes, matching `<Scene>` in @solidrt/3d), clearColor?, camera?, oversample?, maxOversample?, viewRef?(view), output? (a function composes the leaf from the texture id; `false` = no own view, the layer shows only through `<View2d>` children and the view props throw), events?, pointer? (a createPointerFeed fed from the view's root, what a map over this view binds; `useSpriteLayer().pointer` inside), onPointer{Down,Move,Up}?, onWheel?, onTap? (the view's root: `event.sprite` is the hit sprite or null over empty space) |
363
+ | `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? |
364
+ | `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? |
365
+ | `Camera2d` | createCamera2d's options minus `viewport` (world?, min/maxZoom?, pivot?, deadZone?, panSpeed?, zoomSpeed?, rollSpeed?, followSpeed?, inertia?, x?, y?, zoom?, rotation?), viewport? (`() => { width, height }`, default: the driven viewport's size), input? (the input map driving its `pan`/`zoom`/`roll` axes; live), actions? (action names per axis when the map's differ), ref? - a `<SpriteLayer>` child driving the nearest view's camera (the `<SpriteLayer>`'s own, or inside a `<View2d>` that view) from the map, nothing else; read at mount; throws under `output={false}` outside a `<View2d>` |
366
+ | `View2d` | a `<SpriteLayer>` child: one more view of the layer from a camera of its own (layer.createView as a component): width, height (view pixels, live; fixed-size only for now), camera? (partial CameraUpdate on the view's camera, live; the same state a `<Camera2d>` child writes), oversample?, maxOversample? (the auto-pick, as SpriteLayer's), clearColor?, label? (createView's, fixed), ref?(view), output?(texture) (else a built-in `<texture>` leaf at the view size carrying the view's handlers), events?, pointer? (this view's feed, fed from its root), onPointer{Down,Move,Up}?, onWheel?, onTap? (the view's root: `event.sprite` null over empty space); a `<Camera2d>` child drives the VIEW from its map (inside, `useSpriteLayer()` reports the view as `viewport` and the feed as `pointer`); `<Sprite>`/`<Group>` children mount to the layer as outside |
367
+ | `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), frames? (the tileset `setTiles` indices name), 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? |
368
+
369
+ `SpriteLayer` owns the layer and its own view, rendered as the built-in
370
+ `<texture>` leaf carrying the view's pointer handlers (opt out with
371
+ `events={false}`; compose yourself with `output`, then spread
372
+ `useSpriteLayer().viewport.handlers` onto your leaf; `output={false}` for
373
+ no own view at all). `useSpriteLayer()` returns `{ layer, parent,
374
+ viewport, pointer }` - the same shape as `useScene()` in `@solidrt/3d` -
375
+ where `parent` is the enclosing `<Group>`'s handle (null at the layer
376
+ root), so imperative `addSprite(layer, { parent })` mounts where the JSX
377
+ sits, `viewport` is the nearest view (the `<SpriteLayer>`'s own, or
378
+ inside a `<View2d>` that view), what a `<Camera2d>` drives, and
379
+ `pointer` that view's feed (the owner's `pointer` prop, null without
380
+ one); read under `output={false}` outside a `<View2d>` `viewport` throws. A FILL-mode
381
+ view is 1x1 until the first layout: `ref` fires at mount, before it, so
382
+ `viewport.width`/`height` read 1 there. Sprites added and positioned from
383
+ `ref` (the pool, the opening scene) are fine - the first layout resizes
384
+ the view before the first paint - but anything that needs the real size
385
+ (centering on the view) waits for `onLayout`, or works in a `designSize`
386
+ that is known up front. `Sprite` renders
387
+ nothing - it allocates a record through context and syncs props into it.
136
388
  `GroupContext` is `createContext<SpriteGroup | null>(null)` on purpose: an
137
389
  optional parent needs a non-undefined default, since Solid 2 throws on a
138
390
  resolved `undefined` even when one was passed as the default.
139
391
 
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.
392
+ Pointer events (dispatch.ts, both layer kinds): the DOM event model one
393
+ tree deeper, with the VIEW as the root of the walk. Exact rotated-rect
394
+ containment, topmost sprite first; down/move/up/wheel dispatch on the hit
395
+ sprite, bubble through its enclosing groups and END AT THE VIEW's
396
+ listeners (`view.listen({...})`, the `<SpriteLayer>`/`<View2d>`
397
+ `onPointer*/onWheel/onTap` props) - over empty space the walk is the view
398
+ alone, with `event.sprite` null. `event.sprite` stays the hit,
399
+ `currentTarget` the handle whose handler runs (the view at the end),
400
+ `stopPropagation()` stops the walk, and a stopped DOWN claims the whole
401
+ press: that pointer's move, up and tap never reach the view either (the
402
+ chain still bubbles).
403
+ That one rule is how a sprite drags itself under a `<Camera2d>` without
404
+ the view panning: stop the down, own the captured moves. Capture is per
405
+ pointerId to the press target, the view included (a drag from empty
406
+ space keeps delivering to the root as it crosses sprites; a drag from a
407
+ sprite keeps naming it with live coordinates). Enter/leave fire on the
408
+ sprite alone - a group never receives them - while the root sees every
409
+ move, so "hovering empty space" is a root move with `sprite` null. Taps
410
+ are synthesized by the dispatch (DOM click, Unity's click handler):
411
+ `onTap` fires after the up when the press released on the target it
412
+ pressed within the slop (8 window px, core's recognizer slop, so a press
413
+ is never both a tap and a pan), was the only pointer down for its whole
414
+ press (a pinch never taps), with `tapCount` counting repeats within 300
415
+ ms and 20 px on the same target (a double tap is `tapCount === 2`).
416
+ Wheel walks like a move with `deltaX/deltaY`. Every event carries
417
+ `native`, the leaf's element event, for core's recognizers (`createPan`
418
+ to drag a sprite with slop, `createTransform` under the camera); a plain
419
+ `onPointerMove` also fires on hover, so a sprite drag gates on its own
420
+ pressed flag from down to up (`native.button` is set on down/up only).
421
+ Root listeners all run, in registration order (the root is the last
422
+ stop, nothing is left to claim); a record layer has no groups, so the
423
+ chain is the sprite then the root. Event x/y are layer pixels with the
424
+ camera undone. checks/dispatch-check.ts pins the walk, claiming, capture,
425
+ hover, wheel and tap rules headless.
144
426
 
145
427
  ## Traps
146
428
 
@@ -148,8 +430,16 @@ is flat. Event x/y are layer pixels with the camera undone.
148
430
  live app-long. Dispose atlases yourself (or let the reactive owner do it -
149
431
  createAtlas registers with the owning scope like every core texture).
150
432
  - `capacity` is a reservation, not a limit, on both layer kinds; reserve
151
- realistically to skip the growth copies. On the records layer, do not
152
- cache `layer.records` across addSprite - growth replaces the array.
433
+ realistically to skip the growth copies. The pool idiom that fits a
434
+ game: reserve `capacity`, add every sprite at mount with `visible:
435
+ false`, then show/hide - a hidden sprite keeps its slot, so draw order
436
+ stays stable and a spawn is one setSprite, never an add. Keep a free
437
+ list of hidden handles; scanning the pool for one is the cost that
438
+ shows up first at a few hundred entities. On the records layer, do not
439
+ cache `layer.records` across addSprite - growth replaces the array and
440
+ a hoisted reference becomes a dead copy whose writes publish nothing.
441
+ `layer.withRecords(fn)` is the hoist-proof read; a bare `layer.records`
442
+ at use time is equally live.
153
443
  - Node layer: `sprite.node` is public FOR BINDING PRODUCERS, not for
154
444
  lifecycle - never destroyNode it yourself (removeSprite owns that), and
155
445
  a transform written through flux:spatial directly bypasses the sprite's
@@ -158,14 +448,22 @@ is flat. Event x/y are layer pixels with the camera undone.
158
448
  - With a transition set, the sprite's fields (and getSprite) read the
159
449
  TARGET, not the mid-flight pose - the JS mirror is what setSprite
160
450
  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
451
+ compose. Picking, the pose buffer and `worldPosition` see the actual
452
+ mid-flight pose (what is on screen). Clearing the transition (null) keeps the
163
453
  mid-flight pose on the node while the mirror still holds the old
164
454
  target: the next setSprite write snaps to whatever it says.
165
- - Node layer picking reads the index as of the last core flush; `pick`/
166
- `pickRect` run the layer's pending batch first, so write-then-pick in
167
- one tick is coherent. Producers moving nodes between flushes are one
168
- frame stale to picking, like every query.
455
+ - Node layer picking reads the index as of the last core flush; every
456
+ query runs the layer's pending batch first, so write-then-query in one
457
+ tick is coherent. Producers moving nodes between flushes are one frame
458
+ stale to picking, like every query.
459
+ - The queries are per-body answers - a hit test, a marquee, a blast
460
+ radius, one mover call per character - not a collision broadphase. A
461
+ bullets-vs-crowd test at frame rate is hundreds of core queries per
462
+ frame, and it is simulation logic anyway, which lives in plain
463
+ TypeScript with no layer in sight (core's "keep the simulation out of
464
+ the renderer"): the app's own arrays, a uniform grid over them,
465
+ runnable headless. Tile-layer collision (tiles are baked records, not
466
+ nodes) is a solid-cell grid query of its own, not the sprite index.
169
467
  - Records layer: record order is draw order: `removeSprite` shifts every
170
468
  later sprite down one slot (copyWithin + index fixup, O(later
171
469
  sprites)). Its flush publishes the WHOLE live prefix, not a dirty
@@ -173,24 +471,55 @@ is flat. Event x/y are layer pixels with the camera undone.
173
471
  memcpy, microseconds at 10k; the node layer's style publish is the same
174
472
  whole-prefix shape. Dirty ranges were deliberately not built until a
175
473
  measurement asks.
474
+ - `orderBy` on BOTH layers: the core gathers publishes into key order
475
+ (gpu `instanceOrder`, radix sort + one extra memcpy, no per-record JS),
476
+ so a y-sorted crowd costs the same flush as an unsorted one. Records
477
+ and slots stay stably addressed; ties keep slot order; `pick()` still
478
+ resolves overlap by slot/record order. Records layer keys: "y" or a raw
479
+ `{ field, descending? }` offset into the 13-float record. Node layer
480
+ keys: `"y"` - WORLD y from the core-written pose buffer, so sprites
481
+ moved by native transitions (or any core producer) re-sort with zero JS
482
+ per frame - or `"renderOrder"` - the app-owned per-sprite `renderOrder`
483
+ field (style record float 8, default 0; @solidrt/3d's name for the same
484
+ knob), the explicit-layering key for painter-order scenes: raise a
485
+ dragged piece with `setSprite(hit, { renderOrder: ++top })`, back to 0
486
+ to restore. Either way the core gathers
487
+ pose AND style under ONE permutation and republishes the sibling buffer
488
+ itself when the key buffer re-orders (the multi-buffer stage of
489
+ okf/backlog/gpu-instance-order.md). `renderOrder` on a record-layer
490
+ sprite throws - its 13-float record has no key field.
176
491
  - The node layer's STYLE slots are not compacted: a removed sprite leaves
177
492
  its style floats in place (invisible - the pose is zeroed) until the
178
493
  slot recycles. Do not read style truth from the buffer; getSprite reads
179
494
  the JS mirror.
180
495
  - `createImage` is the wrong loader for pixel-art atlases: it never forwards
181
- sampler options, so it is always `filter: "linear"`. `createAtlas` decodes
182
- bytes and passes `filter: "nearest"` through - use it, or `decodeImage` +
183
- `createTexture` directly.
496
+ sampler options, so it is always `filter: "linear"`. `createAtlas` takes
497
+ decoded pixels (`createAtlas(decodeImage(bytes), { filter: "nearest" })`,
498
+ or pixels built in code) and passes the full sampler through (`filter`,
499
+ `wrap`, `mipmap`, `anisotropy`); the record it returns is what `grid` and
500
+ `namedFrames` slice.
501
+ - Frames that share an edge do not bleed: the fragment stage clamps every
502
+ sample into its frame, so a sprite at a fractional position (a scrolling
503
+ backdrop, an easing camera) never paints a line of the neighbouring cell,
504
+ and an edge-to-edge sheet needs no gutter and no shaved rect table. The
505
+ one case left is a `mipmap` atlas, whose mip texels average across cell
506
+ edges before sampling: pass `inset` to `grid`/`namedFrames` (2^k texels
507
+ keeps mip level k clean, losing that border) or pack a gutter as
508
+ `spacing`, until extrusion lands (okf/backlog/2d-atlas-extrude.md).
184
509
  - Tint multiplies the sampled texel (`texture * tint`) and the pipeline
185
510
  blends with `blend: "alpha"` in record order, the premultiplied composite.
186
511
  The atlas is premultiplied because `decodeImage` premultiplies by default;
187
512
  an atlas uploaded from straight-alpha pixels (`decodeImage(bytes, { alpha:
188
- "straight" })` + `createTexture`) draws color under transparent texels
513
+ "straight" })` into `createAtlas`) draws color under transparent texels
189
514
  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.
515
+ - Every rotation must agree on direction (clockwise, y-down):
516
+ `pointInSprite` in pick.ts with the vertex stage's `iRot`, and
517
+ `projectCamera` in camera.ts with `uCameraRot` and `<TileLayer>`'s view
518
+ transform. The differential checks (pick-check.ts, camera-check.ts)
519
+ guard the JS math against oracles but NOT against the shader - if you
520
+ touch one rotation, touch all, then run examples/camera-probe.tsx (the
521
+ live guard: shader vs projectCamera, node/record parity, the pointer
522
+ round trip) and watch for CAMERA-OK.
194
523
  - Sprite handles go inert on removal (`sprite.layer === null`); setSprite on
195
524
  an inert handle is a silent no-op (matching the throw-in-dev policy would
196
525
  mean throwing, but removal racing a queued pointer event is routine, not
@@ -209,9 +538,14 @@ is flat. Event x/y are layer pixels with the camera undone.
209
538
  Both are LAYOUT components: neither can live inside a d-* subtree (the
210
539
  insert throws); for a detached parent, `<SpriteLayer output>` hands out
211
540
  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.
541
+ - `chunkClearColor` is PER CHUNK, as named: never-written regions have no
542
+ chunk and render nothing, so a full-bleed ground color belongs on the
543
+ container behind the layer (a `d-rect` under it), not here. The flip side is FREE
544
+ TRANSPARENCY: with the default `[0,0,0,0]` clear, a mostly-unwritten
545
+ tile layer stacked over another composites with no mask, no alpha pass,
546
+ no shader - a sparse upper world (floating clouds over a sea) just
547
+ works, and per-cell alpha in the written cells carries through the
548
+ chunk (transparent clear + alpha blend).
215
549
  - Two samplers, two jobs. The ATLAS sampler (createAtlas `filter`) decides
216
550
  whether texels are hard blocks ("nearest", pixel art) or smooth
217
551
  ("linear"). The LAYER's output sampler (the sprite layer's target, the
@@ -224,12 +558,24 @@ is flat. Event x/y are layer pixels with the camera undone.
224
558
  (nearest inside keeps blocks square), the linear composite spreads the
225
559
  fraction over one device pixel at block edges. The components pick n
226
560
  every layout from their leaf's window box (`getBoundingBoxViewport` x
227
- `displayScale()`, which composes designSize fits and camera zoom), within
561
+ `displayScale()`, which composes designSize fits and camera zoom; the
562
+ tile layer divides its camera rotation's AABB swell back out - rotation
563
+ is not a resolution factor - and shrinks only past a margin so an
564
+ oscillating measurement cannot re-bake in a loop), within
228
565
  a budget of the window's own device pixel count; the
229
566
  primitives default to 1 and take `{ oversample }` / `setOversample(n)`
230
567
  - 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.
568
+ yourself with the exported `fitOversample`. The window budget bounds one
569
+ TARGET, which never binds for chunk-sized tile targets: a tile world's
570
+ texture memory is resident chunks x n squared, and `maxOversample` is
571
+ the cap that bounds it (auto-pick only; a 2x display otherwise picks 16x
572
+ the memory of n = 1, silently). A layer whose oversample changes more
573
+ than a few times in a second warns in the console (thrash: every change
574
+ resizes and redraws the targets, a tile layer re-bakes every resident
575
+ chunk) - pin `oversample` or set `maxOversample` when an animated
576
+ transform or camera legitimately sweeps the scale. Never fix a shimmer by
577
+ snapping the fit to an integer: the scene should fill its box at any
578
+ ratio.
233
579
  - Retro scrolling habits are the app's, not the layer's: keep the camera
234
580
  fractional (rounding it to design pixels makes motion step at the
235
581
  rounding rate, not the frame rate), and a game that wants whole-pixel
@@ -242,8 +588,9 @@ is flat. Event x/y are layer pixels with the camera undone.
242
588
  default size) and every resident chunk keeps a composited leaf. Bounded
243
589
  worlds only; streaming/infinite is stage B2 in
244
590
  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.
591
+ - The sprite layer's camera rotation is IN-SHADER (uCameraRot) on
592
+ purpose: rotating the layer's OUTPUT leaf instead is wrong - the output
593
+ is viewport-sized with the camera already applied, so a leaf transform
594
+ spins the cropped viewport and the corners cut. The tile layer gets
595
+ away with the transform-on-the-leaf camera only because its composited
596
+ view is the WORLD, not a viewport of it.
package/README.md CHANGED
@@ -7,12 +7,12 @@ texture element.
7
7
  *Status: experimental. Expect API churn.*
8
8
 
9
9
  ```tsx
10
- import { render } from "@solidrt/core"
10
+ import { decodeImage, render } from "@solidrt/core"
11
11
  import { createAtlas, grid, Sprite, SpriteLayer } from "@solidrt/2d"
12
12
  import sheet from "./sheet.png" with { type: "binary" }
13
13
 
14
- let atlas = createAtlas(sheet, { filter: "nearest" })
15
- let frames = grid(4, 4, { width: atlas.width, height: atlas.height })
14
+ let atlas = createAtlas(decodeImage(sheet), { filter: "nearest" })
15
+ let frames = grid(atlas, 4, 4)
16
16
 
17
17
  render(() => (
18
18
  <window>