@solidrt/2d 0.0.56 → 0.0.58

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