@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 +536 -374
- package/examples/anim.tsx +3 -3
- package/examples/parity.tsx +4 -4
- package/examples/springs.tsx +29 -7
- package/package.json +3 -3
- package/src/animation.ts +5 -9
- package/src/camera2d.ts +20 -3
- package/src/components/group.tsx +6 -6
- package/src/components/sprite-layer.tsx +8 -0
- package/src/components/sprite.tsx +6 -6
- package/src/components/view2d.tsx +8 -0
- package/src/dispatch.ts +2 -2
- package/src/index.ts +6 -3
- package/src/layer.ts +231 -52
- package/src/records.ts +6 -6
- package/src/shaders.ts +22 -18
- package/src/tiles.ts +4 -4
- package/src/views.ts +13 -13
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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}
|
|
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
|
-
-
|
|
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
|
-
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
- The
|
|
528
|
-
|
|
529
|
-
|
|
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.
|