@solidrt/2d 0.0.57 → 0.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/layer.ts CHANGED
@@ -30,13 +30,21 @@ import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEve
30
30
  import { beginBufferWrite, createBuffer, destroyBuffer, endBufferWrite } from "@solidrt/core/gpu"
31
31
  import type { BufferId, TextureId } from "@solidrt/core/gpu"
32
32
  import * as spatial from "flux:spatial"
33
- import type { Impact as CoreImpact, MoveOptions as CoreMoveOptions, NodeId, NodeTransition, QueryFilter } from "flux:spatial"
33
+ import type {
34
+ Impact as CoreImpact,
35
+ MoveOptions as CoreMoveOptions,
36
+ NodeEndpoint,
37
+ NodeId,
38
+ NodeTransition,
39
+ NodeTransitionSpec,
40
+ QueryFilter,
41
+ } from "flux:spatial"
34
42
  import { on } from "srt:events"
35
43
  import type { CameraState, CameraUpdate } from "./camera.ts"
36
44
  import type { Frame } from "./frames.ts"
37
45
  import { FULL_FRAME, writeFrame } from "./frames.ts"
38
46
  import type { RecordLayer } from "./records.ts"
39
- import { createSpritePipeline, INSTANCE_ATTRIBUTES_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
47
+ import { createSpritePipeline, INSTANCE_LAYOUTS_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
40
48
  import { createViews } from "./views.ts"
41
49
  import type { ViewHandle, ViewOptions } from "./views.ts"
42
50
 
@@ -93,6 +101,26 @@ const WORLD = new Float32Array(16)
93
101
  let declared = new Map<NodeId, Sprite | SpriteGroup>()
94
102
  let subscribed = false
95
103
 
104
+ // Free routing: a destroyed sprite or group whose declaration carries an
105
+ // `exit` stays in the core as a LEAVING node (drawn, picked by nothing)
106
+ // until its exits settle, and the core's "spatialNodeFreed" event says
107
+ // when it is gone - the cue to recycle what the layer kept for it (a
108
+ // sprite's pose slot). One lazy subscription, keyed by node.
109
+ let freeing = new Map<NodeId, () => void>()
110
+ let freeingSubscribed = false
111
+
112
+ function onFreed(node: NodeId, done: () => void): void {
113
+ freeing.set(node, done)
114
+ if (freeingSubscribed) return
115
+ freeingSubscribed = true
116
+ on("spatialNodeFreed", (event: { node: NodeId }) => {
117
+ let done = freeing.get(event.node)
118
+ if (!done) return
119
+ freeing.delete(event.node)
120
+ done()
121
+ })
122
+ }
123
+
96
124
  function declareTransition(node: NodeId, handle: Sprite | SpriteGroup, transition: NodeTransition | string | null): void {
97
125
  if (transition === null) {
98
126
  declared.delete(node)
@@ -118,12 +146,12 @@ function declareTransition(node: NodeId, handle: Sprite | SpriteGroup, transitio
118
146
  * fields - they touch no GPU state (the scene-graph handler rule).
119
147
  */
120
148
  export type Sprite = {
121
- /** The owning layer, null after removeSprite (which owns the write). */
149
+ /** The owning layer, null after destroySprite (which owns the write). */
122
150
  readonly layer: SpriteLayer | RecordLayer | null
123
151
  /**
124
152
  * The sprite's SPATIAL ARENA node - the citizenship handle: bind core
125
153
  * producers to it or reach it through flux:spatial directly (the layer
126
- * still owns the node's life; destroy it only via removeSprite). Null
154
+ * still owns the node's life; destroy it only via destroySprite). Null
127
155
  * on a record layer's sprites.
128
156
  */
129
157
  readonly node: NodeId | null
@@ -313,7 +341,9 @@ export type LayerPointerListener = {
313
341
  onTap?: (event: LayerTapEvent) => void
314
342
  }
315
343
 
316
- export type SpriteHandlers = {
344
+ /** The element handlers for the leaf that shows a view (`ViewHandle.handlers`);
345
+ * spread onto the host element. */
346
+ export type LayerHandlers = {
317
347
  onPointerDown: (event: ElementPointerEvent) => void
318
348
  onPointerMove: (event: ElementPointerEvent) => void
319
349
  onPointerUp: (event: ElementPointerEvent) => void
@@ -343,6 +373,12 @@ export type SpriteLayerOptions = {
343
373
  /** Names the GPU resources (buffers, pipeline; views default to
344
374
  * `<label>-view`); default "sprites". */
345
375
  label?: string
376
+ /** Stagger (ms) on the layer's root: every sprite or group added
377
+ * straight under the layer (no group) that enters or leaves in one
378
+ * frame is spaced by `index * stagger` - the whole-layer form of a
379
+ * group's `stagger` (a group declaring its own wins for what is under
380
+ * it). See setStagger. */
381
+ stagger?: number
346
382
  /** Skip the owner-scoped auto-dispose (see createSpriteLayer). */
347
383
  autoFree?: boolean
348
384
  /**
@@ -381,7 +417,7 @@ export type SpriteLayerOptions = {
381
417
  * on the sprite alone), so one group handler covers a whole assembly.
382
418
  */
383
419
  export type SpriteGroup = {
384
- /** The owning layer, null after removeGroup (which owns the write). */
420
+ /** The owning layer, null after destroyGroup (which owns the write). */
385
421
  readonly layer: SpriteLayer | null
386
422
  /** The group's spatial arena node. */
387
423
  readonly node: NodeId
@@ -395,7 +431,7 @@ export type SpriteGroup = {
395
431
  readonly _visible: boolean
396
432
  /** See Sprite._parent. */
397
433
  readonly _parent: SpriteGroup | null
398
- /** The handles parented here (removeGroup removes them with it);
434
+ /** The handles parented here (destroyGroup removes them with it);
399
435
  * internal -
400
436
  * membership writes go through addSprite/addGroup/setSpriteParent/
401
437
  * setGroup. */
@@ -476,7 +512,7 @@ export type LayerBase = {
476
512
  _add(opts?: AddSpriteOptions): Sprite
477
513
  _write(sprite: SpriteState, opts: SpriteOptions): void
478
514
  _read(sprite: Sprite): Required<SpriteOptions>
479
- _remove(sprite: SpriteState): void
515
+ _destroy(sprite: SpriteState): void
480
516
  _schedule(): void
481
517
  }
482
518
 
@@ -499,7 +535,7 @@ export type QueryOptions = {
499
535
 
500
536
  /** One sprite a raycast() ray strikes: the distance along the ray, the
501
537
  * point on the sprite's edge and the unit edge normal facing the ray. */
502
- export type RayHit = { sprite: Sprite; distance: number; point: [number, number]; normal: [number, number] }
538
+ export type Hit = { sprite: Sprite; distance: number; point: [number, number]; normal: [number, number] }
503
539
 
504
540
  /** One sprite an overlap() volume touches: its deepest contact - the
505
541
  * point on the sprite's edge, the unit direction out of the sprite, and
@@ -564,7 +600,7 @@ export type SpriteLayer = LayerBase & {
564
600
  * Reads the index as of the last flush, the pending batch run first,
565
601
  * like pick.
566
602
  */
567
- raycast(x: number, y: number, dx: number, dy: number, opts?: QueryOptions): RayHit[]
603
+ raycast(x: number, y: number, dx: number, dy: number, opts?: QueryOptions): Hit[]
568
604
  /**
569
605
  * Every shown sprite the volume touches, each with its deepest contact
570
606
  * (Godot's intersect_shape, Unity's OverlapCircle/Box/Capsule: a blast
@@ -600,9 +636,15 @@ export type SpriteLayer = LayerBase & {
600
636
  */
601
637
  moveAndSlide(volume: Volume, dx: number, dy: number, opts?: MoveOptions): MoveResult
602
638
  _groups: Set<GroupState>
639
+ /** Set (or with null clear) the stagger on the layer's root: the
640
+ * spacing, in ms, of the enters and exits of the sprites and groups
641
+ * straight under the layer that begin in one frame (SpriteLayerOptions
642
+ * `stagger`). */
643
+ setStagger(ms: number | null): void
603
644
  /** The core node every sprite and group of the layer sits under: what
604
645
  * scopes the layer's queries in the arena shared with 3d scenes. */
605
646
  _root: NodeId
647
+ _destroyGroup(group: GroupState): void
606
648
  }
607
649
 
608
650
  /** The stored UVs at `at` un-mirrored by the sprite's flags: the frame as
@@ -698,7 +740,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
698
740
  checkTint("createSpriteLayer", tint)
699
741
  let pose: BufferId = createBuffer(capacity * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
700
742
  let style: BufferId = createBuffer(capacity * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
701
- let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_ATTRIBUTES_SPLIT)
743
+ let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_LAYOUTS_SPLIT)
702
744
 
703
745
  let disposed = false
704
746
  let scheduled = false
@@ -710,6 +752,9 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
710
752
  // collapses the instance - so holes draw nothing).
711
753
  let highWater = 0
712
754
  let freeSlots: number[] = []
755
+ // Destroyed handles still leaving in the core (see onFreed): a sprite's
756
+ // slot stays taken until the core says it is gone; dispose frees them.
757
+ let leaving = new Set<NodeId>()
713
758
  let gpuCapacity = capacity
714
759
  let styleData = new Float32Array(capacity * STYLE_FLOATS)
715
760
  let byNode = new Map<NodeId, SpriteState>()
@@ -718,6 +763,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
718
763
  fillTransform(0, 0, 0, 1, 1)
719
764
  let root = spatial.createNode(TRANSFORM, true)
720
765
  let filter: QueryFilter = { root }
766
+ if (opts?.stagger !== undefined) spatial.setTransition(root, { stagger: opts.stagger })
721
767
  // Refill the layer's one filter for a query: the root always, the
722
768
  // include-list as the sprites' nodes when given.
723
769
  let queryFilter = (opts: QueryOptions | undefined, site: string): void => {
@@ -768,7 +814,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
768
814
  let grownStyle = new Float32Array(next * STYLE_FLOATS)
769
815
  grownStyle.set(styleData)
770
816
  styleData = grownStyle
771
- views.setBuffers({ instanceBuffers: [newPose, newStyle] })
817
+ views.setBuffers([newPose, newStyle])
772
818
  destroyBuffer(pose)
773
819
  destroyBuffer(style)
774
820
  pose = newPose
@@ -810,7 +856,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
810
856
  pipeline: gpu.pipeline,
811
857
  quad: gpu.quad,
812
858
  atlas,
813
- buffers: () => ({ instanceBuffers: [pose, style] }),
859
+ buffers: () => [pose, style],
814
860
  count: () => published,
815
861
  tint: () => tint,
816
862
  pick: (x, y) => layer.pick(x, y),
@@ -823,7 +869,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
823
869
  opts?.orderBy === "y"
824
870
  ? { field: POSE_Y_FIELD }
825
871
  : opts?.orderBy === "renderOrder"
826
- ? { field: STYLE_KEY_FIELD, slot: 1 }
872
+ ? { field: STYLE_KEY_FIELD, buffer: 2 }
827
873
  : undefined,
828
874
  })
829
875
 
@@ -874,7 +920,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
874
920
  RAY_DIR[1] = dy
875
921
  RAY_DIR[2] = 0
876
922
  queryFilter(opts, "raycast")
877
- let out: RayHit[] = []
923
+ let out: Hit[] = []
878
924
  for (let h of spatial.raycast(RAY_ORIGIN, RAY_DIR, filter)) {
879
925
  let sprite = byNode.get(h.node)
880
926
  if (sprite) out.push({ sprite, distance: h.distance, point: [h.point[0], h.point[1]], normal: [h.normal[0], h.normal[1]] })
@@ -949,6 +995,12 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
949
995
  spatial.destroyNode(group.node)
950
996
  }
951
997
  layer._groups.clear()
998
+ // Corpses mid-exit go now: their slots must not outlive the buffer.
999
+ for (let node of leaving) {
1000
+ freeing.delete(node)
1001
+ spatial.destroyNode(node)
1002
+ }
1003
+ leaving.clear()
952
1004
  spatial.destroyNode(root)
953
1005
  // Let the core emit its final slot-zeroing writes while the pose
954
1006
  // buffer still exists, then free everything.
@@ -1035,20 +1087,45 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
1035
1087
  visible: sprite._visible,
1036
1088
  }
1037
1089
  },
1038
- _remove(sprite) {
1090
+ _destroy(sprite) {
1039
1091
  sprite.layer = null
1040
1092
  if (sprite._parent) {
1041
1093
  sprite._parent._children.delete(sprite)
1042
1094
  sprite._parent = null
1043
1095
  }
1044
- // Destroying the node zeroes its pose slot at the next core flush
1045
- // (zero scale = nothing drawn); the slot then recycles.
1046
- byNode.delete(sprite.node!)
1047
- declared.delete(sprite.node!)
1048
- spatial.destroyNode(sprite.node!)
1049
- freeSlots.push(sprite._slot)
1096
+ let node = sprite.node!
1097
+ byNode.delete(node)
1098
+ declared.delete(node)
1099
+ // A declared exit keeps the node leaving: its slot keeps drawing the
1100
+ // exit until the core frees it (the freed event lands after the
1101
+ // flush that zeroed the slot), then recycles. Otherwise the free is
1102
+ // immediate: the slot zeroes at the next core flush (zero scale =
1103
+ // nothing drawn) and recycles now.
1104
+ let slot = sprite._slot
1105
+ if (spatial.exitNode(node)) {
1106
+ leaving.add(node)
1107
+ onFreed(node, () => {
1108
+ leaving.delete(node)
1109
+ freeSlots.push(slot)
1110
+ })
1111
+ } else {
1112
+ freeSlots.push(slot)
1113
+ }
1050
1114
  layer._schedule()
1051
1115
  },
1116
+ _destroyGroup(group) {
1117
+ let node = group.node
1118
+ declared.delete(node)
1119
+ if (spatial.exitNode(node)) {
1120
+ leaving.add(node)
1121
+ onFreed(node, () => leaving.delete(node))
1122
+ }
1123
+ layer._schedule()
1124
+ },
1125
+ setStagger(ms) {
1126
+ if (disposed) return
1127
+ spatial.setTransition(root, ms === null ? null : { stagger: ms })
1128
+ },
1052
1129
  _schedule() {
1053
1130
  if (disposed || scheduled) return
1054
1131
  scheduled = true
@@ -1086,11 +1163,19 @@ export function getSprite(sprite: Sprite): Required<SpriteOptions> | null {
1086
1163
  }
1087
1164
 
1088
1165
  /**
1089
- * Remove a sprite. The handle goes inert (layer null); further setSprite
1090
- * calls are no-ops.
1166
+ * Destroy a sprite - PixiJS's `sprite.destroy()`, Unity's Destroy: the
1167
+ * handle goes inert (layer null; further setSprite calls are no-ops) and
1168
+ * the sprite is gone for good. With an `exit` in its transition
1169
+ * declaration it animates out first: each component with one plays to
1170
+ * its exit value and the sprite stays drawn meanwhile, a ghost that no
1171
+ * pick, raycast, overlap or sweep sees, then frees when the last exit
1172
+ * settles (no onTransitionEnd fires for an exit: the app already let go).
1173
+ * The one removal verb on a sprite: it cannot exist outside its layer, so
1174
+ * there is no detach; a sprite that should come back is hidden
1175
+ * (`visible: false`), Unity's SetActive.
1091
1176
  */
1092
- export function removeSprite(sprite: Sprite): void {
1093
- sprite.layer?._remove(sprite)
1177
+ export function destroySprite(sprite: Sprite): void {
1178
+ sprite.layer?._destroy(sprite)
1094
1179
  }
1095
1180
 
1096
1181
  /**
@@ -1106,7 +1191,7 @@ export function removeSprite(sprite: Sprite): void {
1106
1191
  export function worldPosition(target: Sprite | SpriteGroup): [number, number] | null {
1107
1192
  if (target.layer === null) return null
1108
1193
  // The set only groups carry tells the two handle kinds apart (as in
1109
- // removeGroup); a node-less sprite is a record layer's.
1194
+ // destroyGroup); a node-less sprite is a record layer's.
1110
1195
  if (!("_children" in target) && target.node === null) {
1111
1196
  let s = target.layer._read(target)
1112
1197
  return [s.x, s.y]
@@ -1136,6 +1221,92 @@ export function setSpriteParent(sprite: Sprite, parent: SpriteGroup | null): voi
1136
1221
  layer._schedule()
1137
1222
  }
1138
1223
 
1224
+ /** A lifecycle endpoint in the object form: the value (2d units, as the
1225
+ * bare form) plus the motion that direction plays - a field left out is
1226
+ * the entry's; naming a `curve` or a `bounce` decides the kind outright,
1227
+ * so an ease-out enter pairs with an ease-in exit. */
1228
+ export type SpriteEndpoint<Value> = {
1229
+ value: Value
1230
+ duration?: number
1231
+ curve?: Extract<NodeTransitionSpec, { curve: unknown }>["curve"]
1232
+ bounce?: number
1233
+ delay?: number
1234
+ }
1235
+
1236
+ /** One spec of a sprite or group transition: the node vocabulary
1237
+ * (`{ duration, bounce? }` a spring, `{ duration, curve }` a tween, or a
1238
+ * shorthand string like "300ms ease-out 100ms"; `delay` in ms holds every
1239
+ * write that long first) with the lifecycle endpoints in 2d units, each a
1240
+ * bare value or a SpriteEndpoint: `from` is the component's enter value,
1241
+ * where a freshly added sprite starts before animating to its mount pose;
1242
+ * `exit` where it animates to when destroySprite/destroyGroup lets go of
1243
+ * it. */
1244
+ export type SpriteTransitionSpec<Value> =
1245
+ | { duration: number; bounce?: number; delay?: number; from?: Value | SpriteEndpoint<Value>; exit?: Value | SpriteEndpoint<Value> }
1246
+ | {
1247
+ duration: number
1248
+ curve: Extract<NodeTransitionSpec, { curve: unknown }>["curve"]
1249
+ delay?: number
1250
+ from?: Value | SpriteEndpoint<Value>
1251
+ exit?: Value | SpriteEndpoint<Value>
1252
+ }
1253
+ | string
1254
+
1255
+ /** A sprite or group transition declaration: a spec per pose component
1256
+ * plus `all` as a catch-all. `position` is x/y (`from: [x, y]`),
1257
+ * `rotation` the rotation in radians (`from: angle`), `scale` a sprite's
1258
+ * w/h (`from: [w, h]`) or a group's uniform scale (`from: s`); `exit`
1259
+ * takes the same units. `stagger` (ms) goes on a GROUP's declaration and
1260
+ * spaces the enters and exits of the sprites and groups under it that
1261
+ * begin in one frame by `index * stagger` (add order for enters, tree
1262
+ * order for exits; the nearest declaring group wins); a sprite has no
1263
+ * children, so on a sprite it does nothing. */
1264
+ export type SpriteTransition = {
1265
+ position?: SpriteTransitionSpec<[number, number]>
1266
+ rotation?: SpriteTransitionSpec<number>
1267
+ scale?: SpriteTransitionSpec<[number, number] | number>
1268
+ all?: NodeTransitionSpec
1269
+ stagger?: number
1270
+ }
1271
+
1272
+ /** The 2d declaration in the arena's own lanes: endpoint values lifted
1273
+ * into the plane (z = 0, a unit z scale, the rotation a quaternion about
1274
+ * z), bare or inside the endpoint object; everything else passes
1275
+ * through. */
1276
+ function toNodeTransition(transition: SpriteTransition | string | null): NodeTransition | string | null {
1277
+ if (transition === null || typeof transition === "string") return transition
1278
+ let out: NodeTransition = {}
1279
+ if (transition.all !== undefined) out.all = transition.all
1280
+ if (transition.stagger !== undefined) out.stagger = transition.stagger
1281
+ if (transition.position !== undefined) out.position = liftSpec(transition.position, ([x, y]) => [x, y, 0])
1282
+ if (transition.rotation !== undefined) {
1283
+ out.rotation = liftSpec(transition.rotation, angle => [0, 0, Math.sin(angle / 2), Math.cos(angle / 2)])
1284
+ }
1285
+ if (transition.scale !== undefined) {
1286
+ out.scale = liftSpec(transition.scale, s => (typeof s === "number" ? [s, s, 1] : [s[0], s[1], 1]))
1287
+ }
1288
+ return out
1289
+ }
1290
+
1291
+ function liftSpec<Value>(spec: SpriteTransitionSpec<Value>, lift: (value: Value) => number[]): NodeTransitionSpec {
1292
+ if (typeof spec === "string") return spec
1293
+ let { from, exit, ...rest } = spec
1294
+ let out: NodeTransitionSpec = rest
1295
+ if (from !== undefined) out.from = liftEndpoint(from, lift)
1296
+ if (exit !== undefined) out.exit = liftEndpoint(exit, lift)
1297
+ return out
1298
+ }
1299
+
1300
+ function liftEndpoint<Value>(endpoint: Value | SpriteEndpoint<Value>, lift: (value: Value) => number[]): number[] | NodeEndpoint {
1301
+ // The object form is the one shape here with a `value` key; a bare
1302
+ // value is a number or a pair.
1303
+ if (typeof endpoint === "object" && endpoint !== null && !Array.isArray(endpoint)) {
1304
+ let { value, ...motion } = endpoint as SpriteEndpoint<Value>
1305
+ return { ...motion, value: lift(value) }
1306
+ }
1307
+ return lift(endpoint as Value)
1308
+ }
1309
+
1139
1310
  /**
1140
1311
  * Declare (or with null clear) how the sprite's pose writes animate: once
1141
1312
  * set, setSprite writes are TARGETS the core animates toward - JS writes
@@ -1145,24 +1316,32 @@ export function setSpriteParent(sprite: Sprite, parent: SpriteGroup | null): voi
1145
1316
  * rotation (always the short arc) and `scale` its w/h; each spec is
1146
1317
  * `{ duration, bounce? }` (a spring, the retargeting-safe default) /
1147
1318
  * `{ duration, curve }` (a tween) / a shorthand string like
1148
- * "300ms ease-out". Clearing cancels running tracks in place (the sprite
1149
- * keeps its mid-flight pose) and later writes snap. Each natural settle
1150
- * calls the sprite's `onTransitionEnd` with the component. Node layer
1151
- * only.
1319
+ * "300ms ease-out". A `from` on a component is its enter value: a sprite
1320
+ * declared in the tick that added it starts there and animates to its
1321
+ * mount pose (once, at add; a later declaration animates writes only).
1322
+ * An `exit` is where it animates to when destroySprite lets go of it
1323
+ * (see there); either endpoint takes a SpriteEndpoint to own its
1324
+ * direction's motion, and `delay` holds writes. Clearing cancels running
1325
+ * tracks in place (the sprite keeps its mid-flight pose) and later writes
1326
+ * snap. Each natural settle calls the sprite's `onTransitionEnd` with the
1327
+ * component. Node layer only.
1152
1328
  */
1153
- export function setSpriteTransition(sprite: Sprite, transition: NodeTransition | string | null): void {
1329
+ export function setSpriteTransition(sprite: Sprite, transition: SpriteTransition | string | null): void {
1154
1330
  if (sprite.layer === null) return
1155
1331
  if (sprite.node === null) throw new Error("setSpriteTransition: record sprites have no node transitions")
1156
- spatial.setTransition(sprite.node, transition)
1157
- declareTransition(sprite.node, sprite, transition)
1332
+ let node = toNodeTransition(transition)
1333
+ spatial.setTransition(sprite.node, node)
1334
+ declareTransition(sprite.node, sprite, node)
1158
1335
  }
1159
1336
 
1160
1337
  /** The group counterpart of setSpriteTransition (`scale` is the group's
1161
- * uniform scale). */
1162
- export function setGroupTransition(group: SpriteGroup, transition: NodeTransition | string | null): void {
1338
+ * uniform scale), and the home of `stagger`: a group declaring one spaces
1339
+ * the enters and exits of everything under it (see SpriteTransition). */
1340
+ export function setGroupTransition(group: SpriteGroup, transition: SpriteTransition | string | null): void {
1163
1341
  if (group.layer === null) return
1164
- spatial.setTransition(group.node, transition)
1165
- declareTransition(group.node, group, transition)
1342
+ let node = toNodeTransition(transition)
1343
+ spatial.setTransition(group.node, node)
1344
+ declareTransition(group.node, group, node)
1166
1345
  }
1167
1346
 
1168
1347
  /** Add a transform group (see SpriteGroup). */
@@ -1226,24 +1405,26 @@ export function setGroup(group: SpriteGroup, opts: GroupOptions): void {
1226
1405
  }
1227
1406
 
1228
1407
  /**
1229
- * Remove a group AND everything under it: child sprites and groups are
1230
- * removed with it - Unity's Destroy, Godot's free, the subtree form of
1231
- * removeSprite. To keep a child, re-parent it out first (setSpriteParent /
1232
- * setGroup's `parent`). Every removed handle goes inert. A component tree
1233
- * unmounts children first and never sees the recursion. (@solidrt/3d's
1234
- * `remove` DETACHES a re-addable subtree instead - its nodes exist outside
1235
- * a scene; a sprite cannot exist outside its layer, so here remove means
1236
- * destroy, exactly as it does for removeSprite.)
1408
+ * Destroy a group AND everything under it: child sprites and groups die
1409
+ * with it - Unity's Destroy, Godot's queue_free, the subtree form of
1410
+ * destroySprite (@solidrt/3d's `destroy` is the same verb one dimension
1411
+ * up). To keep a child, re-parent it out first (setSpriteParent /
1412
+ * setGroup's `parent`). Every destroyed handle goes inert. Exits play
1413
+ * through the whole subtree: children are let go of first, each
1414
+ * animating its own `exit`, and the group - its own exit or not - stays
1415
+ * in the core as their frame until the last of them has settled, then
1416
+ * frees. A component tree unmounts children first and never sees the
1417
+ * recursion.
1237
1418
  */
1238
- export function removeGroup(group: SpriteGroup): void {
1419
+ export function destroyGroup(group: SpriteGroup): void {
1239
1420
  let layer = group.layer
1240
1421
  if (!layer) return
1241
1422
  let g: GroupState = group
1242
1423
  // Children first, over a snapshot (each removal edits _children); the
1243
1424
  // set only groups carry tells the two handle kinds apart.
1244
1425
  for (let child of [...g._children]) {
1245
- if ("_children" in child) removeGroup(child)
1246
- else removeSprite(child)
1426
+ if ("_children" in child) destroyGroup(child)
1427
+ else destroySprite(child)
1247
1428
  }
1248
1429
  g.layer = null
1249
1430
  if (g._parent) {
@@ -1251,7 +1432,5 @@ export function removeGroup(group: SpriteGroup): void {
1251
1432
  g._parent = null
1252
1433
  }
1253
1434
  layer._groups.delete(group)
1254
- declared.delete(group.node)
1255
- spatial.destroyNode(group.node)
1256
- layer._schedule()
1435
+ layer._destroyGroup(g)
1257
1436
  }
package/src/records.ts CHANGED
@@ -47,7 +47,7 @@ export type RecordLayerOptions = Omit<SpriteLayerOptions, "orderBy"> & {
47
47
  * further up the screen = drawn first); or an explicit `{ field,
48
48
  * descending? }` float offset into the record for a custom sort key.
49
49
  * Record slots stay stable - record i keeps meaning sprite i, and
50
- * removeSprite still shifts - only the draw order changes. Ties keep
50
+ * destroySprite still shifts - only the draw order changes. Ties keep
51
51
  * record order, so an unset key draws exactly as before. Known
52
52
  * limitation: pick() resolves overlapping sprites by record order, not
53
53
  * visual order, when a key is set.
@@ -63,7 +63,7 @@ export type RecordLayer = LayerBase & {
63
63
  * is draw order - unless the layer was created with `orderBy`, which
64
64
  * draws in key order while record i keeps meaning sprite i. Write fields
65
65
  * directly for large per-frame populations, then call touch() once. Do
66
- * not cache indices across removeSprite - records shift - and do not
66
+ * not cache indices across destroySprite - records shift - and do not
67
67
  * cache the array across addSprite - growth replaces it.
68
68
  */
69
69
  records: Float32Array
@@ -104,7 +104,7 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
104
104
  : orderBy === "y"
105
105
  ? { field: Y_FIELD_OFFSET }
106
106
  : { field: orderBy.field, descending: orderBy.descending }
107
- let gpu = createSpritePipeline(label, VERTEX, INSTANCE_ATTRIBUTES)
107
+ let gpu = createSpritePipeline(label, VERTEX, [INSTANCE_ATTRIBUTES])
108
108
 
109
109
  let disposed = false
110
110
  let dirty = false
@@ -132,7 +132,7 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
132
132
  out.set(layer.records.subarray(0, count * FLOATS_PER_SPRITE))
133
133
  endBufferWrite(target, count * FLOATS_PER_SPRITE * 4)
134
134
  if (grown !== null) {
135
- views.setBuffers({ instanceBuffer: grown })
135
+ views.setBuffers([grown])
136
136
  views.setCount(count)
137
137
  if (instanceOrder !== undefined) {
138
138
  // The growth publish above landed BEFORE the swap (the entry must
@@ -192,7 +192,7 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
192
192
  pipeline: gpu.pipeline,
193
193
  quad: gpu.quad,
194
194
  atlas,
195
- buffers: () => ({ instanceBuffer: records }),
195
+ buffers: () => [records],
196
196
  count: () => published,
197
197
  tint: () => tint,
198
198
  pick: (x, y) => layer.pick(x, y),
@@ -282,7 +282,7 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
282
282
  visible: true,
283
283
  }
284
284
  },
285
- _remove(sprite) {
285
+ _destroy(sprite) {
286
286
  // Later sprites shift down one draw slot (order preserved).
287
287
  sprite.layer = null
288
288
  let index = sprite._slot
package/src/shaders.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  // against oracles but NOT against these shaders - if you touch one
22
22
  // rotation, touch all.
23
23
  import { compileShader, createBuffer, createRenderPipeline, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyShader, glsl, linkProgram } from "@solidrt/core/gpu"
24
- import type { BufferId, InstanceAttribute, RenderPipelineId, VertexAttribute } from "@solidrt/core/gpu"
24
+ import type { BufferId, RenderPipelineId, VertexAttribute } from "@solidrt/core/gpu"
25
25
 
26
26
  export let VERTEX = glsl`
27
27
  in vec2 aPos;
@@ -84,11 +84,11 @@ export let FRAGMENT = glsl`
84
84
 
85
85
  /** The instance attribute list matching the 13-float record layout. */
86
86
  export const INSTANCE_ATTRIBUTES: VertexAttribute[] = [
87
- { name: "iCenter", format: "vec2" },
88
- { name: "iSize", format: "vec2" },
89
- { name: "iUv", format: "vec4" },
90
- { name: "iRot", format: "f32" },
91
- { name: "iTint", format: "vec4" },
87
+ { name: "iCenter", format: "float32x2" },
88
+ { name: "iSize", format: "float32x2" },
89
+ { name: "iUv", format: "float32x4" },
90
+ { name: "iRot", format: "float32" },
91
+ { name: "iTint", format: "float32x4" },
92
92
  ]
93
93
 
94
94
  export let VERTEX_SPLIT = glsl`
@@ -119,15 +119,20 @@ export let VERTEX_SPLIT = glsl`
119
119
  }
120
120
  `
121
121
 
122
- /** The split layout: slot 0 the Pose2D record, slot 1 the style record
123
- * (iRenderOrder is layout-only - see the header note). */
124
- export const INSTANCE_ATTRIBUTES_SPLIT: InstanceAttribute[] = [
125
- { name: "iPos", format: "vec2" },
126
- { name: "iRot", format: "f32" },
127
- { name: "iScale", format: "vec2" },
128
- { name: "iUv", format: "vec4", slot: 1 },
129
- { name: "iTint", format: "vec4", slot: 1 },
130
- { name: "iRenderOrder", format: "f32", slot: 1 },
122
+ /** The split layout: one instance buffer holding the Pose2D record, a
123
+ * second holding the style record (iRenderOrder is layout-only - see the
124
+ * header note). */
125
+ export const INSTANCE_LAYOUTS_SPLIT: VertexAttribute[][] = [
126
+ [
127
+ { name: "iPos", format: "float32x2" },
128
+ { name: "iRot", format: "float32" },
129
+ { name: "iScale", format: "float32x2" },
130
+ ],
131
+ [
132
+ { name: "iUv", format: "float32x4" },
133
+ { name: "iTint", format: "float32x4" },
134
+ { name: "iRenderOrder", format: "float32" },
135
+ ],
131
136
  ]
132
137
 
133
138
  /** What a layer's targets draw with: the unit quad every instance reuses
@@ -142,7 +147,7 @@ export type SpritePipeline = { quad: BufferId; pipeline: RenderPipelineId; dispo
142
147
  * view - add entries over the one pipeline. The program lives as long as
143
148
  * the pipeline; `dispose` frees both and the quad.
144
149
  */
145
- export function createSpritePipeline(label: string, vertex: string, instanceAttributes: InstanceAttribute[]): SpritePipeline {
150
+ export function createSpritePipeline(label: string, vertex: string, instanceLayouts: VertexAttribute[][]): SpritePipeline {
146
151
  // One unit quad (triangle strip), reused by every instance.
147
152
  let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
148
153
  label: `${label}-quad`,
@@ -156,8 +161,7 @@ export function createSpritePipeline(label: string, vertex: string, instanceAttr
156
161
  let pipeline = createRenderPipeline(program, {
157
162
  label,
158
163
  topology: "triangle-strip",
159
- attributes: [{ name: "aPos", format: "vec2" }],
160
- instanceAttributes,
164
+ buffers: [{ attributes: [{ name: "aPos", format: "float32x2" }] }, ...instanceLayouts.map(attributes => ({ stepMode: "instance" as const, attributes }))],
161
165
  blend: "alpha",
162
166
  })
163
167
  return {
package/src/tiles.ts CHANGED
@@ -304,10 +304,10 @@ export function createTileLayer(
304
304
  label: `${label}-chunk`,
305
305
  topology: "triangle-strip",
306
306
  vertexCount: 4,
307
- attributes: [{ name: "aPos", format: "vec2" }],
308
- buffer: quad,
309
- instanceAttributes: INSTANCE_ATTRIBUTES,
310
- instanceBuffer: buffer,
307
+ buffers: [
308
+ { attributes: [{ name: "aPos", format: "float32x2" }], buffer: quad },
309
+ { stepMode: "instance", attributes: INSTANCE_ATTRIBUTES, buffer },
310
+ ],
311
311
  instanceCount: perChunk,
312
312
  blend: "alpha",
313
313
  textures: { uAtlas: atlas },