@solidrt/2d 0.0.71 → 0.0.72

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
@@ -17,7 +17,8 @@ Contents:
17
17
  - [Three faces](#three-faces)
18
18
  - [A layer renders through its views](#a-layer-renders-through-its-views)
19
19
  - [Atlases and frames](#atlases-and-frames)
20
- - [Pose and style slots](#pose-and-style-slots)
20
+ - [Materials](#materials)
21
+ - [Pose, sprite and style slots](#pose-sprite-and-style-slots)
21
22
  - [Fixed instance slots](#fixed-instance-slots)
22
23
  - [Growth](#growth)
23
24
  - [Picking](#picking)
@@ -48,6 +49,7 @@ Contents:
48
49
  - [Pointer events](#pointer-events)
49
50
  - [Traps](#traps)
50
51
  - [Atlases and sampling](#atlases-and-sampling)
52
+ - [Materials](#materials-1)
51
53
  - [Slots, capacity and order](#slots-capacity-and-order)
52
54
  - [Nodes, transitions and queries](#nodes-transitions-and-queries)
53
55
  - [Cameras and rotation](#cameras-and-rotation)
@@ -94,10 +96,12 @@ included, and a tile world bakes from several tilesets in one pass. A
94
96
  Unity's Sprite and Godot's AtlasTexture pair the texture with the rect
95
97
  the same way), the record stores that texture's index in the layer's
96
98
  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
99
+ fragment SET is GENERATED per layer with one sampler per atlas, the
100
+ flat per-instance index picking it (`spriteSource` in `@solidrt/2d/glsl`,
101
+ prepended to whatever fragment the layer's material carries):
102
+ derivatives are taken from the unclamped uv before the branch and the
103
+ tap is `textureGrad`, so mip selection is the quad's own and well
104
+ defined inside the branch. A frame
101
105
  whose texture the layer did not declare THROWS at the write (the
102
106
  wrong-sheet bug, caught instead of drawn); the frame left out of
103
107
  `addSprite` is the whole FIRST atlas. An atlas declared `sdf: { range }`
@@ -107,19 +111,114 @@ the edge, anti-aliased over one screen pixel at whatever zoom, the true
107
111
  field in alpha grows a per-sprite outline (text runs set it; a colour
108
112
  atlas ignores the field). Fixed at creation like the list itself.
109
113
 
110
- ### Pose and style slots
111
-
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.
114
+ ### Materials
115
+
116
+ What a layer's pixels look like is its MATERIAL (material.ts), one per
117
+ layer, fixed at creation (`createSpriteLayer(atlases, { material })`,
118
+ the records and tile layers alike, `<SpriteLayer material>` /
119
+ `<TileLayer material>`): the material sits on the thing that is one
120
+ draw entry - the layer is the batch - as @solidrt/3d's sits on the
121
+ mesh, so two looks are two layers, which is also how you order them
122
+ (the additive glow layer draws over the scene layer). The same model
123
+ and the same words as 3d, deliberately: `unlit()` is the stock
124
+ material, `prelude`/`surface` its tier-2 slot, `shaderMaterialClass`
125
+ / `shaderMaterial` the class form over the layer's GLSL set,
126
+ `instanceBuffers` / `instanceStyle` / `setInstanceStyle` /
127
+ `instanceAttribute` the per-sprite style record (the comparison and
128
+ the decisions: okf/plans/materials-one-model.md; Godot's
129
+ `CanvasItem.material` with a `canvas_item` shader is the nearest
130
+ engine shape, PixiJS and Phaser the batch-per-look precedent). The one
131
+ deliberate asymmetry: a 3d `blend` implies `transparent` and the
132
+ scene's back-to-front sort; a layer has no depth and draws in record
133
+ order, so nothing sorts.
134
+
135
+ - `unlit({ blend?, prelude?, surface?, params?, textures?,
136
+ instanceBuffers?, instanceStyle?, label? })` - the atlas texel times
137
+ the sprite's tint (a distance-field atlas decoded to its fill and
138
+ outline), times the layer tint, blended with `blend` ("alpha" by
139
+ default, "add" for glows and additive particles, "multiply" to
140
+ darken, "none" to overwrite; pipeline state, so per layer). Every
141
+ layer draws with `unlit()` unless told otherwise. The TIER-2 slot:
142
+ `surface` declares `void surface(inout Sprite s)`, called with the
143
+ struct filled and before the layer tint - `color` (the stock result,
144
+ premultiplied: texel times tint, or the decoded field), `uv`,
145
+ `frame`, `tint`, `atlas` - rewrite `s.color` or `discard`;
146
+ `prelude` is file scope after the set (uniforms, each a LAYER PARAM;
147
+ helpers); `spriteSample(uv)` resamples the fragment's own atlas at
148
+ another uv (the pick, the frame clamp, the field decode, the tint),
149
+ so a scroll is `spriteSample(mix(s.frame.xy, s.frame.zw,
150
+ fract(t)))` - the clamp into the frame still applies, so a wrap is
151
+ `fract` in FRAME space. One program per distinct source, cached for
152
+ the app's lifetime like 3d's.
153
+ - PER-SPRITE DATA: `instanceBuffers: [{ attributes: [{ name, format }]
154
+ }]` on either form declares the material's STYLE record(s), one
155
+ stream per buffer bound after the layer's own records, any vertex
156
+ format (a palette index is a `uint8x4` at four bytes, a dissolve
157
+ amount a `float16x2`), every slot starting as `instanceStyle` (the
158
+ identity of what the look does with it). On `unlit` the stock
159
+ vertex stage forwards each attribute unchanged as a flat varying -
160
+ `iPalette` reaches the prelude and the surface as `vPalette`
161
+ (`forwardedName`; names follow the layer's own `i` + capital
162
+ convention) - so a palette swap is one attribute, one surface line
163
+ and a `setInstanceStyle(layer, sprite, [row])` per sprite. Writes:
164
+ `setInstanceStyle(layer, sprite, values)` (the first buffer, one
165
+ value per component as the shader sees them; marks the slot like
166
+ setSprite), `instanceAttribute(layer, name)` (the accessor: slot in,
167
+ component values out and in through the format's codec; valid
168
+ across growth) plus `updateRecords(layer, { stream, first, count
169
+ })`, or `records(layer, stream)` in bulk (stream 1.. the material's
170
+ buffers in order; a Float32Array over an all-float layout, bytes
171
+ otherwise), `<Sprite style>` in the component face, `setTile(...,
172
+ { style })` on a tile layer. A recycled slot (and a records layer's
173
+ shifted one) starts blank again.
174
+ - LAYER PARAMS: `layer.setParams({ uFlash: 0.5 })` writes a prelude's
175
+ uniforms to every view (and every resident chunk of a tile layer,
176
+ which RE-BAKES on each write like its tint) over the material's own
177
+ `params`; the `params` option and prop seed them, a view created
178
+ later inherits the merged record. @solidrt/3d's setMeshParams one
179
+ dimension down. Material `textures` (a palette LUT, a noise texture)
180
+ bind beside the atlases and count against the same
181
+ `limits.maxTextureUnits` budget, checked at layer creation.
182
+ - The class form, `shaderMaterialClass({ vertex, fragment,
183
+ instanceBuffers?, instanceStyle?, blend?, label? })` with
184
+ `instance({ params?, textures? })` and `dispose()` on the class
185
+ (`shaderMaterial(opts)` is a class with one instance): TIER 1 pairs
186
+ a vertex stage of your own with `unlitFragment(options)` from
187
+ `@solidrt/2d/glsl` - compose `SPRITE_VARYINGS`, core's
188
+ `SCREEN_SIZE_GLSL`, your placement into `corner`/`center`/`rot`/
189
+ `clampScale`, then `SPRITE_VERTEX_BODY`; `SPRITE_VERTEX` is the
190
+ stock stage, `unlitVertex(forwards)` the stock stage forwarding
191
+ attributes - and TIER 3 writes a `main` of its own over the layer's
192
+ SET, which the layer prepends (the varyings, `uAtlasN`, `uTint`, the
193
+ `Sprite` struct, `spriteOf()`, `spriteSample(uv)`: declare none of
194
+ them, start with `Sprite s = spriteOf();` since the derivatives
195
+ every tap uses are taken there, and write premultiplied). One
196
+ program per ATLAS LIST met (the set is generated per list), one
197
+ pipeline per instance-layout list met on top - the records and tile
198
+ layers bind one record buffer, the node layer a pose and a sprite
199
+ buffer, with the same attribute NAMES on both (iPos, iRot, iScale,
200
+ iUv, iTint, iScreenPx, iAtlas, iOutline), so one vertex stage
201
+ serves every layer kind. A vertex stage must declare and use
202
+ `uCamera`, `uCameraRot` and `uViewport` (checked at creation), an
203
+ instance attribute may not reuse a layer name
204
+ (`LAYER_ATTRIBUTE_NAMES`), a texture may not take an atlas sampler's
205
+ (`uAtlasN`). `tests/materials.test.tsx` pins all three tiers, the
206
+ style streams through growth and shift, the params, the resample,
207
+ the tile bake and the throws.
208
+
209
+ ### Pose, sprite and style slots
210
+
211
+ Node layer ownership split, instance-buffer slots on one pipeline (the
212
+ material's; two materials are two layers): slot 0 is the POSE buffer
213
+ `[x, y, angle, sx, sy]` written ONLY by the core (each sprite node's
214
+ Pose2D record sink; one coalesced buffer write per flush however many
215
+ nodes moved), slot 1 the SPRITE record `[u0, v0, u1, v1, tint rgba,
216
+ renderOrder, minScreenPx, maxScreenPx, atlas, outline rgb,
217
+ outlineWidth]` (`SPRITE_FLOATS`, 16), JS-owned through the sprite verbs
218
+ and published through the zero-copy write lease, and after them the
219
+ material's STYLE streams, one per instance buffer it declares (app-owned
220
+ per-sprite data, Materials above). NEVER write the pose buffer from JS -
221
+ the core's staging mirror owns it and will overwrite.
123
222
 
124
223
  ### Fixed instance slots
125
224
 
@@ -212,7 +311,9 @@ detach here (3d's `remove`), and a sprite that should come back is
212
311
  hidden (`visible: false`).
213
312
  A handle's x/y are local to its group; `worldPosition(sprite | group)`
214
313
  reads the layer-pixel position composed through every enclosing group
215
- from the core's world matrix (@solidrt/3d's worldPosition).
314
+ from the core's world matrix (@solidrt/3d's worldPosition) - under a
315
+ transition the pose of this frame, since native motion steps before the
316
+ frame's JS.
216
317
 
217
318
  ### Visibility
218
319
 
@@ -273,14 +374,18 @@ publish itself is the core's record stream (`createRecordStream` in
273
374
  the range-or-whole publish, growth into a replacement), the one model
274
375
  the 3d instance streams run on too; this file only decides what to
275
376
  mark and when to flush. The node layer speaks the same two verbs over
276
- its STYLE records: `records(spriteLayer)` is the style mirror
277
- (`STYLE_FLOATS` floats per slot: `[u0, v0, u1, v1, tint rgba,
377
+ its SPRITE records: `records(spriteLayer)` is the sprite mirror
378
+ (`SPRITE_FLOATS` floats per slot: `[u0, v0, u1, v1, tint rgba,
278
379
  renderOrder, minScreenPx, maxScreenPx, atlas, outline rgb,
279
380
  outlineWidth]`, slot-indexed, poses stay the core's) and
280
381
  `updateRecords(spriteLayer, { first?, count? })` publishes the slot
281
382
  range, so a bulk restyle (a palette cycle over thousands of sprites) is
282
383
  one loop and one range write instead of a setSprite each - the 3d
283
- `records(instancedMesh)` pairing, one dimension down. It is the
384
+ `records(instancedMesh)` pairing, one dimension down. Both layer
385
+ kinds take a `stream` beyond 0 on the same verbs for the material's
386
+ style streams (`records(layer, 1)`, `updateRecords(layer, { stream: 1
387
+ })`), which grow, publish and (on a records layer) shift with the
388
+ layer's own record. It is the
284
389
  escape hatch for motion only JS can compute at scale (measured 30k
285
390
  sprites: 12.9ms raw records vs 30.8ms via setSprite; both figures are
286
391
  the WRITE path only - whatever computes the motion is excluded and is
@@ -478,7 +583,9 @@ kinds): one `uTint` shared-params write multiplied over every sprite's
478
583
  own tint - day/night, a dimmed parallax plane, a fade-in. Cheap to
479
584
  animate (no record touches), unlike TileLayer's, which re-renders
480
585
  resident chunks. The same contract as TileLayer.setTint, so one signal
481
- drives a whole scene across layer kinds.
586
+ drives a whole scene across layer kinds. The general form is
587
+ `setParams` (Materials above): the tint is the one param every material
588
+ takes.
482
589
 
483
590
  ### Views
484
591
 
@@ -562,6 +669,25 @@ never on a cancel, snap or exit; the raw "spatialTransitionEnd" engine
562
669
  event (sol:events, node = sprite.node) stays for flux:spatial consumers.
563
670
  See examples/springs.tsx (tap a sprite: it leaves through its `exit`).
564
671
 
672
+ PAUSE and time scale are declarations on the same nodes:
673
+ `layer.setTimeScale(scale)` / `<SpriteLayer timeScale>` sets the rate
674
+ every sprite and group transition in the layer runs at - with their
675
+ delays, enters, exits and stagger slots - 0 a pause that leaves the
676
+ app's UI moving, 1 app time, 0.5 slow motion; `setGroupTimeScale` /
677
+ `<Group timeScale>` and `setSpriteTimeScale` / `<Sprite timeScale>`
678
+ declare it for a subtree or one sprite. The NEAREST declaring node wins,
679
+ never a product (Godot's process_mode shape): a frozen layer with a
680
+ `<Group timeScale={1}>` inside keeps that group running. A frozen layer
681
+ asks for no frames (the write that resumes it brings the frame), writes
682
+ while frozen retarget and wait, a destroyed sprite with an `exit` stays
683
+ a ghost until its time moves again (the layer's `dispose` still cuts it
684
+ short), and a sprite added frozen sits at its `from`. The scale covers
685
+ the CORE's motion: frame animations follow it only when given a `clock`
686
+ (see Frame animation), and your own `onFrame` logic, timers and audio
687
+ stay on app time - multiply your step by `layer.timeRate()` /
688
+ `timeRate(sprite)`, the effective rate read. The dev clock
689
+ (`/clock?scale=`) scales app time underneath all of this.
690
+
565
691
  ### Frame-rate motion
566
692
 
567
693
  Frame-rate motion only JS can compute (physics, flocking) bypasses the
@@ -588,7 +714,12 @@ most one animation (add detaches the previous); removed sprites prune
588
714
  lazily on the next step. Works on both layer kinds; with `<Sprite>`,
589
715
  attach via `ref` and leave the `frame` prop off - the clip owns that
590
716
  field (the prop effect passes absent props as undefined, which setSprite
591
- keeps, so other props stay reactive).
717
+ keeps, so other props stay reactive). A clip is a JS clock, so a layer's
718
+ `setTimeScale` does not reach it by itself: `createAnimation(frames, fps,
719
+ { clock: layer })` (a group or a sprite works too) runs the clip at that
720
+ handle's `timeRate`, read every frame, so the one pause that freezes the
721
+ world's transitions holds its walk cycles as well, and a slow-motion
722
+ group slows the flipbooks in it.
592
723
 
593
724
  ### Text runs
594
725
 
@@ -640,8 +771,10 @@ Static 2D bulk as a few quads: on tiled GPUs the budget is primitive count
640
771
  quads per frame. `createTileLayer(cols, rows, tileW, tileH, atlases)`
641
772
  bakes the world into CHUNKED `render: "manual"` targets (default ~512px
642
773
  of tiles per chunk, `chunkTiles` to tune), each chunk a manual target
643
- over the layer's ONE sprite pipeline (shaders.ts, compiled once per
644
- layer for its atlases) with FIXED record slots - an empty tile is a
774
+ over the layer's ONE pipeline (its material's, resolved once per layer
775
+ for its atlases; a cell's style record is `setTile`'s `style`, its
776
+ params re-bake every resident chunk on each write) with FIXED record
777
+ slots - an empty tile is a
645
778
  zero-size quad, instance count is constant per chunk. Records hold WORLD
646
779
  pixel coordinates; each chunk target's `uCamera` is its pixel origin, so
647
780
  the shared vertex stage does the chunk-local mapping. Chunks allocate on
@@ -700,8 +833,11 @@ the whole layer through the shared `uTint` uniform, multiplied over the
700
833
  per-cell tints - day/night, a dimmed parallax plane. A layer-tint write
701
834
  is not a record write (chunks re-render GPU-side, nothing re-uploads),
702
835
  but it does re-render every resident chunk: drive it from slow state,
703
- not per frame. Not built yet: camera-driven residency (bake far chunks
704
- on approach, evict) - okf/backlog/2d-baked-layers.md.
836
+ not per frame. `setParams` (a material prelude's uniforms) and a cell's
837
+ `style` (the material's per-cell record, `setTile`/`setTiles` `{ style
838
+ }`) are the same two levels for a custom look, Materials above. Not
839
+ built yet: camera-driven residency (bake far chunks on approach, evict)
840
+ - okf/backlog/2d-baked-layers.md.
705
841
 
706
842
  ## Components
707
843
 
@@ -709,12 +845,12 @@ on approach, evict) - okf/backlog/2d-baked-layers.md.
709
845
 
710
846
  | Component | Props |
711
847
  |---|---|
712
- | `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) |
713
- | `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? |
848
+ | `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/material/orderBy then throw), capacity?, material? (the layer's material, `unlit()` default, `unlit({ blend: "add" })` for glows; mount-fixed), params? (layer.setParams as a prop, over the material's own; live, merge semantics), 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) |
849
+ | `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), style? (the sprite's style record under the layer's material, one value per component of its first instance buffer; live), visible?, transition?, onPointer{Down,Move,Up,Enter,Leave}?, onWheel?, onTap?, ref? |
714
850
  | `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? |
715
851
  | `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>` |
716
852
  | `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 |
717
- | `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? |
853
+ | `TileLayer` | cols, rows, tileW, tileH, atlases (Atlas[], the tilesets bound as one bake; mount-fixed), frames? (the tileset `setTiles` indices name), chunkClearColor?, material? (the bake's material, `unlit()` default; mount-fixed), params? (layer.setParams as a prop; live, each change re-bakes), 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? |
718
854
 
719
855
  ### SpriteLayer and useSpriteLayer
720
856
 
@@ -772,12 +908,15 @@ space keeps delivering to the root as it crosses sprites; a drag from a
772
908
  sprite keeps naming it with live coordinates). Enter/leave fire on the
773
909
  sprite alone - a group never receives them - while the root sees every
774
910
  move, so "hovering empty space" is a root move with `sprite` null. Taps
775
- are synthesized by the dispatch (DOM click, Unity's click handler):
776
- `onTap` fires after the up when the press released on the target it
777
- pressed within the slop (8 window px, core's recognizer slop, so a press
778
- is never both a tap and a pan), was the only pointer down for its whole
779
- press (a pinch never taps), with `tapCount` counting repeats within 300
780
- ms and 20 px on the same target (a double tap is `tapCount === 2`).
911
+ are synthesized by the dispatch, as DOM click (Unity's click handler
912
+ fires for every button; ours, like DOM's, for the primary alone): `onTap`
913
+ fires after the up when a PRIMARY-button press (a right click walks its
914
+ down and up with `button` 2 but never taps, like core's press; read
915
+ `button` on the down to act on it) released on the target it pressed
916
+ within the slop (8 window px, core's recognizer slop, so a press is never
917
+ both a tap and a pan), was the only pointer down for its whole press (a
918
+ pinch never taps), with `tapCount` counting repeats within 300 ms and 20
919
+ px on the same target (a double tap is `tapCount === 2`).
781
920
  Wheel walks like a move with `deltaX/deltaY`. Every event carries
782
921
  `timeStamp` and `predicted` as the element event has them (time input
783
922
  with the first; a `predicted` move is one to draw with, not to measure
@@ -844,11 +983,12 @@ hover, wheel and tap rules headless.
844
983
  shading: the same tint triple is a different brightness in the two
845
984
  packages, so a palette shared across them needs its own conversion.
846
985
  - Tint multiplies the sampled texel (`texture * tint`) and the pipeline
847
- blends with the layer's `blend` in record order - "alpha" by default,
848
- the premultiplied composite; "add" accumulates (glows, sparks, light
849
- cones: two half-alpha whites make opaque white), "multiply" darkens,
850
- "none" overwrites. Pipeline state, so it is per layer and fixed at
851
- creation; an additive effect layer draws over the scene layer.
986
+ blends with the material's `blend` in record order - "alpha" by
987
+ default, the premultiplied composite; "add" accumulates (glows,
988
+ sparks, light cones: two half-alpha whites make opaque white),
989
+ "multiply" darkens, "none" overwrites. Pipeline state, so it is per
990
+ layer (`material: unlit({ blend: "add" })`) and fixed at creation; an
991
+ additive effect layer draws over the scene layer.
852
992
  The atlas is premultiplied because `decodeImage` premultiplies by default;
853
993
  an atlas uploaded from straight-alpha pixels (`decodeImage(bytes, { alpha:
854
994
  "straight" })` into `createAtlas`) draws color under transparent texels
@@ -884,6 +1024,35 @@ hover, wheel and tap rules headless.
884
1024
  snapping the fit to an integer: the scene should fill its box at any
885
1025
  ratio.
886
1026
 
1027
+ ### Materials
1028
+
1029
+ - A `surface` reads the struct, never a local of the generated program:
1030
+ `s.color` is premultiplied and BEFORE the layer tint (`uTint`
1031
+ multiplies after the slot), so a rewrite to an opaque colour is
1032
+ `vec4(rgb, 1.0) * s.color.a` to keep the sprite's coverage.
1033
+ - A custom `main` (tier 3) starts with `Sprite s = spriteOf();`: the uv
1034
+ derivatives every tap uses are taken there, in uniform control flow.
1035
+ A `spriteSample` before it reads zero derivatives and picks the
1036
+ sharpest mip everywhere.
1037
+ - `spriteSample(uv)` clamps into the FRAME like the stock tap: a uv
1038
+ past the frame's edge lands on the edge texel, never on the next
1039
+ cell. Wrap in frame space (`fract`) before mixing into the frame rect.
1040
+ - The stock stage forwards a material's instance attributes as FLAT
1041
+ varyings named `v` + the name without its `i` - `iPalette` is
1042
+ `vPalette` in the surface; a name not spelled `i` + capital throws at
1043
+ `unlit()`. A custom vertex stage forwards what it likes.
1044
+ - A material's `textures` and the atlases share one sampler budget
1045
+ (`limits.maxTextureUnits`, 16): a LUT and a noise texture leave room
1046
+ for fourteen sheets, and the layer creation throws past it.
1047
+ - A tile layer BAKES: `setParams` on it re-renders every resident chunk,
1048
+ like `setTint`. Animated uniforms belong on a sprite layer, whose
1049
+ params are one shared write per view.
1050
+ - The stock material classes are cached for the app's lifetime (one per
1051
+ blend x prelude x surface x layouts, like 3d's unlit); a
1052
+ `shaderMaterialClass` is yours to `dispose()`, and its programs are
1053
+ compiled per atlas LIST met - two layers over the same atlases share
1054
+ the program, two over different lists compile twice.
1055
+
887
1056
  ### Slots, capacity and order
888
1057
 
889
1058
  - `capacity` is a reservation, not a limit, on both layer kinds; reserve
package/README.md CHANGED
@@ -39,7 +39,7 @@ transitions, groups, picking and the collision queries reach it with no
39
39
  per-frame JS: a sprite write is a target the core animates toward, and
40
40
  the core writes the pose buffer itself. For motion only JS can compute at
41
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
42
+ twenty JS-owned floats per sprite in one `Float32Array`, published by
43
43
  dirty range with the same `records`/`updateRecords`/`setRecordCount`
44
44
  verbs as `@solidrt/3d`'s record mesh - ten thousand float stores and one
45
45
  buffer write, not twenty thousand FFI calls.
@@ -54,8 +54,12 @@ hit-test exact rotated rects, topmost first, with pointer capture.
54
54
 
55
55
  In the package: atlas creation, slicing and extrusion (`createAtlas`,
56
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
57
+ sprite layer with its views and cameras, materials (the stock `unlit`
58
+ with its blend and a `surface` slot over the sprite, `shaderMaterial`
59
+ over the layer's GLSL set, per-sprite style records in any vertex format
60
+ - the same three tiers and the same words as `@solidrt/3d`, the material
61
+ on the layer because the layer is the batch), sprite pointer events, the
62
+ baked tile layer, frame animation, key ordering, world-space text (a sprite
59
63
  font over the runtime's glyph engine is one more atlas, a run of text a
60
64
  group of glyph sprites: `createSpriteFont`, `addText`, `<Text2d>`), and
61
65
  the component face. Staged
@@ -67,7 +67,7 @@ copy one and adapt it.
67
67
  `maxScreenPx`), a label bar that never grows past 24 px (`maxScreenPx`)
68
68
  and a selection ring that never shrinks under 48 px (`minScreenPx`,
69
69
  gliding to the tapped sprite through a position transition), the glow
70
- layer the same atlas on `blend: "add"` with haloes that add up to white
70
+ layer the same atlas on `unlit({ blend: "add" })` with haloes that add up to white
71
71
  where they overlap. Picking follows the drawn size. Debug commands
72
72
  `zoom`, `select` and `pick` drive it headless.
73
73
  - `split-screen.tsx` - one `<SpriteLayer output={false}>` (sprites, no
@@ -1,6 +1,6 @@
1
1
  // Camera-mapping probe: the live guard that the three spellings of the
2
- // camera mapping agree - projectCamera (camera.ts), the vertex stages'
3
- // uCameraRot (shaders.ts), and pointer dispatch's unprojectCamera
2
+ // camera mapping agree - projectCamera (camera.ts), the vertex stage's
3
+ // uCameraRot (glsl.ts), and pointer dispatch's unprojectCamera
4
4
  // (layer.ts). The headless check (tests/camera.test.ts) ties
5
5
  // projectCamera to the <view>-prop oracle but cannot see the shader; this
6
6
  // probe closes that gap on a real client. Self-asserting: watch the logs
@@ -11,8 +11,8 @@
11
11
  // What it checks, all under one rotated + pivoted camera:
12
12
  // 1. The shader implements projectCamera: a probe sprite samples opaque
13
13
  // at its PROJECTED pixel and background at the unrotated position.
14
- // 2. VERTEX (records) and VERTEX_SPLIT (nodes) agree: pixel parity
15
- // between the two layer kinds.
14
+ // 2. The one vertex stage over the two record layouts (records, nodes)
15
+ // agrees with itself: pixel parity between the two layer kinds.
16
16
  // 3. Pointer dispatch round-trips: a pointer at the projected screen
17
17
  // point hits the sprite and reports its world coordinates.
18
18
  // 4. Tile chunks bake with the pinned identity camera rotation.
@@ -7,7 +7,7 @@
7
7
  // px however far in you zoom (maxScreenPx alone), and the selection RING
8
8
  // that never shrinks under 48 px however far out (minScreenPx alone), so
9
9
  // the selected sprite stays findable at the overview. The glow layer is
10
- // the same atlas on a `blend: "add"` pipeline: half-lit haloes that
10
+ // the same atlas on an additive material: half-lit haloes that
11
11
  // accumulate to white where they overlap, over the transparent clear.
12
12
  // Wheel or pinch to zoom, drag empty space to pan, tap a sprite to
13
13
  // select it (the ring glides along), tap empty space to deselect.
@@ -21,7 +21,7 @@
21
21
  // pixels) and the ring's; `pick` ({ x, y }) returns
22
22
  // what the world view hits at those VIEW pixels, as marker kinds.
23
23
  import { createEffect, createInputMap, createPointerFeed, decodeImage, displayScale, onFrame, render, windowSize } from "@solidrt/core"
24
- import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, feedPointer, fitOversample, grid, setSprite, setSpriteTransition } from "@solidrt/2d"
24
+ import { addSprite, camera2dActions, camera2dBindings, createAtlas, createCamera2d, createSpriteLayer, feedPointer, fitOversample, grid, setSprite, setSpriteTransition, unlit } from "@solidrt/2d"
25
25
  import type { Camera2dHandle, SpriteHandle, ViewHandle } from "@solidrt/2d"
26
26
  import { registerDebug } from "sol:dev"
27
27
  import logoBytes from "./logo.png" with { type: "binary" }
@@ -80,7 +80,7 @@ function App() {
80
80
  let frames = grid(atlas, 2, 2)
81
81
  let win = { width: 1, height: 1 }
82
82
  let layer = createSpriteLayer([atlas], { capacity: 128, label: "markers" })
83
- let glow = createSpriteLayer([atlas], { capacity: 64, blend: "add", label: "glow" })
83
+ let glow = createSpriteLayer([atlas], { capacity: 64, material: unlit({ blend: "add" }), label: "glow" })
84
84
  world = layer.createView({ width: win.width, height: win.height, clearColor: [0.05, 0.05, 0.09, 1], label: "world" })
85
85
  let glowView = glow.createView({ width: win.width, height: win.height, clearColor: [0, 0, 0, 0], label: "glow" })
86
86
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@solidrt/2d",
3
- "version": "0.0.71",
3
+ "version": "0.0.72",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "exports": {
10
- ".": "./src/index.ts"
10
+ ".": "./src/index.ts",
11
+ "./glsl": "./src/glsl.ts"
11
12
  },
12
13
  "files": [
13
14
  "src/",
@@ -15,10 +16,10 @@
15
16
  "AGENTS.md"
16
17
  ],
17
18
  "devDependencies": {
18
- "@solidrt/test": "0.0.71"
19
+ "@solidrt/test": "0.0.72"
19
20
  },
20
21
  "peerDependencies": {
21
22
  "@solidjs/signals": "2.0.0-rc.13",
22
- "@solidrt/core": "0.0.71"
23
+ "@solidrt/core": "0.0.72"
23
24
  }
24
25
  }
package/src/animation.ts CHANGED
@@ -6,23 +6,42 @@
6
6
  // nothing - the demand-gate story unchanged. The clock is the app's frame
7
7
  // time: the clip freezes and resumes with the dev tools' clock, plays at
8
8
  // its own speed under `sol render`, and a step lands on the frame it is
9
- // due in. The clip
9
+ // due in; with a `clock` it runs at that layer's, group's or sprite's
10
+ // time rate instead, so one setTimeScale pauses the walk cycles with the
11
+ // world. The clip
10
12
  // does not own its sprites (destroySprite prunes lazily on the next step),
11
13
  // and a sprite belongs to at most one animation. Plain JS over setSprite,
12
14
  // so it works on both layer kinds and composes with <Sprite> via ref (leave
13
15
  // the frame prop off - the clip owns that field).
14
16
  import { getOwner, onCleanup, onFrame, runWithOwner } from "@solidrt/core"
15
17
  import type { Frame } from "./frames.ts"
16
- import type { Sprite } from "./layer.ts"
17
- import { setSprite } from "./layer.ts"
18
+ import type { Sprite, SpriteGroup, SpriteLayer } from "./layer.ts"
19
+ import { setSprite, timeRate } from "./layer.ts"
18
20
 
19
21
  export type AnimationOptions = {
20
22
  /** Wrap around (default) or play once and hold the last frame. */
21
23
  loop?: boolean
24
+ /**
25
+ * Run on this layer's, group's or sprite's time (its `timeRate`, read
26
+ * every frame) instead of plain app time: the clip's step is the frame
27
+ * step times the rate, so a layer paused with `setTimeScale(0)` holds
28
+ * the walk cycles too, and a slow-motion group slows the flipbooks in
29
+ * it. One clock per clip, since every attached sprite shows the same
30
+ * frame; a destroyed clock reads as app time.
31
+ */
32
+ clock?: SpriteLayer | SpriteGroup | Sprite
22
33
  /** Skip the owner-scoped auto-dispose (the core resource contract). */
23
34
  autoFree?: boolean
24
35
  }
25
36
 
37
+ /** The rate a clip's clock runs at now: the handle's own, or 1 without a
38
+ * clock or for one that is gone (a destroyed sprite, a record sprite). */
39
+ function rateOf(clock: SpriteLayer | SpriteGroup | Sprite | undefined): number {
40
+ if (clock === undefined) return 1
41
+ if ("_root" in clock) return clock.timeRate()
42
+ return timeRate(clock) ?? 1
43
+ }
44
+
26
45
  export type SpriteAnimation = {
27
46
  /** Current frame index into the clip. */
28
47
  frame(): number
@@ -66,6 +85,7 @@ export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOp
66
85
  throw new Error(`createAnimation: fps must be positive, got ${fps}`)
67
86
  }
68
87
  let loop = opts?.loop !== false
88
+ let clock = opts?.clock
69
89
  let sprites = new Set<Sprite>()
70
90
  let current = 0
71
91
  let playing = true
@@ -116,8 +136,9 @@ export function createAnimation(frames: Frame[], fps: number, opts?: AnimationOp
116
136
  }
117
137
 
118
138
  function tick(now: number): void {
119
- // Never backward: across a hot reload the frame time restarts.
120
- if (lastTick !== null) elapsed += Math.max(0, now - lastTick)
139
+ // Never backward: across a hot reload the frame time restarts. The
140
+ // step is the frame step on the clock's time (rateOf: 1 without one).
141
+ if (lastTick !== null) elapsed += Math.max(0, now - lastTick) * rateOf(clock)
121
142
  lastTick = now
122
143
  let raw = Math.floor((elapsed * fps) / 1000)
123
144
  if (!loop && raw >= frames.length) {
package/src/atlas.ts CHANGED
@@ -82,16 +82,20 @@ export function isAtlas(a: unknown): a is Atlas {
82
82
  * Validate a layer's `atlases` list (throws - the dev validation policy):
83
83
  * a non-empty array of distinct atlas records, no more than the device
84
84
  * binds in one pass (`limits.maxTextureUnits`, 16 on every GLES 3.0
85
- * device). Returns the texture -> sampler index map the layer resolves
85
+ * device) once the material's own `samplers` (its textures: a palette
86
+ * LUT, a noise texture) are counted in - the two lists bind on the same
87
+ * targets. Returns the texture -> sampler index map the layer resolves
86
88
  * frames through; the index is the atlas's position in the list.
87
89
  * Internal - every layer kind calls it.
88
90
  */
89
- export function checkAtlases(verb: string, atlases: readonly Atlas[]): Map<TextureId, number> {
91
+ export function checkAtlases(verb: string, atlases: readonly Atlas[], samplers = 0): Map<TextureId, number> {
90
92
  if (!Array.isArray(atlases) || atlases.length === 0) {
91
93
  throw new Error(`${verb}: atlases must be a non-empty array of atlas records (createAtlas, or { texture, width, height })`)
92
94
  }
93
- if (atlases.length > limits.maxTextureUnits) {
94
- throw new Error(`${verb}: ${atlases.length} atlases exceed the ${limits.maxTextureUnits} sampler inputs one pass may bind (limits.maxTextureUnits); pack sheets together`)
95
+ if (atlases.length + samplers > limits.maxTextureUnits) {
96
+ throw new Error(
97
+ `${verb}: ${atlases.length} atlases${samplers > 0 ? ` plus the material's ${samplers} textures` : ""} exceed the ${limits.maxTextureUnits} sampler inputs one pass may bind (limits.maxTextureUnits); pack sheets together`,
98
+ )
95
99
  }
96
100
  let index = new Map<TextureId, number>()
97
101
  atlases.forEach((atlas, i) => {
package/src/camera.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // imports, so its test (tests/camera.test.ts) exercises this
4
4
  // module headless on the flux binary. Three consumers implement or undo
5
5
  // exactly this mapping and must agree with it: the vertex stages
6
- // (shaders.ts, uCamera + uCameraRot), the tile layer's composite
6
+ // (glsl.ts, uCamera + uCameraRot), the tile layer's composite
7
7
  // transform (the <view> props in components/tile-layer.tsx) and the pointer inverse
8
8
  // (spriteDispatch in layer.ts, which calls unprojectCamera directly). The
9
9
  // check keeps the spellings from drifting.
@@ -101,7 +101,7 @@ export function applyCamera(camera: CameraState, update: CameraUpdate): void {
101
101
  if (update.pivotY !== undefined) camera.pivotY = update.pivotY
102
102
  }
103
103
 
104
- /** The shared params the vertex stages (shaders.ts) read for a camera:
104
+ /** The shared params the vertex stage (glsl.ts) reads for a camera:
105
105
  * uCamera [x, y, zoom, zoom] and uCameraRot [cos, sin, pivotX, pivotY]. */
106
106
  export function cameraParams(camera: CameraState): { uCamera: [number, number, number, number]; uCameraRot: [number, number, number, number] } {
107
107
  return {
@@ -1,4 +1,4 @@
1
- import { createEffect, merge, onFrame, untrack } from "@solidrt/core"
1
+ import { callRef, createEffect, merge, onFrame, untrack } from "@solidrt/core"
2
2
  import type { InputMap, VoidComponent } from "@solidrt/core"
3
3
  import { createCamera2d } from "../camera2d.ts"
4
4
  import type { Camera2d as Camera2dHandle, Camera2dAxes, Camera2dOptions } from "../camera2d.ts"
@@ -72,6 +72,6 @@ export let Camera2d: VoidComponent<Camera2dProps> = props => {
72
72
  () => untrack(() => cam.set({})),
73
73
  { defer: true },
74
74
  )
75
- untrack(() => props.ref)?.(cam)
75
+ callRef(() => props.ref, cam)
76
76
  return null
77
77
  }