@solidrt/2d 0.0.70 → 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/src/layer.ts CHANGED
@@ -12,19 +12,27 @@
12
12
  //
13
13
  // Two instance-buffer slots split ownership: slot 0 is the pose buffer,
14
14
  // written ONLY by the core (one coalesced write per flush however many
15
- // nodes moved); slot 1 is the style buffer [u0, v0, u1, v1, tint rgba,
16
- // renderOrder, minScreenPx, maxScreenPx, atlas], JS-owned and published
17
- // through the zero-copy write lease. Never write the pose buffer from JS -
18
- // the core's staging mirror is the owner and will overwrite. For motion
19
- // only JS can compute at large populations, the records layer (records.ts)
15
+ // nodes moved); slot 1 is the SPRITE record [u0, v0, u1, v1, tint rgba,
16
+ // renderOrder, minScreenPx, maxScreenPx, atlas, outline], JS-owned
17
+ // through the sprite verbs and published through the zero-copy write
18
+ // lease. After them come the MATERIAL's style streams (style.ts), one
19
+ // per instance buffer its material declares: app-owned per-sprite data
20
+ // in any vertex format, written with setInstanceStyle, instanceAttribute
21
+ // or records(layer, stream). Never write the pose buffer from JS - the
22
+ // core's staging mirror is the owner and will overwrite. For motion only
23
+ // JS can compute at large populations, the records layer (records.ts)
20
24
  // is the escape hatch.
21
25
  //
22
26
  // A layer draws from the ATLASES it declares at creation, bound together
23
- // as one draw: every frame carries its texture, the style record stores
24
- // that texture's index in the list, and the generated fragment stage
25
- // (shaders.ts) picks the sampler per instance - so sprites from several
26
- // sheets interleave freely in one draw, key order included, and a frame
27
- // from a sheet the layer did not declare throws at the write.
27
+ // as one draw: every frame carries its texture, the sprite record stores
28
+ // that texture's index in the list, and the fragment set generated for
29
+ // the list (glsl.ts) picks the sampler per instance - so sprites from
30
+ // several sheets interleave freely in one draw, key order included, and
31
+ // a frame from a sheet the layer did not declare throws at the write.
32
+ // What the pixels look like is the layer's MATERIAL (material.ts): the
33
+ // stock `unlit()` unless the options name one, which owns the pipeline
34
+ // (its blend, its program over the layer's set) and whose params and
35
+ // textures every view seeds.
28
36
  //
29
37
  // Sprites hold FIXED instance slots (freed slots recycle): draw order is
30
38
  // slot order, so removal never shifts records and pose sinks never rebind.
@@ -35,8 +43,8 @@
35
43
  // (dispatch.ts) undoes it with unprojectCamera.
36
44
  import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core"
37
45
  import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
38
- import { beginBufferWrite, checkScreenSize, createBuffer, destroyBuffer, endBufferWrite, screenSizeScale } from "@solidrt/core/gpu"
39
- import type { BlendMode, BufferId, TextureId } from "@solidrt/core/gpu"
46
+ import { checkScreenSize, createBuffer, createRecordStream, destroyBuffer, screenSizeScale } from "@solidrt/core/gpu"
47
+ import type { BufferId, RecordStream, ShaderParams, TextureId } from "@solidrt/core/gpu"
40
48
  import * as spatial from "flux:spatial"
41
49
  import type {
42
50
  Impact as CoreImpact,
@@ -56,7 +64,11 @@ import type { Frame } from "./frames.ts"
56
64
  import { fullFrame, isFrame, writeFrame } from "./frames.ts"
57
65
  import type { RecordLayer } from "./records.ts"
58
66
  import { floorReach, pointInSprite } from "./pick.ts"
59
- import { createSpritePipeline, INSTANCE_LAYOUTS_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
67
+ import { atlasBindings, POSE_ATTRIBUTES, SPRITE_ATTRIBUTES } from "./glsl.ts"
68
+ import { materialSamplers, unlit } from "./material.ts"
69
+ import type { Material } from "./material.ts"
70
+ import { blankStyle, createStyleStreams, growStyle } from "./style.ts"
71
+ import type { StyleStream } from "./style.ts"
60
72
  import { createViews } from "./views.ts"
61
73
  import type { ViewHandle, ViewOptions } from "./views.ts"
62
74
 
@@ -65,24 +77,24 @@ export const POSE_FLOATS = 5
65
77
 
66
78
  // Float offset of world y in a pose record - what `orderBy: "y"` keys on.
67
79
  const POSE_Y_FIELD = 1
68
- /** Floats per style record:
80
+ /** Floats per sprite record (the node layer's slot 1, SPRITE_ATTRIBUTES):
69
81
  * [u0, v0, u1, v1, tintR, tintG, tintB, tintA, renderOrder, minScreenPx,
70
82
  * maxScreenPx, atlas, outlineR, outlineG, outlineB, outlineWidth]. */
71
- export const STYLE_FLOATS = 16
83
+ export const SPRITE_FLOATS = 16
72
84
 
73
- // Float offset of renderOrder in a style record - what `orderBy: "renderOrder"`
85
+ // Float offset of renderOrder in a sprite record - what `orderBy: "renderOrder"`
74
86
  // keys on.
75
- const STYLE_KEY_FIELD = 8
76
- // Float offsets of the screen-size clamp in a style record.
77
- const STYLE_MIN_PX_FIELD = 9
78
- const STYLE_MAX_PX_FIELD = 10
87
+ const SPRITE_KEY_FIELD = 8
88
+ // Float offsets of the screen-size clamp in a sprite record.
89
+ const SPRITE_MIN_PX_FIELD = 9
90
+ const SPRITE_MAX_PX_FIELD = 10
79
91
  // Float offset of the outline (rgb plus width in world pixels) a
80
92
  // distance-field atlas draws under the sprite; text runs write it, a
81
93
  // colour atlas ignores it.
82
- const STYLE_OUTLINE_FIELD = 12
94
+ const SPRITE_OUTLINE_FIELD = 12
83
95
  // Float offset of the atlas sampler index (the frame's texture's position
84
96
  // in the layer's atlas list).
85
- const STYLE_ATLAS_FIELD = 11
97
+ const SPRITE_ATLAS_FIELD = 11
86
98
 
87
99
  const RESOLVED = Promise.resolve()
88
100
 
@@ -287,7 +299,7 @@ export type SpriteOptions = {
287
299
  /**
288
300
  * Show or hide the sprite (default true), @solidrt/3d's setVisible in
289
301
  * the setSprite bag: hidden, its pose slot zeroes (nothing drawn) and
290
- * pick/pickRect skip it; the handle, slot, style records and any
302
+ * pick/pickRect skip it; the handle, slot, sprite records and any
291
303
  * running transition state stay, so showing again restores the sprite
292
304
  * as it was. Node layer only - a record sprite has no node (hide it by
293
305
  * zeroing w or h); setting this there throws.
@@ -320,6 +332,9 @@ export type LayerEventBase = {
320
332
  predicted: boolean
321
333
  pointerId: number
322
334
  pointerType: string
335
+ /** The pressed button on a down, up or tap (0 = primary, 2 = secondary;
336
+ * touch is always 0), as the element event carries it; absent on a
337
+ * move, cancel or wheel. */
323
338
  button?: number
324
339
  shiftKey: boolean
325
340
  ctrlKey: boolean
@@ -378,10 +393,10 @@ type TapFields = {
378
393
  export type SpriteWheelEvent = SpritePointerEvent & WheelFields
379
394
  export type LayerWheelEvent = LayerPointerEvent & WheelFields
380
395
  /**
381
- * A press that released on the target it pressed without travelling past
382
- * the slop, the only pointer down for its whole press (a pinch never taps).
383
- * Dispatched after the up, bubbling the same way; `x`/`y` are the release
384
- * point.
396
+ * A primary-button press (DOM click: a right click never taps) that
397
+ * released on the target it pressed without travelling past the slop, the
398
+ * only pointer down for its whole press (a pinch never taps). Dispatched
399
+ * after the up, bubbling the same way; `x`/`y` are the release point.
385
400
  */
386
401
  export type SpriteTapEvent = SpritePointerEvent & TapFields
387
402
  export type LayerTapEvent = LayerPointerEvent & TapFields
@@ -415,6 +430,17 @@ export function checkTint(verb: string, tint: [number, number, number, number]):
415
430
  }
416
431
  }
417
432
 
433
+ /** Validate a params record (throws - the dev validation policy): a
434
+ * plain object of finite numbers or number lists. Internal - every
435
+ * layer kind's setParams calls it. */
436
+ export function checkParams(verb: string, params: ShaderParams): void {
437
+ if (typeof params !== "object" || params === null || Array.isArray(params)) throw new Error(`${verb}: params must be an object of uniform values, got ${JSON.stringify(params)}`)
438
+ for (let [name, value] of Object.entries(params)) {
439
+ let ok = typeof value === "number" ? Number.isFinite(value) : Array.isArray(value) && value.length > 0 && value.every(Number.isFinite)
440
+ if (!ok) throw new Error(`${verb}: ${name} must be a finite number or a list of them, got ${JSON.stringify(value)}`)
441
+ }
442
+ }
443
+
418
444
  export type SpriteLayerOptions = {
419
445
  /**
420
446
  * Initial slot reservation; default 1024. The layer grows past it on
@@ -427,13 +453,25 @@ export type SpriteLayerOptions = {
427
453
  * setTint. */
428
454
  tint?: [number, number, number, number]
429
455
  /**
430
- * How the sprites blend into the layer's targets, core gpu's BlendMode;
431
- * default "alpha" (premultiplied source-over in draw order). "add" for
432
- * glows, explosions and additive particles, "multiply" to darken, "none"
433
- * to overwrite. Pipeline state, fixed at creation: an additive layer is
434
- * a second layer over the same atlas.
456
+ * What the sprites look like: the layer's material (material.ts),
457
+ * fixed at creation - its pipeline (the blend mode, its program over
458
+ * the layer's atlases), its params and its textures on every view,
459
+ * its instance buffers as per-sprite style streams after the layer's
460
+ * own records. Default `unlit()`: the atlas texel times the tints,
461
+ * alpha-blended in draw order. An additive glow layer is
462
+ * `material: unlit({ blend: "add" })` over the same atlases; a look is
463
+ * `unlit({ prelude, surface })` or a shaderMaterialClass instance. The
464
+ * material sits on the LAYER because the layer is the batch - two
465
+ * looks are two layers, which is also how you order them.
435
466
  */
436
- blend?: BlendMode
467
+ material?: Material
468
+ /**
469
+ * Uniform values seeded on every view of the layer over the
470
+ * material's own (a prelude's uniforms: a flash amount, a palette
471
+ * row, a clock); see setParams. Names the program does not declare
472
+ * are skipped.
473
+ */
474
+ params?: ShaderParams
437
475
  /** Names the GPU resources (buffers, pipeline; views default to
438
476
  * `<label>-view`); default "sprites". */
439
477
  label?: string
@@ -443,17 +481,21 @@ export type SpriteLayerOptions = {
443
481
  * group's `stagger` (a group declaring its own wins for what is under
444
482
  * it). See setStagger. */
445
483
  stagger?: number
484
+ /** The rate the layer's native motion runs at (setTimeScale at
485
+ * creation): 0 freezes every sprite and group transition, 1 is app
486
+ * time; a group declaring its own wins for what is under it. */
487
+ timeScale?: number
446
488
  /** Skip the owner-scoped auto-dispose (see createSpriteLayer). */
447
489
  autoFree?: boolean
448
490
  /**
449
491
  * Draw sprites in KEY order instead of slot order, produced by core at
450
- * every publish (the gpu `instanceOrder` primitive across the pose/style
492
+ * every publish (the gpu `instanceOrder` primitive across the pose/sprite
451
493
  * buffer pair - no per-sprite JS anywhere): `"y"` keys on the sprite's
452
494
  * WORLD y, the pose the core itself writes, so a perspective crowd
453
495
  * paints back to front (smaller y = further up the screen = drawn
454
496
  * first) - and because the key is core-owned, sprites moved by native
455
497
  * transitions or any other core producer re-sort with zero JS per
456
- * frame. Slots stay fixed - handles, picking and the style records are
498
+ * frame. Slots stay fixed - handles, picking and the sprite records are
457
499
  * untouched, only the draw order changes - and ties keep slot order, so
458
500
  * sprites at equal y draw exactly as without it.
459
501
  *
@@ -551,6 +593,9 @@ export type LayerBase = {
551
593
  * in draw order.
552
594
  */
553
595
  readonly atlases: readonly Atlas[]
596
+ /** The material the layer draws with, as declared at creation (the
597
+ * stock `unlit()` when none was). */
598
+ readonly material: Material
554
599
  /** Live sprite count. */
555
600
  readonly count: number
556
601
  /**
@@ -562,6 +607,16 @@ export type LayerBase = {
562
607
  * kinds.
563
608
  */
564
609
  setTint(tint: [number, number, number, number]): void
610
+ /**
611
+ * Write uniform values to every view of the layer (one shared-params
612
+ * write per view, cheap to animate): the material's prelude uniforms
613
+ * - a flash amount, a palette row, a scroll offset, a clock - over the
614
+ * material's own `params`; @solidrt/3d's setMeshParams one dimension
615
+ * down, the layer being the draw entry. Absent names keep their
616
+ * values; a name the program does not declare is skipped. Views
617
+ * created later seed from the merged record.
618
+ */
619
+ setParams(params: ShaderParams): void
565
620
  /** Every shown sprite whose rotated rect contains the layer-pixel point
566
621
  * (a hidden sprite or subtree is never hit), topmost first - the
567
622
  * all-hits shape of @solidrt/3d's pick; `pick(x, y)[0]` is the topmost.
@@ -591,6 +646,17 @@ export type LayerBase = {
591
646
  _read(sprite: Sprite): Required<SpriteOptions>
592
647
  _destroy(sprite: SpriteState): void
593
648
  _schedule(): void
649
+ /** The layer's JS-owned record stream, what records()/updateRecords
650
+ * read and mark: a records layer's whole records, a node layer's
651
+ * sprite records (its poses are the core's). Internal. */
652
+ _stream: RecordStream
653
+ /** The stream's mirror as floats, re-pointed by growth; records() hands
654
+ * it out. Internal. */
655
+ _records: Float32Array
656
+ /** The material's style streams, one per instance buffer it declares,
657
+ * in its order; what setInstanceStyle, instanceAttribute, records(layer,
658
+ * stream) and updateRecords reach. Internal. */
659
+ _styles: StyleStream[]
594
660
  }
595
661
 
596
662
  /** A query volume for overlap/sweep/moveAndSlide, in layer pixels: a
@@ -719,6 +785,20 @@ export type SpriteLayer = LayerBase & {
719
785
  * straight under the layer that begin in one frame (SpriteLayerOptions
720
786
  * `stagger`). */
721
787
  setStagger(ms: number | null): void
788
+ /** Set (or with null clear) the time scale on the layer's root: the
789
+ * rate every sprite and group transition in the layer runs at, with
790
+ * their delays, enters, exits and stagger slots - 0 a pause that leaves
791
+ * the app's UI moving, 1 app time, other values slow or fast motion
792
+ * (SpriteLayerOptions `timeScale`). The nearest declaring node wins:
793
+ * a group or sprite with its own (setGroupTimeScale,
794
+ * setSpriteTimeScale) keeps its rate inside a frozen layer. A frozen
795
+ * layer asks for no frames; this write brings the one that resumes it.
796
+ * Not covered: frame animations (createAnimation), which are JS clocks
797
+ * on app time unless given this layer as their `clock`, and the app's
798
+ * own onFrame logic, which multiplies its step by `timeRate()`. */
799
+ setTimeScale(scale: number | null): void
800
+ /** The rate the layer's root runs at (setTimeScale); 1 after dispose. */
801
+ timeRate(): number
722
802
  /** The core node every sprite and group of the layer sits under: what
723
803
  * scopes the layer's queries in the arena shared with 3d scenes. */
724
804
  _root: NodeId
@@ -826,9 +906,22 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
826
906
  let label = opts?.label ?? "sprites"
827
907
  let tint = opts?.tint ?? [1, 1, 1, 1]
828
908
  checkTint("createSpriteLayer", tint)
909
+ let material = opts?.material ?? unlit()
910
+ checkAtlases("createSpriteLayer", atlases, materialSamplers(material))
911
+ // The params every view seeds: the material's, the layer's over them,
912
+ // merged as setParams writes land so a later view starts current.
913
+ let params: ShaderParams = { ...material.params, ...opts?.params }
829
914
  let pose: BufferId = createBuffer(capacity * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
830
- let style: BufferId = createBuffer(capacity * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
831
- let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_LAYOUTS_SPLIT, opts?.blend ?? "alpha", atlases)
915
+ // The sprite records: JS-written through the sprite verbs (writeSprite),
916
+ // records()/updateRecords for a bulk restyle, published by the flush as
917
+ // the dirty slot range, or the used prefix whole under an order (the
918
+ // core gathers every instance buffer under the one permutation).
919
+ let sprites = createRecordStream(SPRITE_FLOATS * 4, capacity, { label: `${label}-sprites`, autoFree: false })
920
+ // The material's style streams, after the layer's own: the same
921
+ // publish, the same growth.
922
+ let styles = createStyleStreams(material, capacity, label)
923
+ let ordered = opts?.orderBy !== undefined
924
+ let gpu = material.pipeline(atlases, [POSE_ATTRIBUTES, SPRITE_ATTRIBUTES], label)
832
925
  // The sprites with a screen-size clamp (either bound on), and the
833
926
  // furthest any of them reaches from its center at its floor (floorReach,
834
927
  // view pixels): the candidate box a pick searches around the pointer. A
@@ -847,13 +940,12 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
847
940
  // ceiling set alone must not drop below the floor already there.
848
941
  let checkClamp = (verb: string, sprite: SpriteState, opts: SpriteOptions): void => {
849
942
  if (opts.minScreenPx === undefined && opts.maxScreenPx === undefined) return
850
- let at = sprite._slot * STYLE_FLOATS
851
- checkScreenSize(verb, opts.minScreenPx ?? styleData[at + STYLE_MIN_PX_FIELD]!, opts.maxScreenPx ?? styleData[at + STYLE_MAX_PX_FIELD]!)
943
+ let at = sprite._slot * SPRITE_FLOATS
944
+ checkScreenSize(verb, opts.minScreenPx ?? spriteData[at + SPRITE_MIN_PX_FIELD]!, opts.maxScreenPx ?? spriteData[at + SPRITE_MAX_PX_FIELD]!)
852
945
  }
853
946
 
854
947
  let disposed = false
855
948
  let scheduled = false
856
- let styleDirty = false
857
949
  let published = 0
858
950
  // The publish pass of every frame runs the pending flush ahead of the
859
951
  // paint, so a write made anywhere in the frame's JS is in that frame's
@@ -878,7 +970,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
878
970
  // slot stays taken until the core says it is gone; dispose frees them.
879
971
  let leaving = new Set<NodeId>()
880
972
  let gpuCapacity = capacity
881
- let styleData = new Float32Array(capacity * STYLE_FLOATS)
973
+ let spriteData = new Float32Array(sprites.bytes.buffer)
882
974
  let byNode = new Map<NodeId, SpriteState>()
883
975
  // The layer's root node (identity): parentless sprites and groups hang
884
976
  // off it, so a query scoped to it sees exactly this layer.
@@ -886,6 +978,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
886
978
  let root = spatial.createNode(TRANSFORM, true)
887
979
  let filter: QueryFilter = { root }
888
980
  if (opts?.stagger !== undefined) spatial.setTransition(root, { stagger: opts.stagger })
981
+ if (opts?.timeScale !== undefined) spatial.setTimeScale(root, opts.timeScale)
889
982
  // Refill the layer's one filter for a query: the root always, the
890
983
  // include-list as the sprites' nodes when given.
891
984
  let queryFilter = (opts: QueryOptions | undefined, site: string): void => {
@@ -909,12 +1002,8 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
909
1002
  let flush = () => {
910
1003
  scheduled = false
911
1004
  if (disposed) return
912
- if (styleDirty) {
913
- styleDirty = false
914
- let out = beginBufferWrite(style)
915
- out.set(styleData.subarray(0, highWater * STYLE_FLOATS))
916
- endBufferWrite(style, highWater * STYLE_FLOATS * 4)
917
- }
1005
+ if (sprites.dirty) sprites.publish(ordered, highWater)
1006
+ for (let s of styles) if (s.stream.dirty) s.stream.publish(ordered, highWater)
918
1007
  if (published !== highWater) {
919
1008
  views.setCount(highWater)
920
1009
  published = highWater
@@ -924,25 +1013,26 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
924
1013
  spatial.flush()
925
1014
  }
926
1015
 
927
- // Grow both instance buffers to `next` slots: the pose sinks move in one
1016
+ // The instance buffers in pipeline order after the quad: the pose, the
1017
+ // sprite records, the material's style streams.
1018
+ let buffers = (): BufferId[] => [pose, sprites.buffer, ...styles.map(s => s.stream.buffer)]
1019
+ // Grow every instance buffer to `next` slots: the pose sinks move in one
928
1020
  // retargetRecords call (the whole used range republishes at the next
929
- // flush), the style mirror grows in JS and republishes through the lease.
930
- // The entries hold the old buffers alive until the swaps land, so the
931
- // destroys are safe to issue right after.
1021
+ // flush), the record streams grow into replacements marked whole, which
1022
+ // the next flush publishes. The entries hold the old buffers alive until
1023
+ // the swaps land, so the destroys are safe to issue right after.
932
1024
  let grow = (next: number) => {
933
- let newPose = createBuffer(next * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
934
- let newStyle = createBuffer(next * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
935
- spatial.retargetRecords(pose, newPose)
936
- let grownStyle = new Float32Array(next * STYLE_FLOATS)
937
- grownStyle.set(styleData)
938
- styleData = grownStyle
939
- views.setBuffers([newPose, newStyle])
940
- destroyBuffer(pose)
941
- destroyBuffer(style)
942
- pose = newPose
943
- style = newStyle
1025
+ let oldPose = pose
1026
+ pose = createBuffer(next * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
1027
+ spatial.retargetRecords(oldPose, pose)
1028
+ let oldSprites = sprites.grow(next)
1029
+ spriteData = new Float32Array(sprites.bytes.buffer)
1030
+ let oldStyles = styles.map(s => growStyle(s, next))
1031
+ views.setBuffers(buffers())
1032
+ destroyBuffer(oldPose)
1033
+ destroyBuffer(oldSprites)
1034
+ for (let old of oldStyles) destroyBuffer(old)
944
1035
  gpuCapacity = next
945
- styleDirty = true
946
1036
  }
947
1037
 
948
1038
  // A frame's atlas index, or a throw: run before ANY field of a write
@@ -954,43 +1044,45 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
954
1044
  return frameIndex(verb, atlasIndex, opts.frame)
955
1045
  }
956
1046
 
957
- let writeStyle = (sprite: SpriteState, opts: SpriteOptions, atlas: number) => {
958
- let at = sprite._slot * STYLE_FLOATS
1047
+ let writeSprite = (sprite: SpriteState, opts: SpriteOptions, atlas: number) => {
1048
+ let at = sprite._slot * SPRITE_FLOATS
1049
+ let changed = false
959
1050
  let flipX = opts.flipX !== undefined && opts.flipX !== sprite._flipX
960
1051
  let flipY = opts.flipY !== undefined && opts.flipY !== sprite._flipY
961
1052
  if (flipX) sprite._flipX = !sprite._flipX
962
1053
  if (flipY) sprite._flipY = !sprite._flipY
963
1054
  if (opts.frame !== undefined) {
964
1055
  let f = opts.frame
965
- writeFrame(styleData, at, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
966
- styleData[at + STYLE_ATLAS_FIELD] = atlas
967
- styleDirty = true
1056
+ writeFrame(spriteData, at, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
1057
+ spriteData[at + SPRITE_ATLAS_FIELD] = atlas
1058
+ changed = true
968
1059
  } else if (flipX || flipY) {
969
1060
  // No new frame: toggle the changed axes on the stored UVs.
970
- writeFrame(styleData, at, styleData[at]!, styleData[at + 1]!, styleData[at + 2]!, styleData[at + 3]!, flipX, flipY)
971
- styleDirty = true
1061
+ writeFrame(spriteData, at, spriteData[at]!, spriteData[at + 1]!, spriteData[at + 2]!, spriteData[at + 3]!, flipX, flipY)
1062
+ changed = true
972
1063
  }
973
1064
  if (opts.tint !== undefined) {
974
- styleData[at + 4] = opts.tint[0]
975
- styleData[at + 5] = opts.tint[1]
976
- styleData[at + 6] = opts.tint[2]
977
- styleData[at + 7] = opts.tint[3]
978
- styleDirty = true
1065
+ spriteData[at + 4] = opts.tint[0]
1066
+ spriteData[at + 5] = opts.tint[1]
1067
+ spriteData[at + 6] = opts.tint[2]
1068
+ spriteData[at + 7] = opts.tint[3]
1069
+ changed = true
979
1070
  }
980
1071
  if (opts.renderOrder !== undefined) {
981
- styleData[at + STYLE_KEY_FIELD] = opts.renderOrder
982
- styleDirty = true
1072
+ spriteData[at + SPRITE_KEY_FIELD] = opts.renderOrder
1073
+ changed = true
983
1074
  }
984
1075
  if (opts.minScreenPx !== undefined) {
985
- styleData[at + STYLE_MIN_PX_FIELD] = opts.minScreenPx
986
- styleDirty = true
1076
+ spriteData[at + SPRITE_MIN_PX_FIELD] = opts.minScreenPx
1077
+ changed = true
987
1078
  }
988
1079
  if (opts.maxScreenPx !== undefined) {
989
- styleData[at + STYLE_MAX_PX_FIELD] = opts.maxScreenPx
990
- styleDirty = true
1080
+ spriteData[at + SPRITE_MAX_PX_FIELD] = opts.maxScreenPx
1081
+ changed = true
991
1082
  }
992
- let min = styleData[at + STYLE_MIN_PX_FIELD]!
993
- if (min > 0 || styleData[at + STYLE_MAX_PX_FIELD]! > 0) {
1083
+ if (changed) sprites.mark(sprite._slot, sprite._slot + 1)
1084
+ let min = spriteData[at + SPRITE_MIN_PX_FIELD]!
1085
+ if (min > 0 || spriteData[at + SPRITE_MAX_PX_FIELD]! > 0) {
994
1086
  clamped.add(sprite)
995
1087
  let r = floorReach(sprite._w, sprite._h, min)
996
1088
  if (r >= reach) reach = r
@@ -1004,13 +1096,14 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1004
1096
  label,
1005
1097
  pipeline: gpu.pipeline,
1006
1098
  quad: gpu.quad,
1007
- atlases: atlases.map(a => a.texture),
1008
- buffers: () => [pose, style],
1099
+ textures: { ...atlasBindings(atlases.map(a => a.texture)), ...material.textures },
1100
+ buffers,
1009
1101
  count: () => published,
1010
1102
  tint: () => tint,
1103
+ params: () => params,
1011
1104
  pick: (x, y, zoom) => layer.pick(x, y, zoom),
1012
1105
  // The order key one view entry declares: "y" on world y in the pose
1013
- // record (slot 0), "renderOrder" on the app-owned key in the style
1106
+ // record (slot 0), "renderOrder" on the app-owned key in the sprite
1014
1107
  // record (slot 1); either way the core gathers BOTH buffers under the
1015
1108
  // one permutation at every publish and republishes the sibling itself
1016
1109
  // when the key buffer re-orders.
@@ -1018,12 +1111,13 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1018
1111
  opts?.orderBy === "y"
1019
1112
  ? { field: POSE_Y_FIELD }
1020
1113
  : opts?.orderBy === "renderOrder"
1021
- ? { field: STYLE_KEY_FIELD, buffer: 2 }
1114
+ ? { field: SPRITE_KEY_FIELD, buffer: 2 }
1022
1115
  : undefined,
1023
1116
  })
1024
1117
 
1025
1118
  let layer: SpriteLayer = {
1026
1119
  atlases,
1120
+ material,
1027
1121
  get count() {
1028
1122
  return byNode.size
1029
1123
  },
@@ -1033,6 +1127,12 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1033
1127
  tint = next
1034
1128
  views.setTint(next)
1035
1129
  },
1130
+ setParams(next) {
1131
+ if (disposed) return
1132
+ checkParams("setParams", next)
1133
+ Object.assign(params, next)
1134
+ views.setParams(next)
1135
+ },
1036
1136
  createView(vopts) {
1037
1137
  if (disposed) throw new Error("createView: layer is disposed")
1038
1138
  return views.create(vopts)
@@ -1068,7 +1168,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1068
1168
  // the vertex stage draws.
1069
1169
  if (reachDirty) {
1070
1170
  reach = 0
1071
- for (let s of clamped) reach = Math.max(reach, floorReach(s._w, s._h, styleData[s._slot * STYLE_FLOATS + STYLE_MIN_PX_FIELD]!))
1171
+ for (let s of clamped) reach = Math.max(reach, floorReach(s._w, s._h, spriteData[s._slot * SPRITE_FLOATS + SPRITE_MIN_PX_FIELD]!))
1072
1172
  reachDirty = false
1073
1173
  }
1074
1174
  let candidates = new Set<NodeId>()
@@ -1084,8 +1184,8 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1084
1184
  spatial.worldMatrix(sprite.node!, WORLD)
1085
1185
  let w = Math.hypot(WORLD[0]!, WORLD[1]!)
1086
1186
  let h = Math.hypot(WORLD[4]!, WORLD[5]!)
1087
- let at = sprite._slot * STYLE_FLOATS
1088
- let scale = screenSizeScale(w, h, zoom, styleData[at + STYLE_MIN_PX_FIELD]!, styleData[at + STYLE_MAX_PX_FIELD]!)
1187
+ let at = sprite._slot * SPRITE_FLOATS
1188
+ let scale = screenSizeScale(w, h, zoom, spriteData[at + SPRITE_MIN_PX_FIELD]!, spriteData[at + SPRITE_MAX_PX_FIELD]!)
1089
1189
  if (pointInSprite(x, y, WORLD[12]!, WORLD[13]!, w * scale, h * scale, Math.atan2(WORLD[1]!, WORLD[0]!))) out.push(sprite)
1090
1190
  }
1091
1191
  }
@@ -1195,18 +1295,18 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1195
1295
  spatial.flush()
1196
1296
  views.dispose()
1197
1297
  destroyBuffer(pose)
1198
- destroyBuffer(style)
1199
- gpu.dispose()
1298
+ sprites.destroy()
1299
+ for (let s of styles) s.stream.destroy()
1200
1300
  },
1201
1301
  _add(opts) {
1202
1302
  if (disposed) throw new Error("addSprite: layer is disposed")
1203
- // The style bag with its defaults - renderOrder and the clamp at 0
1303
+ // The fields bag with its defaults - renderOrder and the clamp at 0
1204
1304
  // explicitly, since a recycled slot holds the previous occupant's
1205
1305
  // values otherwise - resolved before a slot or node is taken, so a
1206
1306
  // bad frame allocates nothing. An absent key and an explicit
1207
1307
  // undefined both take the default (the options convention
1208
1308
  // everywhere; a plain spread would let undefined win).
1209
- let style: SpriteOptions = {
1309
+ let fields: SpriteOptions = {
1210
1310
  ...opts,
1211
1311
  frame: opts?.frame ?? fullFrame(atlases[0]!),
1212
1312
  tint: opts?.tint ?? [1, 1, 1, 1],
@@ -1214,7 +1314,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1214
1314
  minScreenPx: opts?.minScreenPx ?? 0,
1215
1315
  maxScreenPx: opts?.maxScreenPx ?? 0,
1216
1316
  }
1217
- let atlas = frameAtlas("addSprite", style)
1317
+ let atlas = frameAtlas("addSprite", fields)
1218
1318
  if (opts?.parent && opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
1219
1319
  let slot = freeSlots.pop() ?? highWater++
1220
1320
  if (slot >= gpuCapacity) grow(gpuCapacity * 2)
@@ -1244,20 +1344,22 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1244
1344
  spatial.setBounds(node, COLUMN_BOUNDS)
1245
1345
  spatial.bindPoseRecord(node, pose, slot)
1246
1346
  byNode.set(node, sprite)
1247
- writeStyle(sprite, style, atlas)
1347
+ writeSprite(sprite, fields, atlas)
1248
1348
  // No outline until a text run sets one: a recycled slot would
1249
- // otherwise leak the previous occupant's.
1250
- styleData.fill(0, slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD, slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD + 4)
1349
+ // otherwise leak the previous occupant's. The material's sprites
1350
+ // slots start over for the same reason.
1351
+ spriteData.fill(0, slot * SPRITE_FLOATS + SPRITE_OUTLINE_FIELD, slot * SPRITE_FLOATS + SPRITE_OUTLINE_FIELD + 4)
1352
+ for (let style of styles) blankStyle(style, slot)
1251
1353
  layer._schedule()
1252
1354
  return sprite
1253
1355
  },
1254
1356
  _outline(sprite, r, g, b, width) {
1255
- let at = sprite._slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD
1256
- styleData[at] = r
1257
- styleData[at + 1] = g
1258
- styleData[at + 2] = b
1259
- styleData[at + 3] = width
1260
- styleDirty = true
1357
+ let at = sprite._slot * SPRITE_FLOATS + SPRITE_OUTLINE_FIELD
1358
+ spriteData[at] = r
1359
+ spriteData[at + 1] = g
1360
+ spriteData[at + 2] = b
1361
+ spriteData[at + 3] = width
1362
+ sprites.mark(sprite._slot, sprite._slot + 1)
1261
1363
  layer._schedule()
1262
1364
  },
1263
1365
  _write(sprite, opts) {
@@ -1285,25 +1387,25 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1285
1387
  opts.maxScreenPx !== undefined ||
1286
1388
  (moved && clamped.has(sprite))
1287
1389
  ) {
1288
- writeStyle(sprite, opts, atlas)
1390
+ writeSprite(sprite, opts, atlas)
1289
1391
  }
1290
- if (moved || styleDirty) layer._schedule()
1392
+ if (moved || sprites.dirty) layer._schedule()
1291
1393
  },
1292
1394
  _read(sprite) {
1293
- let at = sprite._slot * STYLE_FLOATS
1395
+ let at = sprite._slot * SPRITE_FLOATS
1294
1396
  return {
1295
1397
  x: sprite._x,
1296
1398
  y: sprite._y,
1297
1399
  w: sprite._w,
1298
1400
  h: sprite._h,
1299
- frame: readFrame(styleData, at, sprite, atlases[styleData[at + STYLE_ATLAS_FIELD]!]!.texture),
1401
+ frame: readFrame(spriteData, at, sprite, atlases[spriteData[at + SPRITE_ATLAS_FIELD]!]!.texture),
1300
1402
  flipX: sprite._flipX,
1301
1403
  flipY: sprite._flipY,
1302
1404
  rotation: sprite._rot,
1303
- tint: [styleData[at + 4]!, styleData[at + 5]!, styleData[at + 6]!, styleData[at + 7]!],
1304
- minScreenPx: styleData[at + STYLE_MIN_PX_FIELD]!,
1305
- maxScreenPx: styleData[at + STYLE_MAX_PX_FIELD]!,
1306
- renderOrder: styleData[at + STYLE_KEY_FIELD]!,
1405
+ tint: [spriteData[at + 4]!, spriteData[at + 5]!, spriteData[at + 6]!, spriteData[at + 7]!],
1406
+ minScreenPx: spriteData[at + SPRITE_MIN_PX_FIELD]!,
1407
+ maxScreenPx: spriteData[at + SPRITE_MAX_PX_FIELD]!,
1408
+ renderOrder: spriteData[at + SPRITE_KEY_FIELD]!,
1307
1409
  visible: sprite._visible,
1308
1410
  }
1309
1411
  },
@@ -1347,6 +1449,13 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1347
1449
  if (disposed) return
1348
1450
  spatial.setTransition(root, ms === null ? null : { stagger: ms })
1349
1451
  },
1452
+ setTimeScale(scale) {
1453
+ if (disposed) return
1454
+ spatial.setTimeScale(root, scale)
1455
+ },
1456
+ timeRate() {
1457
+ return disposed ? 1 : spatial.timeRate(root)
1458
+ },
1350
1459
  _schedule() {
1351
1460
  if (disposed || scheduled) return
1352
1461
  scheduled = true
@@ -1354,6 +1463,11 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1354
1463
  },
1355
1464
  _groups: new Set(),
1356
1465
  _root: root,
1466
+ _stream: sprites,
1467
+ get _records() {
1468
+ return spriteData
1469
+ },
1470
+ _styles: styles,
1357
1471
  }
1358
1472
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
1359
1473
  return layer
@@ -1417,7 +1531,8 @@ export function destroySprite(sprite: Sprite): void {
1417
1531
  * through every enclosing group (@solidrt/3d's worldPosition, Three's
1418
1532
  * getWorldPosition), read from the core's world matrix as the tree stands
1419
1533
  * now, pending writes included. Under a transition this is the
1420
- * mid-flight pose - what picking and the screen show - where the handle's
1534
+ * mid-flight pose of THIS frame (native motion steps before any of the
1535
+ * frame's JS) - what picking and the screen show - where the handle's
1421
1536
  * own fields hold the target. A record layer's sprite has no groups, so
1422
1537
  * its world position is its own, read as getSprite reads it (the records
1423
1538
  * array). A fresh pair per call; null once the handle is inert.
@@ -1580,6 +1695,38 @@ export function setGroupTransition(group: SpriteGroup, transition: SpriteTransit
1580
1695
  declareTransition(group.node, group, node)
1581
1696
  }
1582
1697
 
1698
+ /**
1699
+ * Declare (null clears) the rate the sprite's own native motion runs at
1700
+ * (its transitions, with their delays, enter and exit): 0 freezes it, 1
1701
+ * is app time, other values slow or fast motion. Overrides the layer's
1702
+ * and the enclosing groups' rate for this sprite alone (the nearest
1703
+ * declaring node wins); see SpriteLayer.setTimeScale. Node layer only.
1704
+ */
1705
+ export function setSpriteTimeScale(sprite: Sprite, scale: number | null): void {
1706
+ if (sprite.layer === null) return
1707
+ if (sprite.node === null) throw new Error("setSpriteTimeScale: record sprites have no native motion")
1708
+ spatial.setTimeScale(sprite.node, scale)
1709
+ }
1710
+
1711
+ /** The group counterpart of setSpriteTimeScale: the rate everything under
1712
+ * the group runs at, down to the next group or sprite declaring its own
1713
+ * - a frozen world with one live group inside it. */
1714
+ export function setGroupTimeScale(group: SpriteGroup, scale: number | null): void {
1715
+ if (group.layer === null) return
1716
+ spatial.setTimeScale(group.node, scale)
1717
+ }
1718
+
1719
+ /**
1720
+ * The rate a sprite's or group's motion runs at: its own time scale, else
1721
+ * the nearest declaring group's, else the layer's, else 1. What JS-driven
1722
+ * motion of the handle multiplies its own frame step by. Null for a
1723
+ * destroyed handle or a record layer's sprite (no native motion).
1724
+ */
1725
+ export function timeRate(target: Sprite | SpriteGroup): number | null {
1726
+ if (target.layer === null || target.node === null) return null
1727
+ return spatial.timeRate(target.node)
1728
+ }
1729
+
1583
1730
  /** Add a transform group (see SpriteGroup). */
1584
1731
  export function addGroup(layer: SpriteLayer, opts?: GroupOptions): SpriteGroup {
1585
1732
  let group = {