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