@solidrt/2d 0.0.52
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 +219 -0
- package/README.md +51 -0
- package/examples/README.md +21 -0
- package/examples/logo.png +0 -0
- package/examples/parity.tsx +204 -0
- package/examples/pick.tsx +90 -0
- package/examples/springs.tsx +69 -0
- package/examples/sprites.tsx +73 -0
- package/examples/tiles.tsx +102 -0
- package/package.json +21 -0
- package/src/atlas.ts +52 -0
- package/src/components.tsx +287 -0
- package/src/frames.ts +78 -0
- package/src/index.ts +29 -0
- package/src/layer.ts +788 -0
- package/src/pick.ts +22 -0
- package/src/records.ts +294 -0
- package/src/shaders.ts +93 -0
- package/src/tiles.ts +281 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# @solidrt/2d - agent notes
|
|
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>`
|
|
5
|
+
leaf. The live layer backs every sprite with a SPATIAL ARENA node (never a
|
|
6
|
+
rendertree element - `d-texture` sprites are the right tool up to the low
|
|
7
|
+
thousands; measured ~0.65us paint and ~15KB memory per NODE, and every
|
|
8
|
+
moved node is two setProperty FFI calls per frame). The node makes sprites
|
|
9
|
+
citizens of the spatial core: native producers (node transitions, animation
|
|
10
|
+
clips, physics) reach them through `sprite.node`, hierarchy recomputes
|
|
11
|
+
moved subtrees in Rust, and picking walks the core BVH.
|
|
12
|
+
|
|
13
|
+
## The model
|
|
14
|
+
|
|
15
|
+
- THREE faces, layered: the node-backed live layer (layer.ts:
|
|
16
|
+
`createSpriteLayer`/`addSprite`/`setSprite`/`removeSprite` plus
|
|
17
|
+
`addGroup`/`setGroup`/`setSpriteParent`/`setSpriteTransition`/
|
|
18
|
+
`setGroupTransition` - plain objects, no signals, usable without
|
|
19
|
+
components), the records layer (records.ts: `createRecordLayer` - the
|
|
20
|
+
raw escape hatch, below), and the component face (components.tsx:
|
|
21
|
+
`SpriteLayer`/`Sprite`/`Group` over context).
|
|
22
|
+
- Node layer ownership split, two instance-buffer slots on one pipeline:
|
|
23
|
+
slot 0 is the POSE buffer `[x, y, angle, sx, sy]` written ONLY by the
|
|
24
|
+
core (each sprite node's Pose2D record sink; one coalesced buffer write
|
|
25
|
+
per flush however many nodes moved), slot 1 the STYLE buffer
|
|
26
|
+
`[u0, v0, u1, v1, tint rgba]`, JS-owned, published through the zero-copy
|
|
27
|
+
write lease. NEVER write the pose buffer from JS - the core's staging
|
|
28
|
+
mirror owns it and will overwrite.
|
|
29
|
+
- Sprites hold FIXED instance slots: draw order is slot order, removal
|
|
30
|
+
zeroes the pose (zero scale = nothing drawn) and recycles the slot to
|
|
31
|
+
the next add. No painter's-insertion-order guarantee across removals;
|
|
32
|
+
opaque-or-transparent pixel art never notices, z-ordered translucency is
|
|
33
|
+
the sort-key backlog item (okf/backlog/2d-sprite-sort-key.md).
|
|
34
|
+
- Growth (past `capacity`, doubling): pose sinks move in ONE core
|
|
35
|
+
`retargetRecords` call (full republish next flush), style re-uploads,
|
|
36
|
+
`setDraw({ instanceBuffers })` swaps both, old buffers destroyed.
|
|
37
|
+
- Picking is the core index: `pick` raycasts [x, y, -1] along +z (exact
|
|
38
|
+
rotated-rect via the node's local box), topmost = highest slot;
|
|
39
|
+
`pickRect` is the BVH overlap query (exact for rotated sprites, the
|
|
40
|
+
marquee). Both filter to the layer's own nodes - the arena is shared
|
|
41
|
+
with e.g. a 3d scene.
|
|
42
|
+
- Groups (`addGroup`/`<Group>`) are plain arena nodes (x, y, rotation,
|
|
43
|
+
UNIFORM scale - a group is a frame, never a sprite size; sprite w/h
|
|
44
|
+
lives in the sprite node's scale, which is why sprites cannot parent
|
|
45
|
+
sprites). Child sprite pose fields are local to the group.
|
|
46
|
+
- Mutations batch to a microtask: style lease publish + count setDraw +
|
|
47
|
+
`spatial.flush()`. No mutation, no publish, no frame: a static layer
|
|
48
|
+
costs zero, the same demand-gate story as the rest of the platform.
|
|
49
|
+
- The records layer (`createRecordLayer`) keeps the old model whole: 13
|
|
50
|
+
JS-owned floats per sprite `[cx, cy, w, h, u0, v0, u1, v1, rot, tint
|
|
51
|
+
rgba]` (`FLOATS_PER_SPRITE`), draw order = insertion order, remove
|
|
52
|
+
shifts, `layer.records` + `touch()` raw writes, JS pick walk. It is the
|
|
53
|
+
escape hatch for motion only JS can compute at scale (measured 30k
|
|
54
|
+
sprites: 12.9ms raw records vs 30.8ms via setSprite) - the axis is
|
|
55
|
+
WHERE MOTION IS COMPUTED, not retained-vs-dynamic. The sprite functions
|
|
56
|
+
(addSprite/setSprite/getSprite/removeSprite) work on both layer kinds;
|
|
57
|
+
record sprites have `node: null` and no groups.
|
|
58
|
+
- Layer space is pixels, top-left origin, y-down - the render tree's frame.
|
|
59
|
+
The pipeline's clip space is y-down too (core gpu.ts pixel contract), so
|
|
60
|
+
the vertex stage carries NO flip anywhere. Do not add one.
|
|
61
|
+
- The camera (`setCamera`/the `camera` prop) is a shared-params write
|
|
62
|
+
(`uCamera`: offset + zoom), one call however many sprites exist. Picking
|
|
63
|
+
undoes it, so events arrive in world (layer) pixels.
|
|
64
|
+
- Retargeted motion is NATIVE: `setSpriteTransition(sprite, { position:
|
|
65
|
+
{ duration: 700, bounce: 0.3 }, ... })` (or the `transition` prop) makes
|
|
66
|
+
setSprite writes TARGETS the core animates toward - position/scale
|
|
67
|
+
(w/h) on the shared spring/tween math, rotation along the quaternion
|
|
68
|
+
geodesic (always the short arc; a spring keeps its velocity through
|
|
69
|
+
retargets). JS costs one write per target CHANGE, zero per frame; the
|
|
70
|
+
running tracks drive frame demand themselves, and settled sprites cost
|
|
71
|
+
nothing (bench: 400 retargets ~4ms, once a second - vs ~5ms per FRAME
|
|
72
|
+
moving the same population imperatively). Mount poses always snap (the
|
|
73
|
+
component declares the transition after the first pose sync; the
|
|
74
|
+
function face sets it after addSprite). Each natural settle calls the
|
|
75
|
+
handle's `onTransitionEnd` (plain field, or the `<Sprite>`/`<Group>` prop)
|
|
76
|
+
with `{ component }` - target-only, never on a cancel, snap or removal;
|
|
77
|
+
the raw "spatialTransitionEnd" engine event (srt:events, node =
|
|
78
|
+
sprite.node) stays for flux:spatial consumers. See examples/springs.tsx.
|
|
79
|
+
- Frame-rate motion only JS can compute (physics, flocking) bypasses the
|
|
80
|
+
declarative layer: `ref` the sprite, call `setSprite` from `onFrame` (a
|
|
81
|
+
~7us core transform write per moved sprite - fine to a few thousand;
|
|
82
|
+
past that use the records layer). Signals carry structure and slow
|
|
83
|
+
state - a `<Sprite x={sig()}>` re-running 60 times a second works but
|
|
84
|
+
re-runs an effect per sprite per frame for nothing.
|
|
85
|
+
- frames.ts and pick.ts are pure (no GPU imports) BY DESIGN so they can be
|
|
86
|
+
checked headless; keep them that way.
|
|
87
|
+
|
|
88
|
+
## The baked tile layer (tiles.ts)
|
|
89
|
+
|
|
90
|
+
Static 2D bulk as a few quads: on tiled GPUs the budget is primitive count
|
|
91
|
+
(core agents/performance.md), so a 100x100 tile world must not be 10,000
|
|
92
|
+
quads per frame. `createTileLayer(cols, rows, tileW, tileH, atlas)` bakes
|
|
93
|
+
the world into CHUNKED `render: "manual"` targets (default ~512px of tiles
|
|
94
|
+
per chunk, `chunkTiles` to tune), each chunk a small copy of the sprite
|
|
95
|
+
pipeline (shaders.ts) with FIXED record slots - an empty tile is a
|
|
96
|
+
zero-size quad, instance count is constant per chunk. Records hold WORLD
|
|
97
|
+
pixel coordinates; each chunk target's `uCamera` is its pixel origin, so
|
|
98
|
+
the shared vertex stage does the chunk-local mapping. Chunks allocate on
|
|
99
|
+
the first `setTile` that reaches them - an empty chunk costs nothing, a
|
|
100
|
+
sparse world is bounded by its content, and world size is bounded by
|
|
101
|
+
memory, not `maxTextureSize`. `setTile` batches to a microtask whose flush
|
|
102
|
+
publishes and re-bakes ONLY dirty chunks. After that the layer is static
|
|
103
|
+
textures: zero per-frame cost however many tiles exist.
|
|
104
|
+
|
|
105
|
+
Scrolling never re-bakes: the `<TileLayer>` camera prop (`TileCamera`) is
|
|
106
|
+
a transform on the composited world view - the world point (x, y) pinned
|
|
107
|
+
to the viewport point (pivotX, pivotY), scaled by zoom, ROTATED by
|
|
108
|
+
rotation about the pivot. Pivot (0,0) makes `{x, y, zoom}` mean what the
|
|
109
|
+
sprite layer's camera means, so one signal drives both; rotation is the
|
|
110
|
+
ship-flies-over-the-map camera and costs the same transform write. The
|
|
111
|
+
grid shape is creation-fixed (recreate to resize). Tiles are data, not
|
|
112
|
+
children: there is no `<Tile>` component on purpose - write cells through
|
|
113
|
+
`ref` with `setTile`. Not built yet: camera-driven residency (bake far
|
|
114
|
+
chunks on approach, evict) - okf/backlog/2d-baked-layers.md.
|
|
115
|
+
|
|
116
|
+
## Components
|
|
117
|
+
|
|
118
|
+
| Component | Props |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `SpriteLayer` | width, height (layer pixels), atlas (TextureId), capacity?, clearColor?, camera?, label?, ref?, output?, events? |
|
|
121
|
+
| `Sprite` | x, y (center; local to the enclosing `<Group>`), w, h, frame?, rotation? (radians, clockwise), tint? ([r,g,b,a] 0..1), transition?, onPointer{Down,Move,Up,Enter,Leave}?, ref? |
|
|
122
|
+
| `Group` | x?, y?, rotation?, scale? (uniform, scales the subtree), transition?, ref? |
|
|
123
|
+
| `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), clearColor?, filter?, chunkTiles?, camera? (TileCamera: x, y, zoom, rotation, pivotX, pivotY), label?, ref? |
|
|
124
|
+
|
|
125
|
+
`SpriteLayer` owns the layer and renders the built-in `<texture>` leaf
|
|
126
|
+
carrying the layer's pointer handlers (opt out with `events={false}`; compose
|
|
127
|
+
yourself with `output`, then spread `useSpriteLayer().handlers` onto your
|
|
128
|
+
leaf). `Sprite` renders nothing - it allocates a record through context and
|
|
129
|
+
syncs props into it.
|
|
130
|
+
`GroupContext` is `createContext<SpriteGroup | null>(null)` on purpose: an
|
|
131
|
+
optional parent needs a non-undefined default, since Solid 2 throws on a
|
|
132
|
+
resolved `undefined` even when one was passed as the default.
|
|
133
|
+
|
|
134
|
+
Pointer events: exact rotated-rect containment, topmost sprite first, capture
|
|
135
|
+
per pointerId (a drag keeps delivering to the grabbed sprite with live
|
|
136
|
+
coordinates), enter/leave paired per pointer. No bubbling - the sprite list
|
|
137
|
+
is flat. Event x/y are layer pixels with the camera undone.
|
|
138
|
+
|
|
139
|
+
## Traps
|
|
140
|
+
|
|
141
|
+
- The atlas is NOT owned by the layer: layers come and go, atlases usually
|
|
142
|
+
live app-long. Dispose atlases yourself (or let the reactive owner do it -
|
|
143
|
+
createAtlas registers with the owning scope like every core texture).
|
|
144
|
+
- `capacity` is a reservation, not a limit, on both layer kinds; reserve
|
|
145
|
+
realistically to skip the growth copies. On the records layer, do not
|
|
146
|
+
cache `layer.records` across addSprite - growth replaces the array.
|
|
147
|
+
- Node layer: `sprite.node` is public FOR BINDING PRODUCERS, not for
|
|
148
|
+
lifecycle - never destroyNode it yourself (removeSprite owns that), and
|
|
149
|
+
a transform written through flux:spatial directly bypasses the sprite's
|
|
150
|
+
pose mirror, so a later setSprite with the old x wins (its compare sees
|
|
151
|
+
no change to skip, but partial writes compose from the mirror).
|
|
152
|
+
- With a transition set, the sprite's fields (and getSprite) read the
|
|
153
|
+
TARGET, not the mid-flight pose - the JS mirror is what setSprite
|
|
154
|
+
composes partial writes from, and targets are the right thing to
|
|
155
|
+
compose. Picking and the pose buffer see the actual mid-flight pose
|
|
156
|
+
(what is on screen). Clearing the transition (null) keeps the
|
|
157
|
+
mid-flight pose on the node while the mirror still holds the old
|
|
158
|
+
target: the next setSprite write snaps to whatever it says.
|
|
159
|
+
- Node layer picking reads the index as of the last core flush; `pick`/
|
|
160
|
+
`pickRect` run the layer's pending batch first, so write-then-pick in
|
|
161
|
+
one tick is coherent. Producers moving nodes between flushes are one
|
|
162
|
+
frame stale to picking, like every query.
|
|
163
|
+
- Records layer: record order is draw order: `removeSprite` shifts every
|
|
164
|
+
later sprite down one slot (copyWithin + index fixup, O(later
|
|
165
|
+
sprites)). Its flush publishes the WHOLE live prefix, not a dirty
|
|
166
|
+
range: one moved sprite re-publishes count x 52 bytes - a single
|
|
167
|
+
memcpy, microseconds at 10k; the node layer's style publish is the same
|
|
168
|
+
whole-prefix shape. Dirty ranges were deliberately not built until a
|
|
169
|
+
measurement asks.
|
|
170
|
+
- The node layer's STYLE slots are not compacted: a removed sprite leaves
|
|
171
|
+
its style floats in place (invisible - the pose is zeroed) until the
|
|
172
|
+
slot recycles. Do not read style truth from the buffer; getSprite reads
|
|
173
|
+
the JS mirror.
|
|
174
|
+
- `createImage` is the wrong loader for pixel-art atlases: it never forwards
|
|
175
|
+
sampler options, so it is always `filter: "linear"`. `createAtlas` decodes
|
|
176
|
+
bytes and passes `filter: "nearest"` through - use it, or `decodeImage` +
|
|
177
|
+
`createTexture` directly.
|
|
178
|
+
- Tint multiplies the sampled texel (`texture * tint`) and the pipeline
|
|
179
|
+
blends with `blend: "alpha"` in record order. The layer's OUTPUT obeys the
|
|
180
|
+
premultiplied-alpha contract to the extent the atlas does: PNG decode
|
|
181
|
+
yields straight alpha, and a translucent texel tinted translucent can
|
|
182
|
+
composite slightly wrong at the edges. Opaque-or-transparent pixel art
|
|
183
|
+
(the overwhelming case) is exact. A premultiply-on-decode option is the
|
|
184
|
+
fix if it ever matters; note it, do not silently add it.
|
|
185
|
+
- `pointInSprite` in pick.ts and the vertex stage's rotation must agree on
|
|
186
|
+
direction (clockwise, y-down). The differential check (pick-check.ts)
|
|
187
|
+
guards the math against an oracle but NOT against the shader - if you
|
|
188
|
+
touch one rotation, touch both.
|
|
189
|
+
- Sprite handles go inert on removal (`sprite.layer === null`); setSprite on
|
|
190
|
+
an inert handle is a silent no-op (matching the throw-in-dev policy would
|
|
191
|
+
mean throwing, but removal racing a queued pointer event is routine, not
|
|
192
|
+
a bug).
|
|
193
|
+
- The `<Sprite>` effect syncs ALL seven fields when ANY prop changes (one
|
|
194
|
+
effect, one tuple). Fine at component scale; if a profile ever blames it,
|
|
195
|
+
split the effects before inventing anything cleverer.
|
|
196
|
+
- `<TileLayer>`'s world view is WORLD sized (cols * tileW) and takes that
|
|
197
|
+
much layout space: put it inside a clipping container (`overflow="clip"`)
|
|
198
|
+
sized to the viewport, or camera panning shows the world hanging out of
|
|
199
|
+
the box.
|
|
200
|
+
- `clearColor` is PER CHUNK: never-written regions have no chunk and render
|
|
201
|
+
nothing, so a full-bleed ground color belongs on the container behind the
|
|
202
|
+
layer (a `d-rect` under it), not on clearColor.
|
|
203
|
+
- Tile layer zoom/rotation scale the BAKED chunk textures at composite time
|
|
204
|
+
(the sprite layer's zoom re-samples the atlas in-shader), so the tile
|
|
205
|
+
layer's `filter` option is what pixel art must set to "nearest" - on top
|
|
206
|
+
of the atlas's own nearest from createAtlas; they are different samplers.
|
|
207
|
+
- A dirty chunk flush publishes and re-bakes that chunk in full. Fine on
|
|
208
|
+
change-only cadence; per-frame setTile churn re-bakes chunks per frame -
|
|
209
|
+
that is sprite-layer work, not tile work.
|
|
210
|
+
- Chunk allocation is MONOTONIC: nothing evicts, so texture memory is
|
|
211
|
+
proportional to the touched area (~920KB per resident chunk at the
|
|
212
|
+
default size) and every resident chunk keeps a composited leaf. Bounded
|
|
213
|
+
worlds only; streaming/infinite is stage B2 in
|
|
214
|
+
okf/backlog/2d-baked-layers.md.
|
|
215
|
+
- The sprite layer's camera cannot rotate (uCamera is offset + zoom); a
|
|
216
|
+
sprite layer riding a rotating TileCamera needs
|
|
217
|
+
okf/backlog/2d-sprite-camera-rotation.md first. Rotating the sprite
|
|
218
|
+
layer's OUTPUT leaf instead is wrong - it is viewport-sized, the corners
|
|
219
|
+
cut.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @solidrt/2d
|
|
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.
|
|
6
|
+
|
|
7
|
+
*Status: experimental. Expect API churn.*
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { render } from "@solidrt/core"
|
|
11
|
+
import { createAtlas, grid, Sprite, SpriteLayer } from "@solidrt/2d"
|
|
12
|
+
import sheet from "./sheet.png" with { type: "binary" }
|
|
13
|
+
|
|
14
|
+
let atlas = createAtlas(sheet, { filter: "nearest" })
|
|
15
|
+
let frames = grid(4, 4, { width: atlas.width, height: atlas.height })
|
|
16
|
+
|
|
17
|
+
render(() => (
|
|
18
|
+
<window>
|
|
19
|
+
<SpriteLayer width={720} height={480} atlas={atlas.texture}>
|
|
20
|
+
<Sprite x={100} y={120} w={32} h={32} frame={frames[0]} />
|
|
21
|
+
<Sprite x={200} y={160} w={32} h={32} frame={frames[5]} rotation={0.4} />
|
|
22
|
+
</SpriteLayer>
|
|
23
|
+
</window>
|
|
24
|
+
))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The rendertree already handles 2D well: a `d-texture` with atlas sub-rects is
|
|
28
|
+
a sprite, with native transitions, gesture-arena pointer events, and
|
|
29
|
+
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.
|
|
36
|
+
|
|
37
|
+
Two layers, like `@solidrt/3d`: an imperative core
|
|
38
|
+
(`createSpriteLayer` / `addSprite` / `setSprite` - per-frame motion calls
|
|
39
|
+
these from `onFrame`) and the component face (`SpriteLayer` / `Sprite`) for
|
|
40
|
+
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/`.
|
|
49
|
+
|
|
50
|
+
The full model and the sharp edges are in [AGENTS.md](AGENTS.md); runnable
|
|
51
|
+
patterns in [examples/](examples/).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @solidrt/2d examples
|
|
2
|
+
|
|
3
|
+
Single-concept sprite-layer patterns. Each file is a complete, runnable app
|
|
4
|
+
(`bunx srt run <file>` from the repo root) demonstrating exactly one thing -
|
|
5
|
+
copy one and adapt it.
|
|
6
|
+
|
|
7
|
+
- `sprites.tsx` - the layer at its natural scale: 500 sprites bouncing at
|
|
8
|
+
frame rate, moved imperatively with `setSprite` from `onFrame` while the
|
|
9
|
+
tree holds one texture leaf. Atlas from PNG bytes (`createAtlas`) sliced
|
|
10
|
+
2x2 with `grid()`.
|
|
11
|
+
- `tiles.tsx` - the baked tile layer: a 128x128 world (6144px - bigger than
|
|
12
|
+
one texture may be) baked into lazily-allocated chunks, flown over by a
|
|
13
|
+
ship-style camera (fixed screen pivot, the world panning and ROTATING
|
|
14
|
+
under it via the `<TileLayer>` camera prop - transform writes, never a
|
|
15
|
+
re-bake), and a timer editing tiles to show that a `setTile` batch
|
|
16
|
+
re-bakes only the chunks it touches.
|
|
17
|
+
- `pick.tsx` - sprite pointer events through the component face: exact
|
|
18
|
+
rotated-rect hit testing topmost-first, pointer capture (drag a sprite and
|
|
19
|
+
the events keep naming it), click-vs-drag slop, and removal through a
|
|
20
|
+
signal so `<For>` unmounts the `<Sprite>` and the layer compacts its draw
|
|
21
|
+
order.
|
|
Binary file
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Node-vs-records parity probe: the same deterministic sprite population
|
|
2
|
+
// rendered through the node-backed live layer (spatial arena nodes, pose
|
|
3
|
+
// buffer core-written) and the records layer (13 JS-owned floats), side by
|
|
4
|
+
// side. Self-asserting: after the first frames it readTexture-compares the
|
|
5
|
+
// two outputs pixel by pixel (the pose round-trips through the core's
|
|
6
|
+
// Pose2D decomposition, so only alpha-edge pixels may flip), cross-checks
|
|
7
|
+
// picking at random points, exercises pickRect, groups and slot recycling,
|
|
8
|
+
// and benches addSprite plus a move-everything frame on both layers. Watch
|
|
9
|
+
// the logs for PARITY/BENCH lines ending in PARITY-OK.
|
|
10
|
+
import { onFrame, render } from "@solidrt/core"
|
|
11
|
+
import { readTexture } from "@solidrt/core/gpu"
|
|
12
|
+
import {
|
|
13
|
+
addGroup,
|
|
14
|
+
addSprite,
|
|
15
|
+
createAtlas,
|
|
16
|
+
createRecordLayer,
|
|
17
|
+
createSpriteLayer,
|
|
18
|
+
getSprite,
|
|
19
|
+
grid,
|
|
20
|
+
removeSprite,
|
|
21
|
+
setGroup,
|
|
22
|
+
setSprite,
|
|
23
|
+
} from "@solidrt/2d"
|
|
24
|
+
import type { SpriteHandle } from "@solidrt/2d"
|
|
25
|
+
import logoBytes from "./logo.png" with { type: "binary" }
|
|
26
|
+
|
|
27
|
+
const N = 200
|
|
28
|
+
const W = 360
|
|
29
|
+
const H = 360
|
|
30
|
+
const SEED = 0x2d2d
|
|
31
|
+
|
|
32
|
+
// Deterministic PRNG (mulberry32) so both layers see identical sprites.
|
|
33
|
+
function rng(seed: number): () => number {
|
|
34
|
+
let a = seed >>> 0
|
|
35
|
+
return () => {
|
|
36
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
37
|
+
let t = a
|
|
38
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
39
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
40
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function App() {
|
|
45
|
+
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
46
|
+
let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
|
|
47
|
+
let nodes = createSpriteLayer(W, H, atlas.texture, { capacity: 64, clearColor: [0.05, 0.05, 0.09, 1], label: "nodes" })
|
|
48
|
+
let records = createRecordLayer(W, H, atlas.texture, { capacity: 64, clearColor: [0.05, 0.05, 0.09, 1], label: "records" })
|
|
49
|
+
|
|
50
|
+
let fields = (r: () => number) => ({
|
|
51
|
+
x: 20 + r() * (W - 40),
|
|
52
|
+
y: 20 + r() * (H - 40),
|
|
53
|
+
w: 16 + r() * 48,
|
|
54
|
+
h: 16 + r() * 48,
|
|
55
|
+
rotation: r() * Math.PI * 2,
|
|
56
|
+
frame: frames[Math.floor(r() * 4) % 4]!,
|
|
57
|
+
tint: [0.5 + r() * 0.5, 0.5 + r() * 0.5, 0.5 + r() * 0.5, 1] as [number, number, number, number],
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Population (the capacity of 64 forces both growth paths, including the
|
|
61
|
+
// pose-buffer retarget).
|
|
62
|
+
let nodeSprites: SpriteHandle[] = []
|
|
63
|
+
let recSprites: SpriteHandle[] = []
|
|
64
|
+
let r1 = rng(SEED)
|
|
65
|
+
let t0 = performance.now()
|
|
66
|
+
for (let i = 0; i < N; i++) nodeSprites.push(addSprite(nodes, fields(r1)))
|
|
67
|
+
let tNodesAdd = performance.now() - t0
|
|
68
|
+
let r2 = rng(SEED)
|
|
69
|
+
t0 = performance.now()
|
|
70
|
+
for (let i = 0; i < N; i++) recSprites.push(addSprite(records, fields(r2)))
|
|
71
|
+
let tRecsAdd = performance.now() - t0
|
|
72
|
+
console.log(`BENCH addSprite x${N}: nodes ${tNodesAdd.toFixed(2)}ms, records ${tRecsAdd.toFixed(2)}ms`)
|
|
73
|
+
|
|
74
|
+
let failures: string[] = []
|
|
75
|
+
let check = (ok: boolean, what: string) => {
|
|
76
|
+
if (!ok) failures.push(what)
|
|
77
|
+
console.log(`PARITY ${ok ? "ok" : "FAIL"}: ${what}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Slot recycling: remove one, add another, the layer stays coherent.
|
|
81
|
+
{
|
|
82
|
+
let extra = addSprite(nodes, { x: 10, y: 10, w: 8, h: 8 })
|
|
83
|
+
let slotBefore = extra._slot
|
|
84
|
+
removeSprite(extra)
|
|
85
|
+
let reused = addSprite(nodes, { x: -100, y: -100, w: 0, h: 0 })
|
|
86
|
+
check(reused._slot === slotBefore, "removed slot recycles to the next add")
|
|
87
|
+
check(getSprite(extra) === null, "removed handle is inert")
|
|
88
|
+
removeSprite(reused)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let pixelParity = () => {
|
|
92
|
+
let a = readTexture(nodes.texture)
|
|
93
|
+
let b = readTexture(records.texture)
|
|
94
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
95
|
+
check(false, `sizes differ: ${a.width}x${a.height} vs ${b.width}x${b.height}`)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
let off = 0
|
|
99
|
+
for (let i = 0; i < a.data.length; i += 4) {
|
|
100
|
+
if (
|
|
101
|
+
Math.abs(a.data[i]! - b.data[i]!) > 8 ||
|
|
102
|
+
Math.abs(a.data[i + 1]! - b.data[i + 1]!) > 8 ||
|
|
103
|
+
Math.abs(a.data[i + 2]! - b.data[i + 2]!) > 8 ||
|
|
104
|
+
Math.abs(a.data[i + 3]! - b.data[i + 3]!) > 8
|
|
105
|
+
) {
|
|
106
|
+
off++
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
let total = a.data.length / 4
|
|
110
|
+
let pct = (off / total) * 100
|
|
111
|
+
check(pct < 0.5, `pixels within tolerance (${off}/${total} off, ${pct.toFixed(3)}%)`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let pickParity = () => {
|
|
115
|
+
let r = rng(SEED ^ 0xffff)
|
|
116
|
+
let byNode = new Map(nodeSprites.map((s, i) => [s, i]))
|
|
117
|
+
let byRec = new Map(recSprites.map((s, i) => [s, i]))
|
|
118
|
+
let mismatches = 0
|
|
119
|
+
for (let i = 0; i < 200; i++) {
|
|
120
|
+
let x = r() * W
|
|
121
|
+
let y = r() * H
|
|
122
|
+
let a = nodes.pick(x, y)
|
|
123
|
+
let b = records.pick(x, y)
|
|
124
|
+
let ai = a ? byNode.get(a) : null
|
|
125
|
+
let bi = b ? byRec.get(b) : null
|
|
126
|
+
if (ai !== bi) mismatches++
|
|
127
|
+
}
|
|
128
|
+
check(mismatches <= 1, `pick agrees at random points (${mismatches} mismatches)`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let rectQuery = () => {
|
|
132
|
+
// Left half of the layer: every hit must at least touch it by AABB,
|
|
133
|
+
// every sprite fully inside it must be reported.
|
|
134
|
+
let hits = new Set(nodes.pickRect(0, 0, W / 2, H))
|
|
135
|
+
let missed = 0
|
|
136
|
+
let phantom = 0
|
|
137
|
+
for (let s of nodeSprites) {
|
|
138
|
+
let f = getSprite(s)!
|
|
139
|
+
let radius = (Math.hypot(f.w, f.h) / 2) * 1.001
|
|
140
|
+
let inside = f.x + radius <= W / 2 && f.x - radius >= 0 && f.y - radius >= 0 && f.y + radius <= H
|
|
141
|
+
let touches = f.x - radius <= W / 2
|
|
142
|
+
if (inside && !hits.has(s)) missed++
|
|
143
|
+
if (!touches && hits.has(s)) phantom++
|
|
144
|
+
}
|
|
145
|
+
check(missed === 0 && phantom === 0, `pickRect covers the left half (${hits.size} hits, ${missed} missed, ${phantom} phantom)`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let groups = () => {
|
|
149
|
+
// A group at (100, 100) rotated 90deg clockwise: a child sprite at
|
|
150
|
+
// local (50, 0) lands at world (100, 150) in y-down space.
|
|
151
|
+
let g = addGroup(nodes, { x: 100, y: 100 })
|
|
152
|
+
let child = addSprite(nodes, { parent: g, x: 50, y: 0, w: 10, h: 10 })
|
|
153
|
+
setGroup(g, { rotation: Math.PI / 2 })
|
|
154
|
+
queueMicrotask(() => {
|
|
155
|
+
check(nodes.pick(100, 150) === child, "group rotation carries the child sprite")
|
|
156
|
+
check(nodes.pick(150, 100) !== child, "the ungrouped position no longer hits")
|
|
157
|
+
let f = getSprite(child)!
|
|
158
|
+
check(f.x === 50 && f.y === 0, "getSprite reads the local pose")
|
|
159
|
+
removeSprite(child)
|
|
160
|
+
// The group node stays for the layer to dispose.
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let bench = () => {
|
|
165
|
+
let r = rng(SEED ^ 0xabcd)
|
|
166
|
+
let t = performance.now()
|
|
167
|
+
for (let i = 0; i < N; i++) setSprite(nodeSprites[i]!, { x: 20 + r() * (W - 40), y: 20 + r() * (H - 40) })
|
|
168
|
+
let tNodes = performance.now() - t
|
|
169
|
+
r = rng(SEED ^ 0xabcd)
|
|
170
|
+
t = performance.now()
|
|
171
|
+
for (let i = 0; i < N; i++) setSprite(recSprites[i]!, { x: 20 + r() * (W - 40), y: 20 + r() * (H - 40) })
|
|
172
|
+
let tRecs = performance.now() - t
|
|
173
|
+
console.log(`BENCH move x${N}: nodes ${tNodes.toFixed(2)}ms, records ${tRecs.toFixed(2)}ms`)
|
|
174
|
+
// Put both populations back so the parity compare still holds.
|
|
175
|
+
let ra = rng(SEED)
|
|
176
|
+
for (let i = 0; i < N; i++) setSprite(nodeSprites[i]!, fields(ra))
|
|
177
|
+
let rb = rng(SEED)
|
|
178
|
+
for (let i = 0; i < N; i++) setSprite(recSprites[i]!, fields(rb))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let frame = 0
|
|
182
|
+
onFrame(() => {
|
|
183
|
+
frame++
|
|
184
|
+
if (frame === 3) {
|
|
185
|
+
bench()
|
|
186
|
+
groups()
|
|
187
|
+
}
|
|
188
|
+
if (frame === 6) {
|
|
189
|
+
pixelParity()
|
|
190
|
+
pickParity()
|
|
191
|
+
rectQuery()
|
|
192
|
+
console.log(failures.length === 0 ? "PARITY-OK" : `PARITY-FAIL: ${failures.join("; ")}`)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<window flexDirection="row" alignItems="center" justifyContent="center" gap={8}>
|
|
198
|
+
<texture src={nodes.texture} width={W} height={H} />
|
|
199
|
+
<texture src={records.texture} width={W} height={H} />
|
|
200
|
+
</window>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
render(() => <App />)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Sprite pointer events through the component face: the built-in leaf
|
|
2
|
+
// carries the layer's handlers, so <Sprite onPointer*> props receive events
|
|
3
|
+
// with LAYER-pixel coordinates and exact rotated-rect hit testing, topmost
|
|
4
|
+
// sprite first. Drag a sprite to move it - the layer captures the pointer on
|
|
5
|
+
// down, so the drag keeps delivering to the grabbed sprite even when the
|
|
6
|
+
// pointer outruns it. A click (press and release without moving) cycles the
|
|
7
|
+
// tint. Shift-click removes the sprite, exercising slot recycling (the
|
|
8
|
+
// freed pose slot zeroes and waits for the next add); structure lives in a
|
|
9
|
+
// signal, so <For> unmounts the removed <Sprite>.
|
|
10
|
+
import { createSignal, render, For } from "@solidrt/core"
|
|
11
|
+
import { createAtlas, grid, setSprite, Sprite, SpriteLayer } from "@solidrt/2d"
|
|
12
|
+
import type { Frame, SpriteHandle } from "@solidrt/2d"
|
|
13
|
+
import logoBytes from "./logo.png" with { type: "binary" }
|
|
14
|
+
|
|
15
|
+
const W = 720
|
|
16
|
+
const H = 720
|
|
17
|
+
|
|
18
|
+
const TINTS: [number, number, number, number][] = [
|
|
19
|
+
[1, 1, 1, 1],
|
|
20
|
+
[1, 0.5, 0.5, 1],
|
|
21
|
+
[0.5, 1, 0.6, 1],
|
|
22
|
+
[0.55, 0.7, 1, 1],
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
type Item = { id: number; x: number; y: number; frame: Frame }
|
|
26
|
+
|
|
27
|
+
function App() {
|
|
28
|
+
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
29
|
+
let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
|
|
30
|
+
let [items, setItems] = createSignal<Item[]>([
|
|
31
|
+
{ id: 0, x: 200, y: 240, frame: frames[0]! },
|
|
32
|
+
{ id: 1, x: 420, y: 300, frame: frames[1]! },
|
|
33
|
+
{ id: 2, x: 300, y: 470, frame: frames[2]! },
|
|
34
|
+
{ id: 3, x: 520, y: 500, frame: frames[3]! },
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<window alignItems="center" justifyContent="center">
|
|
39
|
+
<SpriteLayer width={W} height={H} atlas={atlas.texture} capacity={64} clearColor={[0.05, 0.05, 0.09, 1]}>
|
|
40
|
+
<For each={items()}>
|
|
41
|
+
{item => {
|
|
42
|
+
let tintIndex = 0
|
|
43
|
+
let down = false
|
|
44
|
+
let moved = false
|
|
45
|
+
let downX = 0
|
|
46
|
+
let downY = 0
|
|
47
|
+
let handle: SpriteHandle | undefined
|
|
48
|
+
return (
|
|
49
|
+
<Sprite
|
|
50
|
+
ref={s => (handle = s)}
|
|
51
|
+
x={item.x}
|
|
52
|
+
y={item.y}
|
|
53
|
+
w={96}
|
|
54
|
+
h={96}
|
|
55
|
+
frame={item.frame}
|
|
56
|
+
onPointerDown={e => {
|
|
57
|
+
down = true
|
|
58
|
+
moved = false
|
|
59
|
+
downX = e.x
|
|
60
|
+
downY = e.y
|
|
61
|
+
}}
|
|
62
|
+
onPointerMove={e => {
|
|
63
|
+
// onPointerMove also fires on plain hover; only a captured
|
|
64
|
+
// move (button held since the down) drags. A few pixels of
|
|
65
|
+
// slop keep an ordinary click from registering as a drag.
|
|
66
|
+
if (!down) return
|
|
67
|
+
if (!moved && Math.hypot(e.x - downX, e.y - downY) < 4) return
|
|
68
|
+
moved = true
|
|
69
|
+
if (handle) setSprite(handle, { x: e.x, y: e.y })
|
|
70
|
+
}}
|
|
71
|
+
onPointerUp={e => {
|
|
72
|
+
down = false
|
|
73
|
+
if (moved) return
|
|
74
|
+
if (e.shiftKey) {
|
|
75
|
+
setItems(items().filter(other => other.id !== item.id))
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
tintIndex = (tintIndex + 1) % TINTS.length
|
|
79
|
+
if (handle) setSprite(handle, { tint: TINTS[tintIndex] })
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
)
|
|
83
|
+
}}
|
|
84
|
+
</For>
|
|
85
|
+
</SpriteLayer>
|
|
86
|
+
</window>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render(() => <App />)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Native node transitions at the sprite layer's scale: every sprite
|
|
2
|
+
// declares a spring, and the app writes TARGETS - one setSprite per sprite
|
|
3
|
+
// per shuffle (about once a second), ZERO JS per frame. The core
|
|
4
|
+
// integrates all the springs each frame and publishes one coalesced
|
|
5
|
+
// pose-buffer write; between shuffles the app runs no code at all (there
|
|
6
|
+
// is no onFrame here - the running tracks drive frame demand themselves,
|
|
7
|
+
// and once everything settles the layer costs nothing). Compare
|
|
8
|
+
// sprites.tsx, which moves the same population imperatively every frame:
|
|
9
|
+
// here the JS cost is proportional to target CHANGES, not frames. The
|
|
10
|
+
// console logs each shuffle's burst cost.
|
|
11
|
+
//
|
|
12
|
+
// Rotation targets ride the quaternion geodesic, so a spin to a new
|
|
13
|
+
// random angle always takes the short arc, and the position spring keeps
|
|
14
|
+
// its velocity when a shuffle lands mid-flight - retarget as fast as you
|
|
15
|
+
// like, the motion stays continuous.
|
|
16
|
+
import { render } from "@solidrt/core"
|
|
17
|
+
import { addSprite, createAtlas, createSpriteLayer, grid, setSprite, setSpriteTransition } from "@solidrt/2d"
|
|
18
|
+
import logoBytes from "./logo.png" with { type: "binary" }
|
|
19
|
+
|
|
20
|
+
const COLS = 20
|
|
21
|
+
const ROWS = 20
|
|
22
|
+
const COUNT = COLS * ROWS
|
|
23
|
+
const W = 720
|
|
24
|
+
const H = 720
|
|
25
|
+
const SPRITE = 30
|
|
26
|
+
|
|
27
|
+
function App() {
|
|
28
|
+
let atlas = createAtlas(logoBytes, { label: "logo-atlas" })
|
|
29
|
+
let frames = grid(2, 2, { width: atlas.width, height: atlas.height })
|
|
30
|
+
let layer = createSpriteLayer(W, H, atlas.texture, {
|
|
31
|
+
capacity: COUNT,
|
|
32
|
+
clearColor: [0.05, 0.05, 0.09, 1],
|
|
33
|
+
label: "springs",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
let slotX = (slot: number) => ((slot % COLS) + 0.5) * (W / COLS)
|
|
37
|
+
let slotY = (slot: number) => (Math.floor(slot / COLS) + 0.5) * (H / ROWS)
|
|
38
|
+
|
|
39
|
+
// Sprite k sits at grid slot slots[k]; each shuffle re-deals the slots.
|
|
40
|
+
let slots = Array.from({ length: COUNT }, (_, i) => i)
|
|
41
|
+
let sprites = slots.map(slot =>
|
|
42
|
+
addSprite(layer, { x: slotX(slot), y: slotY(slot), w: SPRITE, h: SPRITE, frame: frames[slot % 4] }),
|
|
43
|
+
)
|
|
44
|
+
for (let sprite of sprites) {
|
|
45
|
+
setSpriteTransition(sprite, { position: { duration: 700, bounce: 0.3 }, rotation: { duration: 700 } })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let shuffle = () => {
|
|
49
|
+
for (let i = slots.length - 1; i > 0; i--) {
|
|
50
|
+
let j = Math.floor(Math.random() * (i + 1))
|
|
51
|
+
;[slots[i], slots[j]] = [slots[j]!, slots[i]!]
|
|
52
|
+
}
|
|
53
|
+
let start = performance.now()
|
|
54
|
+
for (let k = 0; k < COUNT; k++) {
|
|
55
|
+
setSprite(sprites[k]!, { x: slotX(slots[k]!), y: slotY(slots[k]!), rotation: Math.random() * Math.PI * 2 })
|
|
56
|
+
}
|
|
57
|
+
let ms = performance.now() - start
|
|
58
|
+
console.log(`retarget x${COUNT}: ${ms.toFixed(2)} ms; JS idles until the next shuffle`)
|
|
59
|
+
}
|
|
60
|
+
setInterval(shuffle, 1200)
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<window alignItems="center" justifyContent="center">
|
|
64
|
+
<texture src={layer.texture} width={W} height={H} />
|
|
65
|
+
</window>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
render(() => <App />)
|