@solidrt/2d 0.0.57 → 0.0.59

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,288 +10,425 @@ 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/:
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.
30
- - Node layer ownership split, two instance-buffer slots on one pipeline:
31
- slot 0 is the POSE buffer `[x, y, angle, sx, sy]` written ONLY by the
32
- core (each sprite node's Pose2D record sink; one coalesced buffer write
33
- per flush however many nodes moved), slot 1 the STYLE buffer
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.
37
- - Sprites hold FIXED instance slots: draw order is slot order, removal
38
- zeroes the pose (zero scale = nothing drawn) and recycles the slot to
39
- the next add. No painter's-insertion-order guarantee across removals;
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).
42
- - Growth (past `capacity`, doubling): pose sinks move in ONE core
43
- `retargetRecords` call (full republish next flush), style re-uploads,
44
- `setDraw({ instanceBuffers })` swaps both, old buffers destroyed.
45
- - Picking is the core index: `pick` raycasts [x, y, -1] along +z (exact
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.
76
- - Groups (`addGroup`/`<Group>`) are plain arena nodes (x, y, rotation,
77
- UNIFORM scale - a group is a frame, never a sprite size; sprite w/h
78
- lives in the sprite node's scale, which is why sprites cannot parent
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).
94
- - `flipX`/`flipY` mirror on the UV SIDE: the style/record write swaps
95
- u0/u1 (v0/v1), so w/h stay the drawn size, a scale transition never sees
96
- a flip, and picking is unchanged (the vertex stage still carries no
97
- flip). The flags live on the Sprite and re-apply to every later frame
98
- write; `getSprite` returns the frame un-mirrored plus the flags. Raw
99
- `records` writers swap u0/u1 themselves.
100
- - Mutations batch to a microtask: style lease publish + count setDraw +
101
- `spatial.flush()`. No mutation, no publish, no frame: a static layer
102
- costs zero, the same demand-gate story as the rest of the platform.
103
- - The records layer (`createRecordLayer`) keeps the old model whole: 13
104
- JS-owned floats per sprite `[cx, cy, w, h, u0, v0, u1, v1, rot, tint
105
- rgba]` (`FLOATS_PER_SPRITE`), draw order = insertion order (or key
106
- order with `orderBy` - see below), remove
107
- shifts, `layer.records` + `touch()` raw writes, JS pick walk. It is the
108
- escape hatch for motion only JS can compute at scale (measured 30k
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
113
- WHERE MOTION IS COMPUTED, not retained-vs-dynamic. The sprite functions
114
- (addSprite/setSprite/getSprite/removeSprite) work on both layer kinds;
115
- record sprites have `node: null` and no groups.
116
- - Layer space is pixels, top-left origin, y-down - the render tree's frame.
117
- The pipeline's clip space is y-down too (core gpu.ts pixel contract), so
118
- the vertex stage carries NO flip anywhere. Do not add one.
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.
252
- - Retargeted motion is NATIVE: `setSpriteTransition(sprite, { position:
253
- { duration: 700, bounce: 0.3 }, ... })` (or the `transition` prop) makes
254
- setSprite writes TARGETS the core animates toward - position/scale
255
- (w/h) on the shared spring/tween math, rotation along the quaternion
256
- geodesic (always the short arc; a spring keeps its velocity through
257
- retargets). JS costs one write per target CHANGE, zero per frame; the
258
- running tracks drive frame demand themselves, and settled sprites cost
259
- nothing (bench: 400 retargets ~4ms, once a second - vs ~5ms per FRAME
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
265
- component declares the transition after the first pose sync; the
266
- function face sets it after addSprite). Each natural settle calls the
267
- handle's `onTransitionEnd` (plain field, or the `<Sprite>`/`<Group>` prop)
268
- with `{ component }` - target-only, never on a cancel, snap or removal;
269
- the raw "spatialTransitionEnd" engine event (srt:events, node =
270
- sprite.node) stays for flux:spatial consumers. See examples/springs.tsx.
271
- - Frame-rate motion only JS can compute (physics, flocking) bypasses the
272
- declarative layer: `ref` the sprite, call `setSprite` from `onFrame` (a
273
- ~7us core transform write per moved sprite - fine to a few thousand;
274
- past that use the records layer). Signals carry structure and slow
275
- state - a `<Sprite x={sig()}>` re-running 60 times a second works but
276
- re-runs an effect per sprite per frame for nothing.
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.
55
+ ### Three faces
56
+
57
+ THREE faces, layered: the node-backed live layer (layer.ts:
58
+ `createSpriteLayer`/`addSprite`/`setSprite`/`destroySprite` 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
+ `setDrawBuffers({ buffers })` 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. `destroyGroup`
143
+ destroys the SUBTREE: child sprites and groups die with it (re-parent a
144
+ child out first to keep it), children first so each plays its own
145
+ `exit` and the group stays their frame until the last has settled. One
146
+ removal verb, the same word with the same meaning as @solidrt/3d's
147
+ `destroy`: a sprite cannot exist outside its layer, so there is no
148
+ detach here (3d's `remove`), and a sprite that should come back is
149
+ hidden (`visible: false`).
150
+ A handle's x/y are local to its group; `worldPosition(sprite | group)`
151
+ reads the layer-pixel position composed through every enclosing group
152
+ from the core's world matrix (@solidrt/3d's worldPosition).
153
+
154
+ ### Visibility
155
+
156
+ `visible` (@solidrt/3d's setVisible, in the setSprite/setGroup bag;
157
+ default true) is core node visibility: hidden, the pose slot zeroes
158
+ (nothing drawn) and pick/pickRect skip the node; a group's flag hides
159
+ its WHOLE subtree while each child keeps its own. Handle, slot, style
160
+ records and transition state stay, so showing restores the sprite as
161
+ it was. Node layer only - on a record sprite it throws (hide those by
162
+ zeroing w or h).
163
+
164
+ ### Flips
165
+
166
+ `flipX`/`flipY` mirror on the UV SIDE: the style/record write swaps
167
+ u0/u1 (v0/v1), so w/h stay the drawn size, a scale transition never sees
168
+ a flip, and picking is unchanged (the vertex stage still carries no
169
+ flip). The flags live on the Sprite and re-apply to every later frame
170
+ write; `getSprite` returns the frame un-mirrored plus the flags. Raw
171
+ `records` writers swap u0/u1 themselves.
172
+
173
+ ### Mutations batch to a microtask
174
+
175
+ Mutations batch to a microtask: style lease publish + count setDraw +
176
+ `spatial.flush()`. No mutation, no publish, no frame: a static layer
177
+ costs zero, the same demand-gate story as the rest of the platform.
178
+
179
+ ### The records layer
180
+
181
+ The records layer (`createRecordLayer`) keeps the old model whole: 13
182
+ JS-owned floats per sprite `[cx, cy, w, h, u0, v0, u1, v1, rot, tint
183
+ rgba]` (`FLOATS_PER_SPRITE`), draw order = insertion order (or key
184
+ order with `orderBy` - see below), remove
185
+ shifts, `layer.records` + `touch()` raw writes, JS pick walk. It is the
186
+ escape hatch for motion only JS can compute at scale (measured 30k
187
+ sprites: 12.9ms raw records vs 30.8ms via setSprite; both figures are
188
+ the WRITE path only - whatever computes the motion is excluded and is
189
+ usually the dominant cost, e.g. a 24k-particle sim measured ~25ms with
190
+ a near-free publish) - the axis is
191
+ WHERE MOTION IS COMPUTED, not retained-vs-dynamic. The sprite functions
192
+ (addSprite/setSprite/getSprite/destroySprite) work on both layer kinds;
193
+ record sprites have `node: null` and no groups.
194
+
195
+ ### Layer space
196
+
197
+ Layer space is pixels, top-left origin, y-down - the render tree's frame.
198
+ The pipeline's clip space is y-down too (core gpu.ts pixel contract), so
199
+ the vertex stage carries NO flip anywhere. Do not add one.
200
+
201
+ ### The camera
202
+
203
+ The camera (`view.setCamera`/the `camera` prop) is ONE `CameraUpdate`
204
+ type across both layer kinds' views: offset, zoom, and rotation about a
205
+ pivot (camera.ts documents the semantics and the heading-upward
206
+ convention, `rotation = -h - pi/2`). It is a shared-params write on
207
+ the view's target (`uCamera` + `uCameraRot`, the rotation in-shader),
208
+ one call however many sprites exist; `projectCamera`/`unprojectCamera`
209
+ export the world <-> screen mapping as pure functions, and pointer
210
+ dispatch undoes the view's camera with the latter, so events arrive in
211
+ world (layer) pixels. Picking itself works in world space and never
212
+ sees a camera. `view.camera()` reads the camera back (every field
213
+ present - the `CameraState` snapshot, @solidrt/3d's scene.camera())
214
+ and `view.project(x, y)` / `view.unproject(x, y)` are the two pure
215
+ functions over it.
216
+
217
+ ### Camera control
218
+
219
+ Camera control: `createCamera2d(view | views, { viewport: () =>
220
+ ({ width, height }), world?: { width, height }, min/maxZoom?, pivot?,
221
+ deadZone?: { width, height }, panSpeed?, zoomSpeed?, rollSpeed?,
222
+ damping?, followSpeed?, inertia?, x?, y?, zoom?, rotation? })` - Godot's Camera2D
223
+ and Three's MapControls in one control over the shared CameraUpdate:
224
+ pan with inertia on release, zoom about a point, eased `glideTo`/`fit`,
225
+ `follow(x, y)` through a dead zone with damping, roll about the pivot,
226
+ and world bounds that CONTAIN the view (an axis whose view is wider
227
+ than the world centers - so at the fit zoom a pan is a no-op, there is
228
+ nothing to pan; limits ignore rotation, Godot's rule). The pose is
229
+ "world point at the pivot", the pivot a viewport fraction defaulting to
230
+ the center, so `camera().x/y` is the view center and glideTo/follow
231
+ land there (without a `world`, the default pose puts world 0,0 at the
232
+ pivot: a fill layer that wants world = screen at rest takes `pivot: {
233
+ x: 0, y: 0 }`). The first argument is anything with a view's
234
+ `setCamera` (a view of a sprite layer, of a record layer, several at
235
+ once - one camera over a scene's layers - or a signal setter feeding
236
+ `<TileLayer camera>`).
237
+
238
+ #### Input and bindings
239
+
240
+ Input, the rule (ARCHITECTURE.md): the control consumes a device-free
241
+ abstraction and never handles events itself. Its `axes` (core's
242
+ createAxes contract) are `pan` (vec2: a delta is finger travel in
243
+ viewport heights, applied as content travel - the world follows the
244
+ finger; a rate slides at one viewport height per second), `zoom` (axis,
245
+ octaves, positive in: a delta bracketed by a gesture - a pinch - applies
246
+ at once about its focal point, an unbracketed one - a wheel notch -
247
+ retargets an eased glide, which is how the control tells a finger from
248
+ an impulse; notches compound on the pending target, and `damping` scales
249
+ the settle time - 0 applies a notch at once - the 3d controls' knob) and
250
+ `roll` (axis,
251
+ turns); a pan gesture's begin stops any glide (a finger landing on a
252
+ gliding view holds it) and its end flings with the drag's velocity. The
253
+ verbs, each pushing the pose at once: `panBy(dx, dy)` screen pixels,
254
+ `zoomAt(sx, sy, factor, { glide? })`, `rollBy(angle)`, `set(pose)`,
255
+ `glideTo`, `fit`, `follow`/`unfollow`, `interrupt`, `release`. An input
256
+ map (core AGENTS.md) drives the axes by name and the APP binds devices
257
+ to it: the view's pointer feed (`createPointerFeed()`, handed to
258
+ `<SpriteLayer pointer>` / `<View2d pointer>`, or bridged with
259
+ `feedPointer(view, feed)` for an imperative view - fed from the view's
260
+ ROOT, so a sprite that claims its press keeps the camera out of that
261
+ drag and a wheel anywhere zooms), a pad, the keyboard. Nothing binds
262
+ by default; `camera2dBindings({ pointer, gamepad?, keyboard? })` is the
263
+ standard set (drag and two fingers pan, pinch and wheel zoom, twist
264
+ rolls; the left stick and the arrows scroll the VIEW - bound through
265
+ invert(), since keys and sticks move the camera where a drag moves the
266
+ content - the triggers zoom, the right stick's x rolls) and
267
+ `camera2dActions` the declarations:
268
+
269
+ ```tsx
270
+ let pointer = createPointerFeed()
271
+ feedPointer(view, pointer)
272
+ let input = createInputMap(camera2dActions)
273
+ input.bind(camera2dBindings({ pointer, gamepad: gamepad() }))
274
+ input.drive(cam.axes)
275
+ ```
276
+
277
+ Call `cam.update(dt)` from a frame loop (it advances glides, follow,
278
+ inertia and the axis rates, pushes one setCamera per driven view when
279
+ the pose changed and reports that), gated on the reactive
280
+ `cam.active()` (true while a glide, fling, fit, follow or pan gesture
281
+ needs frames, or a rate drives; false at rest), read `cam.camera()` for
282
+ projectCamera and `cam.pose()` for the control's own four values (the
283
+ shape `set()` takes, the 3d controls' `pose()`). The camera has NO tap of its own: taps are the
284
+ dispatch's (`onTap` on the root with `e.sprite` null is "tap on empty
285
+ space"). `<Camera2d input={input}>` inside `<SpriteLayer>` is all of
286
+ that wired through context, the 3d `<OrbitCamera>` shape: options read
287
+ at mount, driving the nearest view (the `<SpriteLayer>`'s own or the
288
+ enclosing `<View2d>`), `viewport` defaulting to that view's size, the
289
+ map's `pan`/`zoom`/`roll` actions (or the names in `actions`) driving
290
+ the axes, frames only while `active()`. A view's feed normalizes a drag
291
+ by the leaf's own box, so a leaf under a designSize fit pans and zooms
292
+ correctly. camera2d.ts imports `@solidrt/core/input` only, so
293
+ checks/camera2d-check.ts pins the clamp, anchoring, glides, follow,
294
+ inertia and the axes headless; examples/camera.tsx (function face) and
295
+ examples/pick.tsx (`<Camera2d>`) are the live guards. The shared
296
+ vocabulary with @solidrt/3d, one kind and unit per word: `pan` vec2,
297
+ `zoom` axis (octaves), `roll` axis (turns) here; `rotate`, `look`,
298
+ `move`, `rise` there. The pose options are initial values; every other
299
+ option (world, zoom range, pivot, dead zone, the speeds, inertia) is
300
+ read where it applies, never snapshotted, so `<Camera2d>` props are
301
+ live - forwarded as getters, a bounds or pivot change re-clamping and
302
+ pushing the pose at once through `set({})` - and a function-face
303
+ caller mutates the options object it passed (the `<OrbitCamera>` rule,
304
+ pinned in the check's live-options case). Not yet, all additive:
305
+ rotation glides, a contain origin other than center.
306
+
307
+ ### Layer tint
308
+
309
+ Layer tint (`setTint(rgba)`/the `tint` option and prop, both layer
310
+ kinds): one `uTint` shared-params write multiplied over every sprite's
311
+ own tint - day/night, a dimmed parallax plane, a fade-in. Cheap to
312
+ animate (no record touches), unlike TileLayer's, which re-renders
313
+ resident chunks. The same contract as TileLayer.setTint, so one signal
314
+ drives a whole scene across layer kinds.
315
+
316
+ ### Views
317
+
318
+ Views (`layer.createView({ width, height, camera?, oversample?,
319
+ clearColor?, label? })`, both layer kinds): the ONLY output a layer
320
+ has - the window-filling main view, a minimap, a radar strip, a zoomed
321
+ inset, two split-screen panes - @solidrt/3d's scene.createView one
322
+ dimension down, as Unity renders a scene only through Cameras and
323
+ Godot a World2D only through Viewports. Each view is one draw target
324
+ holding one entry over the layer's pipeline and instance buffers
325
+ (views.ts): no sprite is mirrored, no record is written twice, and key
326
+ order (`orderBy`) comes along, since the core gathers the buffers
327
+ themselves at publish - ONE live entry declares the order (the first
328
+ view's; when that view is disposed the next live view re-adds its
329
+ entry ordered), every other entry reads them sorted. Growth, the
330
+ instance count and the layer tint fan out to every view; the camera
331
+ and the viewport are the view's own params, so a view costs no
332
+ per-frame JS beyond its camera writes. The `ViewHandle` is the
333
+ viewport contract (texture, width/height/setSize,
334
+ oversample/setOversample, setCamera/camera/project/unproject,
335
+ listen/handlers/handlersFor, dispose - views also die with the layer);
336
+ the sprites stay the layer's (`addSprite`, `pick`, `setTint`,
337
+ `createView`, `dispose`). Pointer events on a view leaf run the
338
+ dispatch with the VIEW's camera undone over the layer's pick, the view
339
+ as the root of the walk: a sprite under a minimap gets its ordinary
340
+ handlers, `view.listen` is the last stop (a tap there with `e.x`/`e.y`
341
+ in world pixels is "glide the main camera here"), and
342
+ a camera bound to a feed `feedPointer(view, feed)` bridges drives any
343
+ view. `<View2d>` is the
344
+ component form; the `<SpriteLayer>`'s built-in leaf is a view too, and
345
+ `<SpriteLayer output={false}>` has none, showing only through its
346
+ `<View2d>` children (examples/split-screen.tsx). examples/views.tsx is
347
+ the live guard. Not yet, all additive: tile-layer views, a `layers`
348
+ bitmask (a markers-only minimap), `into` tiling, per-view tint, and a
349
+ viewport over SEVERAL layers under one camera (their scene-level
350
+ camera) - okf/backlog/2d-layer-views-additive.md.
351
+
352
+ ### Retargeted motion
353
+
354
+ Retargeted motion is NATIVE: `setSpriteTransition(sprite, { position:
355
+ { duration: 700, bounce: 0.3 }, ... })` (or the `transition` prop) makes
356
+ setSprite writes TARGETS the core animates toward - position/scale
357
+ (w/h) on the shared spring/tween math, rotation along the quaternion
358
+ geodesic (always the short arc; a spring keeps its velocity through
359
+ retargets). JS costs one write per target CHANGE, zero per frame; the
360
+ running tracks drive frame demand themselves, and settled sprites cost
361
+ nothing (bench: 400 retargets ~4ms, once a second - vs ~5ms per FRAME
362
+ moving the same population imperatively). Retargeting every frame is
363
+ also a legitimate pattern, not an abuse: rewriting a spring's TARGET
364
+ each frame to chase a moving point (a follow-camera trailing a moving
365
+ sprite) rides the spring's smoothing for free - a spring keeps its
366
+ velocity through retargets, so the chase stays fluid. A mount pose snaps
367
+ unless a component's `from` (2d units: `position: { from: [x, y] }`,
368
+ `rotation: { from: angle }`, `scale: { from: [w, h] }`, a group's
369
+ `from: s`) animates the sprite in from there - once, and only when the
370
+ declaration lands in the tick that added the sprite (the `<Sprite>` prop
371
+ or a setSpriteTransition right after addSprite; a later one animates
372
+ writes only). A component's `exit` (same units) is where it animates to
373
+ when destroySprite/destroyGroup lets go of it: the sprite stays drawn
374
+ while it leaves and is a GHOST meanwhile - no pick, raycast, overlap,
375
+ sweep or pointer event sees it - then frees when the last exit settles
376
+ (a bullet never hits a corpse; an entering sprite is live from its
377
+ first frame, whatever `from` it passes through). Either endpoint takes
378
+ the object form `{ value, duration?, curve?, bounce?, delay? }` to own
379
+ its direction's motion (an ease-out enter, an ease-in exit), and `delay`
380
+ on an entry holds its writes that long on the animation clock (a late
381
+ frame catches up, so a hitch never shifts a held start). `stagger` (ms)
382
+ goes on a GROUP's declaration (`setGroupTransition` / `<Group
383
+ transition>`), never on the sprites: it spaces the enters and exits of
384
+ everything under the group that begin in one frame by `index * stagger`
385
+ (add order in, tree order out whatever the destroy order; nearest
386
+ declaring group wins), and
387
+ cascades nothing unless the sprites declare `from`/`exit` - a hand of
388
+ cards fanning in, a menu popping in, is one `stagger` on the group. For
389
+ the sprites straight under the layer (no group) the layer's root carries
390
+ it: `createSpriteLayer(texture, { stagger })` / `layer.setStagger(ms)`.
391
+ Each natural settle calls the handle's `onTransitionEnd` (plain field,
392
+ or the `<Sprite>`/`<Group>` prop) with `{ component }` - target-only,
393
+ never on a cancel, snap or exit; the raw "spatialTransitionEnd" engine
394
+ event (srt:events, node = sprite.node) stays for flux:spatial consumers.
395
+ See examples/springs.tsx (tap a sprite: it leaves through its `exit`).
396
+
397
+ ### Frame-rate motion
398
+
399
+ Frame-rate motion only JS can compute (physics, flocking) bypasses the
400
+ declarative layer: `ref` the sprite, call `setSprite` from `onFrame` (a
401
+ ~7us core transform write per moved sprite - fine to a few thousand;
402
+ past that use the records layer). Signals carry structure and slow
403
+ state - a `<Sprite x={sig()}>` re-running 60 times a second works but
404
+ re-runs an effect per sprite per frame for nothing.
405
+
406
+ ### Frame animation
407
+
408
+ Frame animation (animation.ts): `createAnimation(frames, fps, { loop })`
409
+ is a clip with a shared wall-clock timer stepping every attached sprite
410
+ (`anim.add(sprite)` / `remove`; `play`/`pause`; `frame()`/`playing()`
411
+ read the clock, methods like the 3d mixer's `playing()`; `loop: false`
412
+ holds the last frame and fires `onFinish`, the mixer's name for it in
413
+ @solidrt/3d). One timer per playing clip, one setSprite
414
+ per sprite per STEP - an 8fps cycle is 8 publishes a second regardless of
415
+ display rate, and a paused clip costs nothing. A sprite belongs to at
416
+ most one animation (add detaches the previous); removed sprites prune
417
+ lazily on the next step. Works on both layer kinds; with `<Sprite>`,
418
+ attach via `ref` and leave the `frame` prop off - the clip owns that
419
+ field (the prop effect passes absent props as undefined, which setSprite
420
+ keeps, so other props stay reactive).
421
+
422
+ ### Pure pieces
423
+
424
+ frames.ts, pick.ts, camera.ts, camera-motion.ts and dispatch.ts are pure
425
+ (no GPU imports) BY DESIGN so they can be checked headless; keep them
426
+ that way.
292
427
 
293
428
  ## The baked tile layer (tiles.ts)
294
429
 
430
+ ### Chunks
431
+
295
432
  Static 2D bulk as a few quads: on tiled GPUs the budget is primitive count
296
433
  (core agents/performance.md), so a 100x100 tile world must not be 10,000
297
434
  quads per frame. `createTileLayer(cols, rows, tileW, tileH, atlas)` bakes
@@ -307,6 +444,8 @@ memory, not `maxTextureSize`. `setTile` batches to a microtask whose flush
307
444
  publishes and re-bakes ONLY dirty chunks. After that the layer is static
308
445
  textures: zero per-frame cost however many tiles exist.
309
446
 
447
+ ### Scrolling and the camera
448
+
310
449
  Scrolling never re-bakes: the `<TileLayer>` camera prop (`TileCamera`, an
311
450
  alias of the shared `CameraUpdate`) is a transform on the composited world
312
451
  view - the world point (x, y) pinned to the viewport point (pivotX,
@@ -321,6 +460,8 @@ empty chunks cost nothing). Tiles are data, not
321
460
  children: there is no `<Tile>` component on purpose - write cells through
322
461
  `ref` with `setTile`.
323
462
 
463
+ ### Bulk writes
464
+
324
465
  Bulk writes: `setTiles(col, row, cols, rows, cells)` writes a rect at
325
466
  once - `cells` row-major, `cols * rows` long, either frames (null clears)
326
467
  or, with a `frames` table given at creation (`createTileLayer(..., {
@@ -339,10 +480,9 @@ creation. The rect form is the shape for worker transfer and chunk
339
480
  re-fill, not a large speedup: the per-cell floor is the record write
340
481
  itself, so a 16k-cell solid rect re-writes in ~14 ms against ~25 ms as a
341
482
  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.
483
+ seed is dominated by chunk allocation at 1-2 ms per chunk.
484
+
485
+ ### Tinting
346
486
 
347
487
  Tinting, two levels: `setTile(col, row, frame, { tint })` writes the
348
488
  cell's record tint (same `[r, g, b, a]` 0..1 semantics as a sprite's
@@ -357,6 +497,8 @@ on approach, evict) - okf/backlog/2d-baked-layers.md.
357
497
 
358
498
  ## Components
359
499
 
500
+ ### Component props
501
+
360
502
  | Component | Props |
361
503
  |---|---|
362
504
  | `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) |
@@ -366,9 +508,13 @@ on approach, evict) - okf/backlog/2d-baked-layers.md.
366
508
  | `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
509
  | `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
510
 
511
+ ### SpriteLayer and useSpriteLayer
512
+
369
513
  `SpriteLayer` owns the layer and its own view, rendered as the built-in
370
514
  `<texture>` leaf carrying the view's pointer handlers (opt out with
371
- `events={false}`; compose yourself with `output`, then spread
515
+ `events={false}`, which throws at mount together with a `pointer` feed -
516
+ the feed would listen at a root no event reaches; the same on `<View2d>`;
517
+ compose yourself with `output`, then spread
372
518
  `useSpriteLayer().viewport.handlers` onto your leaf; `output={false}` for
373
519
  no own view at all). `useSpriteLayer()` returns `{ layer, parent,
374
520
  viewport, pointer }` - the same shape as `useScene()` in `@solidrt/3d` -
@@ -389,6 +535,8 @@ nothing - it allocates a record through context and syncs props into it.
389
535
  optional parent needs a non-undefined default, since Solid 2 throws on a
390
536
  resolved `undefined` even when one was passed as the default.
391
537
 
538
+ ### Pointer events
539
+
392
540
  Pointer events (dispatch.ts, both layer kinds): the DOM event model one
393
541
  tree deeper, with the VIEW as the root of the walk. Exact rotated-rect
394
542
  containment, topmost sprite first; down/move/up/wheel dispatch on the hit
@@ -426,9 +574,64 @@ hover, wheel and tap rules headless.
426
574
 
427
575
  ## Traps
428
576
 
577
+ ### Atlases and sampling
578
+
429
579
  - The atlas is NOT owned by the layer: layers come and go, atlases usually
430
580
  live app-long. Dispose atlases yourself (or let the reactive owner do it -
431
581
  createAtlas registers with the owning scope like every core texture).
582
+ - `createImage` is the wrong loader for pixel-art atlases: it never forwards
583
+ sampler options, so it is always `filter: "linear"`. `createAtlas` takes
584
+ decoded pixels (`createAtlas(decodeImage(bytes), { filter: "nearest" })`,
585
+ or pixels built in code) and passes the full sampler through (`filter`,
586
+ `wrap`, `mipmap`, `anisotropy`); the record it returns is what `grid` and
587
+ `namedFrames` slice.
588
+ - Frames that share an edge do not bleed: the fragment stage clamps every
589
+ sample into its frame, so a sprite at a fractional position (a scrolling
590
+ backdrop, an easing camera) never paints a line of the neighbouring cell,
591
+ and an edge-to-edge sheet needs no gutter and no shaved rect table. The
592
+ one case left is a `mipmap` atlas, whose mip texels average across cell
593
+ edges before sampling: pass `inset` to `grid`/`namedFrames` (2^k texels
594
+ keeps mip level k clean, losing that border) or pack a gutter as
595
+ `spacing`, until extrusion lands (okf/backlog/2d-atlas-extrude.md).
596
+ - Tint multiplies the sampled texel (`texture * tint`) and the pipeline
597
+ blends with `blend: "alpha"` in record order, the premultiplied composite.
598
+ The atlas is premultiplied because `decodeImage` premultiplies by default;
599
+ an atlas uploaded from straight-alpha pixels (`decodeImage(bytes, { alpha:
600
+ "straight" })` into `createAtlas`) draws color under transparent texels
601
+ as opaque - the classic "keyed-out backdrop becomes a wash" symptom.
602
+ - Two samplers, two jobs. The ATLAS sampler (createAtlas `filter`) decides
603
+ whether texels are hard blocks ("nearest", pixel art) or smooth
604
+ ("linear"). The LAYER's output sampler (the sprite layer's target, the
605
+ tile layer's `filter` option, default "linear") does the composite
606
+ resample to the box, and stays linear: "nearest" there snaps texels to
607
+ uneven widths at any fractional scale (a 3.6x designSize fit draws
608
+ source pixels 3 or 4 device pixels wide - shimmer standing still, boil
609
+ when scrolling). Proper resampling at a fractional or HiDPI scale is the
610
+ `oversample` factor: the layer renders at n texels per layer pixel
611
+ (nearest inside keeps blocks square), the linear composite spreads the
612
+ fraction over one device pixel at block edges. The components pick n
613
+ every layout from their leaf's window box (`getBoundingBoxViewport` x
614
+ `displayScale()`, which composes designSize fits and camera zoom; the
615
+ tile layer divides its camera rotation's AABB swell back out - rotation
616
+ is not a resolution factor - and shrinks only past a margin so an
617
+ oscillating measurement cannot re-bake in a loop), within
618
+ a budget of the window's own device pixel count; the
619
+ primitives default to 1 and take `{ oversample }` / `setOversample(n)`
620
+ - with `output` on `<SpriteLayer>` there is no built-in leaf, so set it
621
+ yourself with the exported `fitOversample`. The window budget bounds one
622
+ TARGET, which never binds for chunk-sized tile targets: a tile world's
623
+ texture memory is resident chunks x n squared, and `maxOversample` is
624
+ the cap that bounds it (auto-pick only; a 2x display otherwise picks 16x
625
+ the memory of n = 1, silently). A layer whose oversample changes more
626
+ than a few times in a second warns in the console (thrash: every change
627
+ resizes and redraws the targets, a tile layer re-bakes every resident
628
+ chunk) - pin `oversample` or set `maxOversample` when an animated
629
+ transform or camera legitimately sweeps the scale. Never fix a shimmer by
630
+ snapping the fit to an integer: the scene should fill its box at any
631
+ ratio.
632
+
633
+ ### Slots, capacity and order
634
+
432
635
  - `capacity` is a reservation, not a limit, on both layer kinds; reserve
433
636
  realistically to skip the growth copies. The pool idiom that fits a
434
637
  game: reserve `capacity`, add every sprite at mount with `visible:
@@ -440,31 +643,7 @@ hover, wheel and tap rules headless.
440
643
  a hoisted reference becomes a dead copy whose writes publish nothing.
441
644
  `layer.withRecords(fn)` is the hoist-proof read; a bare `layer.records`
442
645
  at use time is equally live.
443
- - Node layer: `sprite.node` is public FOR BINDING PRODUCERS, not for
444
- lifecycle - never destroyNode it yourself (removeSprite owns that), and
445
- a transform written through flux:spatial directly bypasses the sprite's
446
- pose mirror, so a later setSprite with the old x wins (its compare sees
447
- no change to skip, but partial writes compose from the mirror).
448
- - With a transition set, the sprite's fields (and getSprite) read the
449
- TARGET, not the mid-flight pose - the JS mirror is what setSprite
450
- composes partial writes from, and targets are the right thing to
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
453
- mid-flight pose on the node while the mirror still holds the old
454
- target: the next setSprite write snaps to whatever it says.
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.
467
- - Records layer: record order is draw order: `removeSprite` shifts every
646
+ - Records layer: record order is draw order: `destroySprite` shifts every
468
647
  later sprite down one slot (copyWithin + index fixup, O(later
469
648
  sprites)). Its flush publishes the WHOLE live prefix, not a dirty
470
649
  range: one moved sprite re-publishes count x 52 bytes - a single
@@ -492,26 +671,43 @@ hover, wheel and tap rules headless.
492
671
  its style floats in place (invisible - the pose is zeroed) until the
493
672
  slot recycles. Do not read style truth from the buffer; getSprite reads
494
673
  the JS mirror.
495
- - `createImage` is the wrong loader for pixel-art atlases: it never forwards
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).
509
- - Tint multiplies the sampled texel (`texture * tint`) and the pipeline
510
- blends with `blend: "alpha"` in record order, the premultiplied composite.
511
- The atlas is premultiplied because `decodeImage` premultiplies by default;
512
- an atlas uploaded from straight-alpha pixels (`decodeImage(bytes, { alpha:
513
- "straight" })` into `createAtlas`) draws color under transparent texels
514
- as opaque - the classic "keyed-out backdrop becomes a wash" symptom.
674
+ - Sprite handles go inert on removal (`sprite.layer === null`); setSprite on
675
+ an inert handle is a silent no-op (matching the throw-in-dev policy would
676
+ mean throwing, but removal racing a queued pointer event is routine, not
677
+ a bug).
678
+ - The `<Sprite>` effect syncs ALL seven fields when ANY prop changes (one
679
+ effect, one tuple). Fine at component scale; if a profile ever blames it,
680
+ split the effects before inventing anything cleverer.
681
+
682
+ ### Nodes, transitions and queries
683
+
684
+ - Node layer: `sprite.node` is public FOR BINDING PRODUCERS, not for
685
+ lifecycle - never destroyNode it yourself (destroySprite owns that), and
686
+ a transform written through flux:spatial directly bypasses the sprite's
687
+ pose mirror, so a later setSprite with the old x wins (its compare sees
688
+ no change to skip, but partial writes compose from the mirror).
689
+ - With a transition set, the sprite's fields (and getSprite) read the
690
+ TARGET, not the mid-flight pose - the JS mirror is what setSprite
691
+ composes partial writes from, and targets are the right thing to
692
+ compose. Picking, the pose buffer and `worldPosition` see the actual
693
+ mid-flight pose (what is on screen). Clearing the transition (null) keeps the
694
+ mid-flight pose on the node while the mirror still holds the old
695
+ target: the next setSprite write snaps to whatever it says.
696
+ - Node layer picking reads the index as of the last core flush; every
697
+ query runs the layer's pending batch first, so write-then-query in one
698
+ tick is coherent. Producers moving nodes between flushes are one frame
699
+ stale to picking, like every query.
700
+ - The queries are per-body answers - a hit test, a marquee, a blast
701
+ radius, one mover call per character - not a collision broadphase. A
702
+ bullets-vs-crowd test at frame rate is hundreds of core queries per
703
+ frame, and it is simulation logic anyway, which lives in plain
704
+ TypeScript with no layer in sight (core's "keep the simulation out of
705
+ the renderer"): the app's own arrays, a uniform grid over them,
706
+ runnable headless. Tile-layer collision (tiles are baked records, not
707
+ nodes) is a solid-cell grid query of its own, not the sprite index.
708
+
709
+ ### Cameras and rotation
710
+
515
711
  - Every rotation must agree on direction (clockwise, y-down):
516
712
  `pointInSprite` in pick.ts with the vertex stage's `iRot`, and
517
713
  `projectCamera` in camera.ts with `uCameraRot` and `<TileLayer>`'s view
@@ -520,13 +716,19 @@ hover, wheel and tap rules headless.
520
716
  touch one rotation, touch all, then run examples/camera-probe.tsx (the
521
717
  live guard: shader vs projectCamera, node/record parity, the pointer
522
718
  round trip) and watch for CAMERA-OK.
523
- - Sprite handles go inert on removal (`sprite.layer === null`); setSprite on
524
- an inert handle is a silent no-op (matching the throw-in-dev policy would
525
- mean throwing, but removal racing a queued pointer event is routine, not
526
- a bug).
527
- - The `<Sprite>` effect syncs ALL seven fields when ANY prop changes (one
528
- effect, one tuple). Fine at component scale; if a profile ever blames it,
529
- split the effects before inventing anything cleverer.
719
+ - Retro scrolling habits are the app's, not the layer's: keep the camera
720
+ fractional (rounding it to design pixels makes motion step at the
721
+ rounding rate, not the frame rate), and a game that wants whole-pixel
722
+ scrolling snaps its own camera.
723
+ - The sprite layer's camera rotation is IN-SHADER (uCameraRot) on
724
+ purpose: rotating the layer's OUTPUT leaf instead is wrong - the output
725
+ is viewport-sized with the camera already applied, so a leaf transform
726
+ spins the cropped viewport and the corners cut. The tile layer gets
727
+ away with the transform-on-the-leaf camera only because its composited
728
+ view is the WORLD, not a viewport of it.
729
+
730
+ ### Tile layer
731
+
530
732
  - `<TileLayer>`'s world view is WORLD sized (cols * tileW) and takes that
531
733
  much layout space: put it inside a clipping container (`overflow="clip"`)
532
734
  sized to the viewport, or camera panning shows the world hanging out of
@@ -546,40 +748,6 @@ hover, wheel and tap rules headless.
546
748
  no shader - a sparse upper world (floating clouds over a sea) just
547
749
  works, and per-cell alpha in the written cells carries through the
548
750
  chunk (transparent clear + alpha blend).
549
- - Two samplers, two jobs. The ATLAS sampler (createAtlas `filter`) decides
550
- whether texels are hard blocks ("nearest", pixel art) or smooth
551
- ("linear"). The LAYER's output sampler (the sprite layer's target, the
552
- tile layer's `filter` option, default "linear") does the composite
553
- resample to the box, and stays linear: "nearest" there snaps texels to
554
- uneven widths at any fractional scale (a 3.6x designSize fit draws
555
- source pixels 3 or 4 device pixels wide - shimmer standing still, boil
556
- when scrolling). Proper resampling at a fractional or HiDPI scale is the
557
- `oversample` factor: the layer renders at n texels per layer pixel
558
- (nearest inside keeps blocks square), the linear composite spreads the
559
- fraction over one device pixel at block edges. The components pick n
560
- every layout from their leaf's window box (`getBoundingBoxViewport` x
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
565
- a budget of the window's own device pixel count; the
566
- primitives default to 1 and take `{ oversample }` / `setOversample(n)`
567
- - with `output` on `<SpriteLayer>` there is no built-in leaf, so set it
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.
579
- - Retro scrolling habits are the app's, not the layer's: keep the camera
580
- fractional (rounding it to design pixels makes motion step at the
581
- rounding rate, not the frame rate), and a game that wants whole-pixel
582
- scrolling snaps its own camera.
583
751
  - A dirty chunk flush publishes and re-bakes that chunk in full. Fine on
584
752
  change-only cadence; per-frame setTile churn re-bakes chunks per frame -
585
753
  that is sprite-layer work, not tile work.
@@ -588,9 +756,3 @@ hover, wheel and tap rules headless.
588
756
  default size) and every resident chunk keeps a composited leaf. Bounded
589
757
  worlds only; streaming/infinite is stage B2 in
590
758
  okf/backlog/2d-baked-layers.md.
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.