@solidrt/2d 0.0.67 → 0.0.68

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
@@ -1,7 +1,8 @@
1
1
  # @solidrt/2d - agent notes
2
2
 
3
- An instanced sprite layer above `@solidrt/core/gpu`: one atlas texture, N
4
- quads in ONE draw call, composited into the app as an ordinary `<texture>`
3
+ An instanced sprite layer above `@solidrt/core/gpu`: a layer's atlases
4
+ bound together, N quads from any of them in ONE draw call, composited into
5
+ the app as an ordinary `<texture>`
5
6
  leaf. The live layer backs every sprite with a SPATIAL ARENA node (never a
6
7
  rendertree element - `d-texture` sprites are the right tool up to the low
7
8
  thousands; measured ~0.65us paint and ~15KB memory per NODE, and every
@@ -15,6 +16,7 @@ Contents:
15
16
  - [The model](#the-model)
16
17
  - [Three faces](#three-faces)
17
18
  - [A layer renders through its views](#a-layer-renders-through-its-views)
19
+ - [Atlases and frames](#atlases-and-frames)
18
20
  - [Pose and style slots](#pose-and-style-slots)
19
21
  - [Fixed instance slots](#fixed-instance-slots)
20
22
  - [Growth](#growth)
@@ -23,7 +25,7 @@ Contents:
23
25
  - [Groups](#groups)
24
26
  - [Visibility](#visibility)
25
27
  - [Flips](#flips)
26
- - [Mutations batch to a microtask](#mutations-batch-to-a-microtask)
28
+ - [Mutations batch to the frame](#mutations-batch-to-the-frame)
27
29
  - [The records layer](#the-records-layer)
28
30
  - [Layer space](#layer-space)
29
31
  - [The camera](#the-camera)
@@ -33,6 +35,7 @@ Contents:
33
35
  - [Retargeted motion](#retargeted-motion)
34
36
  - [Frame-rate motion](#frame-rate-motion)
35
37
  - [Frame animation](#frame-animation)
38
+ - [Text runs](#text-runs)
36
39
  - [Pure pieces](#pure-pieces)
37
40
  - [The baked tile layer (tiles.ts)](#the-baked-tile-layer-tilests)
38
41
  - [Chunks](#chunks)
@@ -48,6 +51,7 @@ Contents:
48
51
  - [Slots, capacity and order](#slots-capacity-and-order)
49
52
  - [Nodes, transitions and queries](#nodes-transitions-and-queries)
50
53
  - [Cameras and rotation](#cameras-and-rotation)
54
+ - [Text runs](#text-runs-1)
51
55
  - [Tile layer](#tile-layer)
52
56
 
53
57
  ## The model
@@ -73,15 +77,49 @@ Godot World2D only through Viewports, and there is no privileged first
73
77
  one. The window view, the minimap, two split-screen panes are the same
74
78
  `ViewHandle`. Details under Views below.
75
79
 
80
+ ### Atlases and frames
81
+
82
+ A layer draws from the ATLASES it declares at creation
83
+ (`createSpriteLayer([a, b], ...)`, `<SpriteLayer atlases={[a, b]}>`, the
84
+ record and tile layers alike): `createAtlas` records, or
85
+ `{ texture, width, height }` literals for a render target or a camera
86
+ frame, up to `limits.maxTextureUnits` of them (16 on every GLES 3.0
87
+ device), of any sizes and sampler states. They bind together as ONE
88
+ draw per view - the multi-texture batch of PixiJS and Phaser, where
89
+ Unity and Godot break the batch on every texture change - so sprites
90
+ from several sheets interleave freely in draw order, key order
91
+ included, and a tile world bakes from several tilesets in one pass. A
92
+ `Frame` carries its texture (`{ texture, u0, v0, u1, v1 }`, stamped by
93
+ `grid`/`namedFrames`/`fullFrame`; PixiJS's Texture, Phaser's Frame,
94
+ Unity's Sprite and Godot's AtlasTexture pair the texture with the rect
95
+ the same way), the record stores that texture's index in the layer's
96
+ list (`layer.atlases`, what a raw record writer stores), and the
97
+ fragment stage is GENERATED per layer with one sampler per atlas, the
98
+ flat per-instance index picking it: derivatives are taken from the
99
+ unclamped uv before the branch and the tap is `textureGrad`, so mip
100
+ selection is the quad's own and well defined inside the branch. A frame
101
+ whose texture the layer did not declare THROWS at the write (the
102
+ wrong-sheet bug, caught instead of drawn); the frame left out of
103
+ `addSprite` is the whole FIRST atlas. An atlas declared `sdf: { range }`
104
+ (a sprite font's msdf atlas, or any distance field you upload) is
105
+ decoded in its branch instead of sampled as colour: the median of rgb is
106
+ the edge, anti-aliased over one screen pixel at whatever zoom, the true
107
+ field in alpha grows a per-sprite outline (text runs set it; a colour
108
+ atlas ignores the field). Fixed at creation like the list itself.
109
+
76
110
  ### Pose and style slots
77
111
 
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.
112
+ Node layer ownership split, two instance-buffer slots on one pipeline
113
+ (the layer's, blended with its `blend` option, "alpha" unless it says
114
+ "add", "multiply" or "none" at creation - two blend modes are two
115
+ layers): slot 0 is the POSE buffer `[x, y, angle, sx, sy]` written ONLY
116
+ by the core (each sprite node's Pose2D record sink; one coalesced buffer
117
+ write per flush however many nodes moved), slot 1 the STYLE buffer
118
+ `[u0, v0, u1, v1, tint rgba, renderOrder, minScreenPx, maxScreenPx,
119
+ atlas, outline rgb, outlineWidth]` (16 floats), JS-owned, published
120
+ through the zero-copy write lease. NEVER
121
+ write the pose buffer from JS - the core's staging mirror owns it and
122
+ will overwrite.
85
123
 
86
124
  ### Fixed instance slots
87
125
 
@@ -108,6 +146,30 @@ unordered. Every query passes the core a filter with the layer's root
108
146
  node (sprites and groups without a parent hang off it), so the arena
109
147
  being shared with e.g. a 3d scene costs nothing in JS.
110
148
 
149
+ The screen-size clamp bends this one way. A sprite with `minScreenPx`
150
+ and/or `maxScreenPx` draws its smaller axis within those VIEW pixels
151
+ (scaled uniformly, in the vertex stage over the core-written pose, so
152
+ after group scale and mid-transition; core's `screenSizeScale`, the
153
+ same function @solidrt/3d's sprite material uses), which differs from
154
+ its index column once the zoom carries it past a bound - larger under
155
+ a floor, smaller under a ceiling; equal bounds hold a constant screen
156
+ size. So `pick(x, y, zoom)` takes the camera zoom the clamps are
157
+ measured at (default 1): while no sprite on the layer is clamped it is
158
+ the raycast above; once one is, the candidates are the columns
159
+ containing the point (the ray) plus, when a floor is on, those within
160
+ the widest floor's reach of it (`floorReach` in pick.ts over the
161
+ clamped sprites, recomputed on the next pick after one leaves or
162
+ shrinks; the box overlap is a SURFACE contact in the core, so a box
163
+ inside a big column finds nothing, which is why the ray stays in the
164
+ union), and each is tested exactly against its drawn rect from the
165
+ node's world matrix (`screenSizeScale` + `pointInSprite`). The pointer
166
+ walk and `view.pick(x, y)` pass the view camera's zoom; a bare
167
+ `layer.pick(x, y)` is the unzoomed world. `pickRect`, overlap, sweep and
168
+ the mover stay world-space body queries and never see a clamp. The
169
+ record layer's JS walk applies the same scale per record. The pair is
170
+ validated against the STORED bounds before any write (a ceiling set
171
+ alone may not drop below the floor already there).
172
+
111
173
  ### Spatial queries
112
174
 
113
175
  Spatial queries (`tests/collision.test.tsx` pins the contract): the
@@ -119,7 +181,8 @@ every sprite a volume touches with its deepest contact `{ sprite,
119
181
  point, normal, depth }`, unordered; `layer.sweep(volume, dx, dy,
120
182
  opts?)` every sprite the moving volume first touches `{ sprite, time,
121
183
  point, normal }`, earliest first; `layer.moveAndSlide(volume, dx, dy,
122
- opts?)` the character mover over them, one core call per body per
184
+ opts?)` (and the free `moveAndSlide(layer, ...)` spelling of it, as
185
+ @solidrt/3d's) the character mover over them, one core call per body per
123
186
  frame (the depenetration, slide and floor-snap loop runs in the spatial
124
187
  core, the same one @solidrt/3d's mover uses), returning `{ motion,
125
188
  floor, wall, ceiling, hits }` with `up` defaulting to [0, -1] (y-down).
@@ -170,19 +233,41 @@ flip). The flags live on the Sprite and re-apply to every later frame
170
233
  write; `getSprite` returns the frame un-mirrored plus the flags. Raw
171
234
  `records` writers swap u0/u1 themselves.
172
235
 
173
- ### Mutations batch to a microtask
236
+ ### Mutations batch to the frame
174
237
 
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.
238
+ Mutations batch: style lease publish + count setDraw + `spatial.flush()`
239
+ go out once, in the publish pass of core's `onBeforeRender` ahead of the
240
+ frame's paint, so a write made anywhere in the frame's JS (onFrame, an
241
+ effect, an onLayout handler) is in that frame's picture, never the next;
242
+ a write made outside a frame (an event handler, a timer) publishes at
243
+ the microtask. No mutation, no publish, no frame: a static layer costs
244
+ zero, the same demand-gate story as the rest of the platform.
178
245
 
179
246
  ### The records layer
180
247
 
181
- The records layer (`createRecordLayer`) keeps the old model whole: 13
248
+ The records layer (`createRecordLayer`) keeps the old model whole: 20
182
249
  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
250
+ rgba, minScreenPx, maxScreenPx, atlas, outline rgb, outlineWidth]`
251
+ (`INSTANCE_FLOATS`; `atlas` is the frame's texture as its index in
252
+ `layer.atlases`; the outline is read by a distance-field atlas only), draw order =
253
+ insertion order (or key order with `orderBy` - see below), remove
254
+ shifts, raw writes, JS pick walk. The raw path speaks @solidrt/3d's
255
+ record-mesh verbs, one dimension down: `records(layer)` is the mirror
256
+ (read it at use time - growth replaces it), `updateRecords(layer, {
257
+ first?, count? })` publishes a RANGE of it at the flush (the whole
258
+ mirror by default; ten moved records of ten thousand cost ten - one
259
+ `writeBuffer` at the range's byte offset, clipped to the live sprites;
260
+ under `orderBy` the drawn prefix whole through the lease instead, since
261
+ the core gathers it into key order during the copy), and
262
+ `setRecordCount(layer, n)` dials the drawn prefix - the first n live
263
+ sprites draw and pick, the rest stay live but undrawn and unpicked,
264
+ and the dial stays in force as sprites come and go (write ahead, dial
265
+ after; `layer.count` is the live total regardless). The sprite verbs
266
+ mark their own record, so a `setSprite` here publishes one record, not
267
+ the prefix. The POPULATION verbs differ by kind and stay so: a record
268
+ mesh's records are opaque data (`setRecords` copies in and grows), a
269
+ record layer's are sprites (`addSprite`/`destroySprite`), so the copy-in
270
+ here is `records(layer).set(src)` then `updateRecords(layer)`. It is the
186
271
  escape hatch for motion only JS can compute at scale (measured 30k
187
272
  sprites: 12.9ms raw records vs 30.8ms via setSprite; both figures are
188
273
  the WRITE path only - whatever computes the motion is excluded and is
@@ -190,7 +275,9 @@ usually the dominant cost, e.g. a 24k-particle sim measured ~25ms with
190
275
  a near-free publish) - the axis is
191
276
  WHERE MOTION IS COMPUTED, not retained-vs-dynamic. The sprite functions
192
277
  (addSprite/setSprite/getSprite/destroySprite) work on both layer kinds;
193
- record sprites have `node: null` and no groups.
278
+ record sprites have `node: null` and no groups. `tests/records.test.tsx`
279
+ pins the range, the dial, growth and the ordered publish on painted
280
+ frames.
194
281
 
195
282
  ### Layer space
196
283
 
@@ -224,8 +311,8 @@ range, the contain clamp) apply to pose plus lanes and move the pose,
224
311
  and the PUSH writes the final camera. `pose()` is the pose alone;
225
312
  `camera()` the final camera as the layers received it.
226
313
 
227
- Camera control: `createCamera2d(view | views, { viewport: () =>
228
- ({ width, height }), world?: { width, height, damping? }, min/maxZoom?,
314
+ Camera control: `createCamera2d(view | views, { world?: { width,
315
+ height, damping? }, min/maxZoom?,
229
316
  pivot?, follow?: { damping?, deadZone?, hardLimits?, lookahead? },
230
317
  offset?, panSpeed?, zoomSpeed?, rollSpeed?, damping?, inertia?, x?, y?,
231
318
  zoom?, rotation? })` - Godot's Camera2D and Three's MapControls in one
@@ -241,9 +328,13 @@ at the pivot", the pivot a viewport fraction defaulting to the center,
241
328
  so `camera().x/y` is the view center and glideTo/follow land there
242
329
  (without a `world`, the default pose puts world 0,0 at the pivot: a
243
330
  fill layer that wants world = screen at rest takes `pivot: { x: 0, y:
244
- 0 }`). The first argument is anything with a view's `setCamera` (a view
245
- of a sprite layer, of a record layer, several at once - one camera over
246
- a scene's layers - or a signal setter feeding `<TileLayer camera>`).
331
+ 0 }`). The pivot is the point the control zooms and rolls about - the
332
+ 3d orbit control's target, which that control holds at the view centre
333
+ and re-seats in world space (`setPivot`); this one places it on screen.
334
+ The first argument is anything with a view's `setCamera` and `size()`
335
+ (a view of a sprite layer, of a record layer, several at once - one
336
+ camera over a scene's layers, the FIRST one's size the viewport - or a
337
+ `{ setCamera, size }` pair over a signal feeding `<TileLayer camera>`).
247
338
 
248
339
  **Follow.** `follow(x, y)` chases a world point through the framing in
249
340
  `follow` (core's camera-control, shared with the 3d orbit camera):
@@ -277,7 +368,7 @@ view - a wide camera and a close one, each with its own control - and
277
368
  `activate(name, { blend? })` makes the highest-priority enabled shot
278
369
  live, blending the view from the current output over `blend` seconds
279
370
  (default 0.5; 0 cuts; x/y/rotation/pivot linear, the zoom in log
280
- space, `mixCamera2d`); `deactivate` falls back by priority. A live
371
+ space, `mixCamera`); `deactivate` falls back by priority. A live
281
372
  shot's pushes reach the view at once; a blend needs `update(dt)` while
282
373
  `active()`, the controls' rule. `live()` and `camera()` read the state.
283
374
  A switch mid-blend starts from the output of that moment, so a quick
@@ -309,7 +400,8 @@ gesture measured (viewport heights per second from the pointer feed;
309
400
  the control has no estimator of its own). The
310
401
  verbs, each pushing the pose at once: `panBy(dx, dy)` screen pixels,
311
402
  `zoomAt(sx, sy, factor, { glide? })`, `rollBy(angle)`, `set(pose)`,
312
- `glideTo(x, y, zoom?, rotation?)` (eased, a rotation glide included),
403
+ `glideTo({ x?, y?, zoom?, rotation? })` (eased, the fields given, a
404
+ rotation glide included - the 3d controls' glideTo(pose)),
313
405
  `fit`, `follow`/`unfollow`, `shake`, `interrupt`, `release([vx, vy])`
314
406
  (screen pixels per second of content travel). An input
315
407
  map (core AGENTS.md) drives the axes by name and the APP binds devices
@@ -345,7 +437,7 @@ root with `e.sprite` null is "tap on empty space"). `<Camera2d
345
437
  input={input}>` inside `<SpriteLayer>` is all of that wired through
346
438
  context, the 3d `<OrbitCamera>` shape: options read at mount, driving
347
439
  the nearest view (the `<SpriteLayer>`'s own or the enclosing
348
- `<View2d>`), `viewport` defaulting to that view's size, the map's
440
+ `<View2d>`), that view's `size()` the viewport, the map's
349
441
  `pan`/`zoom`/`roll` actions (or the names in `actions`) driving the
350
442
  axes, frames only while `active()`. A view's feed normalizes a drag by
351
443
  the leaf's own box, so a leaf under a designSize fit pans and zooms
@@ -392,9 +484,10 @@ entry ordered), every other entry reads them sorted. Growth, the
392
484
  instance count and the layer tint fan out to every view; the camera
393
485
  and the viewport are the view's own params, so a view costs no
394
486
  per-frame JS beyond its camera writes. The `ViewHandle` is the
395
- viewport contract (texture, width/height/setSize,
396
- oversample/setOversample, setCamera/camera/project/unproject,
397
- listen/handlers/handlersFor, dispose - views also die with the layer);
487
+ viewport contract (texture, size/setSize,
488
+ oversample/setOversample, setCamera/camera/project/unproject, pick (the
489
+ layer's, through this camera), listen/handlers/handlersFor, dispose -
490
+ views also die with the layer);
398
491
  the sprites stay the layer's (`addSprite`, `pick`, `setTint`,
399
492
  `createView`, `dispose`). Pointer events on a view leaf run the
400
493
  dispatch with the VIEW's camera undone over the layer's pick, the view
@@ -484,12 +577,46 @@ attach via `ref` and leave the `frame` prop off - the clip owns that
484
577
  field (the prop effect passes absent props as undefined, which setSprite
485
578
  keeps, so other props stay reactive).
486
579
 
580
+ ### Text runs
581
+
582
+ Text IN the layer's world - a label under a sprite, a name on a map, a
583
+ damage number - is a text run: a group of glyph sprites drawn from a
584
+ SPRITE FONT's atlas (`createSpriteFont`, `addText`/`setText`/
585
+ `destroyText`, `<Text2d>`; `examples/text.tsx`). The font is the
586
+ runtime's own glyph engine (`flux:font`): it shapes the text (kerning is
587
+ the engine's, carets are one pass), makes glyph cells on a worker thread
588
+ and packs them into one atlas texture that grows as glyphs arrive. The
589
+ atlas is one more entry in the layer's list - `createSpriteLayer([art,
590
+ font.atlas])` - so labels draw in the one draw with the sprites they
591
+ belong to and pan, zoom, sort (`orderBy`), pick and transition as
592
+ sprites; a run's pose is its group's (`run.group`), where glyph pointer
593
+ events bubble. Two cell kinds: "msdf" (the default: one atlas sharp at
594
+ every zoom, the fragment stage decoding `Atlas.sdf`; the per-run
595
+ `outline` draws only here) and "mask" (coverage at the face's exact size,
596
+ for 1:1 drawing). Text
597
+ as elements is still the right tool up to hundreds of labels: `d-text`
598
+ under a `<view>` carrying the camera transform (what `<TileLayer>` does
599
+ for its chunks) costs no JS per label on a camera move and is rasterized
600
+ at the real scale; runs are for thousands, for in-layer draw order, and
601
+ for a zoom animation that would re-rasterize every label per frame.
602
+
603
+ Layout is `layoutText` (text-layout.ts, pure): `\n` breaks, `maxWidth`
604
+ wraps with core's greedy rule, `align` per line, `anchor`
605
+ (start/middle/end, as d-text) and `anchorY` (top/middle/baseline/bottom)
606
+ place the run's point, `letterSpacing` and `lineHeight` as named. The
607
+ run reports `width`, `height`, `ascent` and `lines`. A run's `fontSize`
608
+ defaults to the face's; glyphs scale their cells by `fontSize /
609
+ font.size`.
610
+
487
611
  ### Pure pieces
488
612
 
489
- frames.ts, pick.ts, camera.ts, camera-motion.ts, dispatch.ts,
490
- oversample-math.ts and tiles-math.ts (the tile grid's chunk and slot
491
- math, tests/tiles.test.ts) are pure (no GPU imports) BY DESIGN so they
492
- can be checked headless; keep them that way.
613
+ frames.ts, extrude.ts (the mip-gutter repack, tests/extrude.test.ts),
614
+ pack.ts (the shelf packer, tests/pack.test.ts), pick.ts, camera.ts,
615
+ camera-motion.ts, dispatch.ts, oversample-math.ts, tiles-math.ts (the
616
+ tile grid's chunk and slot math, tests/tiles.test.ts) and text-layout.ts
617
+ (glyph placement over prepared units, tests/text-layout.test.ts) are pure
618
+ (no GPU imports) BY DESIGN so they can be checked headless; keep them
619
+ that way.
493
620
 
494
621
  ## The baked tile layer (tiles.ts)
495
622
 
@@ -497,17 +624,19 @@ can be checked headless; keep them that way.
497
624
 
498
625
  Static 2D bulk as a few quads: on tiled GPUs the budget is primitive count
499
626
  (core agents/performance.md), so a 100x100 tile world must not be 10,000
500
- quads per frame. `createTileLayer(cols, rows, tileW, tileH, atlas)` bakes
501
- the world into CHUNKED `render: "manual"` targets (default ~512px of tiles
502
- per chunk, `chunkTiles` to tune), each chunk a small copy of the sprite
503
- pipeline (shaders.ts) with FIXED record slots - an empty tile is a
627
+ quads per frame. `createTileLayer(cols, rows, tileW, tileH, atlases)`
628
+ bakes the world into CHUNKED `render: "manual"` targets (default ~512px
629
+ of tiles per chunk, `chunkTiles` to tune), each chunk a manual target
630
+ over the layer's ONE sprite pipeline (shaders.ts, compiled once per
631
+ layer for its atlases) with FIXED record slots - an empty tile is a
504
632
  zero-size quad, instance count is constant per chunk. Records hold WORLD
505
633
  pixel coordinates; each chunk target's `uCamera` is its pixel origin, so
506
634
  the shared vertex stage does the chunk-local mapping. Chunks allocate on
507
635
  the first `setTile` that reaches them - an empty chunk costs nothing, a
508
636
  sparse world is bounded by its content, and world size is bounded by
509
- memory, not `maxTextureSize`. `setTile` batches to a microtask whose flush
510
- publishes and re-bakes ONLY dirty chunks. After that the layer is static
637
+ memory, not `maxTextureSize`. `setTile` batches to the frame's publish
638
+ pass (the microtask, outside a frame), whose flush publishes and re-bakes
639
+ ONLY dirty chunks. After that the layer is static
511
640
  textures: zero per-frame cost however many tiles exist.
512
641
 
513
642
  ### Scrolling and the camera
@@ -567,16 +696,17 @@ on approach, evict) - okf/backlog/2d-baked-layers.md.
567
696
 
568
697
  | Component | Props |
569
698
  |---|---|
570
- | `SpriteLayer` | atlas (TextureId), capacity?, tint? ([r,g,b,a] 0..1, over the whole layer in every view), orderBy?, stagger? (ms, the layer root's enter/exit spacing), 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) |
571
- | `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? |
699
+ | `SpriteLayer` | atlases (Atlas[], the sheets bound as one draw; mount-fixed) or layer (an existing createSpriteLayer handle to adopt: the component shows and populates it, keeps only the view and the leaf as its own and leaves the layer's lifetime to its maker, so `<Sprite>`/`<Text2d>` sit over an imperative layer; the creation props atlases/capacity/blend/orderBy then throw), capacity?, blend? (core gpu BlendMode, "alpha" default, "add" for glows; mount-fixed), tint? ([r,g,b,a] 0..1, over the whole layer in every view), orderBy?, stagger? (ms, the layer root's enter/exit spacing), 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) |
700
+ | `Sprite` | x, y (center; local to the enclosing `<Group>`), w, h, frame?, rotation? (radians, clockwise), tint? ([r,g,b,a] 0..1), minScreenPx?, maxScreenPx? (the screen-size clamp on the smaller axis, view pixels; 0 = off; equal = constant size), visible?, transition?, onPointer{Down,Move,Up,Enter,Leave}?, onWheel?, onTap?, ref? |
572
701
  | `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? |
573
- | `Camera2d` | createCamera2d's options minus `viewport` (world?, min/maxZoom?, pivot?, follow?, offset?, panSpeed?, zoomSpeed?, rollSpeed?, damping?, inertia?, x?, y?, zoom?, rotation?), viewport? (`() => { width, height }`, default: the driven viewport's size), input? (the input map driving its `pan`/`zoom`/`roll` axes; live), actions? (action names per axis when the map's differ), ref? - a `<SpriteLayer>` child driving the nearest view's camera (the `<SpriteLayer>`'s own, or inside a `<View2d>` that view) from the map, nothing else; read at mount; throws under `output={false}` outside a `<View2d>` |
702
+ | `Camera2d` | createCamera2d's options (world?, min/maxZoom?, pivot?, follow?, offset?, panSpeed?, zoomSpeed?, rollSpeed?, damping?, inertia?, x?, y?, zoom?, rotation?), input? (the input map driving its `pan`/`zoom`/`roll` axes; live), actions? (action names per axis when the map's differ), ref? - a `<SpriteLayer>` child driving the nearest view's camera (the `<SpriteLayer>`'s own, or inside a `<View2d>` that view; its `size()` the viewport) from the map, nothing else; read at mount; throws under `output={false}` outside a `<View2d>` |
574
703
  | `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 |
575
- | `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? |
704
+ | `TileLayer` | cols, rows, tileW, tileH, atlases (Atlas[], the tilesets bound as one bake; mount-fixed), frames? (the tileset `setTiles` indices name), chunkClearColor?, blend? (the bake's blend mode, "alpha" default; mount-fixed), 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? |
576
705
 
577
706
  ### SpriteLayer and useSpriteLayer
578
707
 
579
- `SpriteLayer` owns the layer and its own view, rendered as the built-in
708
+ `SpriteLayer` owns the layer (or adopts an existing one through `layer`,
709
+ owning only what follows) and its own view, rendered as the built-in
580
710
  `<texture>` leaf carrying the view's pointer handlers (opt out with
581
711
  `events={false}`, which throws at mount together with a `pointer` feed -
582
712
  the feed would listen at a root no event reaches; the same on `<View2d>`;
@@ -591,7 +721,7 @@ inside a `<View2d>` that view), what a `<Camera2d>` drives, and
591
721
  `pointer` that view's feed (the owner's `pointer` prop, null without
592
722
  one); read under `output={false}` outside a `<View2d>` `viewport` throws. A FILL-mode
593
723
  view is 1x1 until the first layout: `ref` fires at mount, before it, so
594
- `viewport.width`/`height` read 1 there. Sprites added and positioned from
724
+ `viewport.size()` reads 1x1 there. Sprites added and positioned from
595
725
  `ref` (the pool, the opening scene) are fine - the first layout resizes
596
726
  the view before the first paint - but anything that needs the real size
597
727
  (centering on the view) waits for `onLayout`, or works in a `designSize`
@@ -645,23 +775,45 @@ hover, wheel and tap rules headless.
645
775
 
646
776
  ### Atlases and sampling
647
777
 
648
- - The atlas is NOT owned by the layer: layers come and go, atlases usually
649
- live app-long. Dispose atlases yourself (or let the reactive owner do it -
650
- createAtlas registers with the owning scope like every core texture).
778
+ - The atlases are NOT owned by the layer: layers come and go, atlases
779
+ usually live app-long. Dispose atlases yourself (or let the reactive
780
+ owner do it - createAtlas registers with the owning scope like every
781
+ core texture).
651
782
  - `createImage` is the wrong loader for pixel-art atlases: it never forwards
652
783
  sampler options, so it is always `filter: "linear"`. `createAtlas` takes
653
784
  decoded pixels (`createAtlas(decodeImage(bytes), { filter: "nearest" })`,
654
785
  or pixels built in code) and passes the full sampler through (`filter`,
655
- `wrap`, `mipmap`, `anisotropy`); the record it returns is what `grid` and
656
- `namedFrames` slice.
786
+ `wrap`, `mipmap`, `anisotropy`); the record it returns is what `grid`,
787
+ `namedFrames` and `fullFrame` slice, and what a layer's `atlases` list
788
+ holds.
789
+ - A frame from a sheet the layer did not declare THROWS at the write
790
+ (addSprite, setSprite, setTile, setTiles, a tile layer's `frames`
791
+ table), naming the layer's atlases: a sprite never silently draws from
792
+ the wrong sheet. The same frames serve every layer that declares their
793
+ atlas (a scene layer and its additive glow layer over the same art).
794
+ Raw record writers store the texture's index in `layer.atlases`.
795
+ - The sampler cap: a layer binds at most `limits.maxTextureUnits` atlases
796
+ (16 on every GLES 3.0 device, the whole pass's budget - a layer's
797
+ targets bind nothing else). Past it, pack sheets together; runtime
798
+ images need the packer (okf/backlog/2d-atlas-runtime-packing.md).
657
799
  - Frames that share an edge do not bleed: the fragment stage clamps every
658
800
  sample into its frame, so a sprite at a fractional position (a scrolling
659
801
  backdrop, an easing camera) never paints a line of the neighbouring cell,
660
- and an edge-to-edge sheet needs no gutter and no shaved rect table. The
661
- one case left is a `mipmap` atlas, whose mip texels average across cell
662
- edges before sampling: pass `inset` to `grid`/`namedFrames` (2^k texels
663
- keeps mip level k clean, losing that border) or pack a gutter as
664
- `spacing`, until extrusion lands (okf/backlog/2d-atlas-extrude.md).
802
+ and an edge-to-edge sheet needs no gutter and no shaved rect table. A
803
+ `mipmap` atlas is different: its mip texels average across cell edges
804
+ BEFORE sampling, and no UV-side trick undoes that. Extrude the sheet
805
+ before `createAtlas` - `extrudeGrid(image, cols, rows, gutter)` for a
806
+ uniform sheet, `extrudeRects(image, rects, gutter)` for a hand-packed
807
+ one: every cell is copied into a gutter of its own replicated edge
808
+ pixels, the result slices with the returned `options`/`rects`, and mip
809
+ levels 0 through log2(gutter) sample clean (TexturePacker's "Extrude",
810
+ Unity's "Extrude Edges"). The rules: `gutter` a power of two and every
811
+ cell size a multiple of it, so cell edges land on mip texel edges (the
812
+ calls throw otherwise, naming the fix); the sheet grows by 2 x gutter
813
+ per cell per axis; deeper levels mix again, so size the gutter to the
814
+ smallest scale the sheet is drawn at (gutter 4 covers down to a
815
+ quarter). Load-time CPU work, a few milliseconds for a 1024-square
816
+ sheet.
665
817
  - The colour contract: tint multiplies the atlas texel as stored -
666
818
  premultiplied sRGB, no decode - and the layer tint multiplies over
667
819
  that, so a tint of `[0.5, 0.5, 0.5, 1]` halves the ENCODED value (about
@@ -671,7 +823,11 @@ hover, wheel and tap rules headless.
671
823
  shading: the same tint triple is a different brightness in the two
672
824
  packages, so a palette shared across them needs its own conversion.
673
825
  - Tint multiplies the sampled texel (`texture * tint`) and the pipeline
674
- blends with `blend: "alpha"` in record order, the premultiplied composite.
826
+ blends with the layer's `blend` in record order - "alpha" by default,
827
+ the premultiplied composite; "add" accumulates (glows, sparks, light
828
+ cones: two half-alpha whites make opaque white), "multiply" darkens,
829
+ "none" overwrites. Pipeline state, so it is per layer and fixed at
830
+ creation; an additive effect layer draws over the scene layer.
675
831
  The atlas is premultiplied because `decodeImage` premultiplies by default;
676
832
  an atlas uploaded from straight-alpha pixels (`decodeImage(bytes, { alpha:
677
833
  "straight" })` into `createAtlas`) draws color under transparent texels
@@ -716,23 +872,24 @@ hover, wheel and tap rules headless.
716
872
  stays stable and a spawn is one setSprite, never an add. Keep a free
717
873
  list of hidden handles; scanning the pool for one is the cost that
718
874
  shows up first at a few hundred entities. On the records layer, do not
719
- cache `layer.records` across addSprite - growth replaces the array and
720
- a hoisted reference becomes a dead copy whose writes publish nothing.
721
- `layer.withRecords(fn)` is the hoist-proof read; a bare `layer.records`
722
- at use time is equally live.
875
+ cache `records(layer)` across addSprite - growth replaces the array and
876
+ a hoisted reference becomes a dead copy whose writes publish nothing;
877
+ read it at use time (the top of the frame's step is the place).
723
878
  - Records layer: record order is draw order: `destroySprite` shifts every
724
879
  later sprite down one slot (copyWithin + index fixup, O(later
725
- sprites)). Its flush publishes the WHOLE live prefix, not a dirty
726
- range: one moved sprite re-publishes count x 52 bytes - a single
727
- memcpy, microseconds at 10k; the node layer's style publish is the same
728
- whole-prefix shape. Dirty ranges were deliberately not built until a
729
- measurement asks.
880
+ sprites)) and marks the shifted records dirty. The flush publishes the
881
+ DIRTY record range (one buffer write at its byte offset, clipped to
882
+ the live sprites), or under `orderBy` the drawn prefix whole through
883
+ the lease - so a moved sprite costs its 80 bytes on a plain layer and
884
+ count x 80 bytes on an ordered one. The node layer's style publish is
885
+ still the whole-prefix shape (a boolean dirty flag over the high-water
886
+ mark), the one place a range is not yet tracked.
730
887
  - `orderBy` on BOTH layers: the core gathers publishes into key order
731
888
  (gpu `instanceOrder`, radix sort + one extra memcpy, no per-record JS),
732
889
  so a y-sorted crowd costs the same flush as an unsorted one. Records
733
890
  and slots stay stably addressed; ties keep slot order; `pick()` still
734
891
  resolves overlap by slot/record order. Records layer keys: "y" or a raw
735
- `{ field, descending? }` offset into the 13-float record. Node layer
892
+ `{ field, descending? }` offset into the 16-float record. Node layer
736
893
  keys: `"y"` - WORLD y from the core-written pose buffer, so sprites
737
894
  moved by native transitions (or any core producer) re-sort with zero JS
738
895
  per frame - or `"renderOrder"` - the app-owned per-sprite `renderOrder`
@@ -743,7 +900,7 @@ hover, wheel and tap rules headless.
743
900
  pose AND style under ONE permutation and republishes the sibling buffer
744
901
  itself when the key buffer re-orders (the multi-buffer stage of
745
902
  okf/backlog/gpu-instance-order.md). `renderOrder` on a record-layer
746
- sprite throws - its 13-float record has no key field.
903
+ sprite throws - its 16-float record has no key field.
747
904
  - The node layer's STYLE slots are not compacted: a removed sprite leaves
748
905
  its style floats in place (invisible - the pose is zeroed) until the
749
906
  slot recycles. Do not read style truth from the buffer; getSprite reads
@@ -804,6 +961,26 @@ hover, wheel and tap rules headless.
804
961
  away with the transform-on-the-leaf camera only because its composited
805
962
  view is the WORLD, not a viewport of it.
806
963
 
964
+ ### Text runs
965
+
966
+ - A run draws nothing for a glyph whose cell is not in the atlas yet:
967
+ the sprite is there, hidden, at its place, and the font re-frames the
968
+ run when the cell lands a frame or two later (`font.ready()` awaits
969
+ that; `settle` does in a test). Printable ASCII is made at font
970
+ creation so a run of it never waits; `chars: false` skips that.
971
+ - The atlas grows by doubling and EVERY cell moves when it does:
972
+ `font.atlas.width`/`height` change in place and every run over the
973
+ font is re-framed. Never cache a glyph's frame outside the font.
974
+ - A sprite font over "msdf" cells (the default) is sharp at every zoom;
975
+ one over "mask" cells is exact at the face's size, resamples under zoom
976
+ like any texture, and silently ignores `outline`.
977
+ - A run is node-layer only (it is a group); `addText` on a layer that
978
+ does not declare `font.atlas` throws. Dispose runs before the font
979
+ (`font.dispose`, or the owner's cleanup).
980
+ - The style record is 16 floats and the raw record 20: a raw record
981
+ writer (`records(layer)`) that assumed 16 is off by the outline field
982
+ per sprite.
983
+
807
984
  ### Tile layer
808
985
 
809
986
  - `<TileLayer>`'s world view is WORLD sized (cols * tileW) and takes that
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @solidrt/2d
2
2
 
3
- An instanced sprite layer for SolidRT: one atlas, one GPU buffer, thousands
4
- of sprites in a single draw call, composited into your app as an ordinary
5
- texture element.
3
+ An instanced sprite layer for SolidRT: your atlases bound together, one GPU
4
+ buffer, thousands of sprites in a single draw call, composited into your
5
+ app as an ordinary texture element.
6
6
 
7
7
  *Status: experimental. Expect API churn.*
8
8
 
@@ -16,7 +16,7 @@ let frames = grid(atlas, 4, 4)
16
16
 
17
17
  render(() => (
18
18
  <window>
19
- <SpriteLayer width={720} height={480} atlas={atlas.texture}>
19
+ <SpriteLayer width={720} height={480} atlases={[atlas]}>
20
20
  <Sprite x={100} y={120} w={32} h={32} frame={frames[0]} />
21
21
  <Sprite x={200} y={160} w={32} h={32} frame={frames[5]} rotation={0.4} />
22
22
  </SpriteLayer>
@@ -24,28 +24,43 @@ render(() => (
24
24
  ))
25
25
  ```
26
26
 
27
+ A frame knows the sheet it is cut from, so a layer declaring several
28
+ atlases draws sprites from any of them in the same draw (the multi-texture
29
+ batch), and a mipmapped sheet goes through `extrudeGrid`/`extrudeRects`
30
+ first so its mip chain does not bleed across cells.
31
+
27
32
  The rendertree already handles 2D well: a `d-texture` with atlas sub-rects is
28
33
  a sprite, with native transitions, gesture-arena pointer events, and
29
34
  per-element inspectability, and it carries populations into the low
30
- thousands. This package is for what lies beyond that: dense, per-frame
31
- animated populations - entities, particles, bullets - where per-sprite
32
- property writes through the JS boundary are the bottleneck. Sprite records
33
- live in one `Float32Array` and publish to the GPU through SolidRT's zero-copy
34
- buffer write lease; moving ten thousand sprites is ten thousand float stores
35
- and one bulk publish, not twenty thousand FFI calls.
35
+ thousands. This package is for what lies beyond that: dense, animated
36
+ populations - entities, particles, bullets - drawn as one instanced draw
37
+ per view. Every sprite is a node of the spatial core, so native
38
+ transitions, groups, picking and the collision queries reach it with no
39
+ per-frame JS: a sprite write is a target the core animates toward, and
40
+ the core writes the pose buffer itself. For motion only JS can compute at
41
+ scale (a flock, a particle sim), the records layer is the escape hatch:
42
+ sixteen JS-owned floats per sprite in one `Float32Array`, published by
43
+ dirty range with the same `records`/`updateRecords`/`setRecordCount`
44
+ verbs as `@solidrt/3d`'s record mesh - ten thousand float stores and one
45
+ buffer write, not twenty thousand FFI calls.
36
46
 
37
47
  Two layers, like `@solidrt/3d`: an imperative core
38
48
  (`createSpriteLayer` / `addSprite` / `setSprite` - per-frame motion calls
39
49
  these from `onFrame`) and the component face (`SpriteLayer` / `Sprite`) for
40
50
  structure and slow state. A static layer publishes nothing and costs nothing;
41
- the paint-order story is the painter's algorithm (insertion order); pointer
42
- events hit-test exact rotated rects, topmost first, with pointer capture.
43
-
44
- v1 scope: atlas creation and slicing (`createAtlas`, `grid`, `namedFrames`),
45
- the sprite layer with camera pan/zoom, sprite pointer events, and the
46
- component face. Staged next: baked/tilemap layers (static worlds as one
47
- quad), z-ordering, frame animation helpers, and the retro presets (pixel
48
- canvas, palette and scanline passes) - see `okf/backlog/`.
51
+ draw order is slot order unless the layer names a key (`orderBy`: world y
52
+ for a perspective crowd, or a per-sprite `renderOrder`); pointer events
53
+ hit-test exact rotated rects, topmost first, with pointer capture.
54
+
55
+ In the package: atlas creation, slicing and extrusion (`createAtlas`,
56
+ `grid`, `namedFrames`, `fullFrame`, `extrudeGrid`, `extrudeRects`), the
57
+ sprite layer with its views and cameras, sprite pointer events, the baked
58
+ tile layer, frame animation, key ordering, world-space text (a sprite
59
+ font over the runtime's glyph engine is one more atlas, a run of text a
60
+ group of glyph sprites: `createSpriteFont`, `addText`, `<Text2d>`), and
61
+ the component face. Staged
62
+ next: streaming tile worlds and the retro presets (pixel canvas, palette
63
+ and scanline passes) - see `okf/backlog/`.
49
64
 
50
65
  The full model and the sharp edges are in [AGENTS.md](AGENTS.md); runnable
51
66
  patterns in [examples/](examples/).