@solidrt/2d 0.0.71 → 0.0.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.
@@ -36,7 +44,7 @@
36
44
  import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core"
37
45
  import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
38
46
  import { checkScreenSize, createBuffer, createRecordStream, destroyBuffer, screenSizeScale } from "@solidrt/core/gpu"
39
- import type { BlendMode, BufferId, RecordStream, TextureId } 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.
@@ -592,12 +647,16 @@ export type LayerBase = {
592
647
  _destroy(sprite: SpriteState): void
593
648
  _schedule(): void
594
649
  /** The layer's JS-owned record stream, what records()/updateRecords
595
- * read and mark: a records layer's whole records, a node layer's style
596
- * records (its poses are the core's). Internal. */
650
+ * read and mark: a records layer's whole records, a node layer's
651
+ * sprite records (its poses are the core's). Internal. */
597
652
  _stream: RecordStream
598
653
  /** The stream's mirror as floats, re-pointed by growth; records() hands
599
654
  * it out. Internal. */
600
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[]
601
660
  }
602
661
 
603
662
  /** A query volume for overlap/sweep/moveAndSlide, in layer pixels: a
@@ -726,6 +785,20 @@ export type SpriteLayer = LayerBase & {
726
785
  * straight under the layer that begin in one frame (SpriteLayerOptions
727
786
  * `stagger`). */
728
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
729
802
  /** The core node every sprite and group of the layer sits under: what
730
803
  * scopes the layer's queries in the arena shared with 3d scenes. */
731
804
  _root: NodeId
@@ -833,14 +906,22 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
833
906
  let label = opts?.label ?? "sprites"
834
907
  let tint = opts?.tint ?? [1, 1, 1, 1]
835
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 }
836
914
  let pose: BufferId = createBuffer(capacity * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
837
- // The style records: JS-written through the sprite verbs (writeStyle),
915
+ // The sprite records: JS-written through the sprite verbs (writeSprite),
838
916
  // records()/updateRecords for a bulk restyle, published by the flush as
839
917
  // the dirty slot range, or the used prefix whole under an order (the
840
- // core gathers both buffers under the one permutation).
841
- let style = createRecordStream(STYLE_FLOATS * 4, capacity, { label: `${label}-style`, autoFree: false })
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)
842
923
  let ordered = opts?.orderBy !== undefined
843
- let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_LAYOUTS_SPLIT, opts?.blend ?? "alpha", atlases)
924
+ let gpu = material.pipeline(atlases, [POSE_ATTRIBUTES, SPRITE_ATTRIBUTES], label)
844
925
  // The sprites with a screen-size clamp (either bound on), and the
845
926
  // furthest any of them reaches from its center at its floor (floorReach,
846
927
  // view pixels): the candidate box a pick searches around the pointer. A
@@ -859,8 +940,8 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
859
940
  // ceiling set alone must not drop below the floor already there.
860
941
  let checkClamp = (verb: string, sprite: SpriteState, opts: SpriteOptions): void => {
861
942
  if (opts.minScreenPx === undefined && opts.maxScreenPx === undefined) return
862
- let at = sprite._slot * STYLE_FLOATS
863
- 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]!)
864
945
  }
865
946
 
866
947
  let disposed = false
@@ -889,7 +970,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
889
970
  // slot stays taken until the core says it is gone; dispose frees them.
890
971
  let leaving = new Set<NodeId>()
891
972
  let gpuCapacity = capacity
892
- let styleData = new Float32Array(style.bytes.buffer)
973
+ let spriteData = new Float32Array(sprites.bytes.buffer)
893
974
  let byNode = new Map<NodeId, SpriteState>()
894
975
  // The layer's root node (identity): parentless sprites and groups hang
895
976
  // off it, so a query scoped to it sees exactly this layer.
@@ -897,6 +978,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
897
978
  let root = spatial.createNode(TRANSFORM, true)
898
979
  let filter: QueryFilter = { root }
899
980
  if (opts?.stagger !== undefined) spatial.setTransition(root, { stagger: opts.stagger })
981
+ if (opts?.timeScale !== undefined) spatial.setTimeScale(root, opts.timeScale)
900
982
  // Refill the layer's one filter for a query: the root always, the
901
983
  // include-list as the sprites' nodes when given.
902
984
  let queryFilter = (opts: QueryOptions | undefined, site: string): void => {
@@ -920,7 +1002,8 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
920
1002
  let flush = () => {
921
1003
  scheduled = false
922
1004
  if (disposed) return
923
- if (style.dirty) style.publish(ordered, highWater)
1005
+ if (sprites.dirty) sprites.publish(ordered, highWater)
1006
+ for (let s of styles) if (s.stream.dirty) s.stream.publish(ordered, highWater)
924
1007
  if (published !== highWater) {
925
1008
  views.setCount(highWater)
926
1009
  published = highWater
@@ -930,20 +1013,25 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
930
1013
  spatial.flush()
931
1014
  }
932
1015
 
933
- // 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
934
1020
  // retargetRecords call (the whole used range republishes at the next
935
- // flush), the style stream grows into a replacement marked whole, which
1021
+ // flush), the record streams grow into replacements marked whole, which
936
1022
  // the next flush publishes. The entries hold the old buffers alive until
937
1023
  // the swaps land, so the destroys are safe to issue right after.
938
1024
  let grow = (next: number) => {
939
- let newPose = createBuffer(next * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
940
- spatial.retargetRecords(pose, newPose)
941
- let oldStyle = style.grow(next)
942
- styleData = new Float32Array(style.bytes.buffer)
943
- views.setBuffers([newPose, style.buffer])
944
- destroyBuffer(pose)
945
- destroyBuffer(oldStyle)
946
- pose = newPose
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)
947
1035
  gpuCapacity = next
948
1036
  }
949
1037
 
@@ -956,8 +1044,8 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
956
1044
  return frameIndex(verb, atlasIndex, opts.frame)
957
1045
  }
958
1046
 
959
- let writeStyle = (sprite: SpriteState, opts: SpriteOptions, atlas: number) => {
960
- let at = sprite._slot * STYLE_FLOATS
1047
+ let writeSprite = (sprite: SpriteState, opts: SpriteOptions, atlas: number) => {
1048
+ let at = sprite._slot * SPRITE_FLOATS
961
1049
  let changed = false
962
1050
  let flipX = opts.flipX !== undefined && opts.flipX !== sprite._flipX
963
1051
  let flipY = opts.flipY !== undefined && opts.flipY !== sprite._flipY
@@ -965,36 +1053,36 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
965
1053
  if (flipY) sprite._flipY = !sprite._flipY
966
1054
  if (opts.frame !== undefined) {
967
1055
  let f = opts.frame
968
- writeFrame(styleData, at, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
969
- styleData[at + STYLE_ATLAS_FIELD] = atlas
1056
+ writeFrame(spriteData, at, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
1057
+ spriteData[at + SPRITE_ATLAS_FIELD] = atlas
970
1058
  changed = true
971
1059
  } else if (flipX || flipY) {
972
1060
  // No new frame: toggle the changed axes on the stored UVs.
973
- writeFrame(styleData, at, styleData[at]!, styleData[at + 1]!, styleData[at + 2]!, styleData[at + 3]!, flipX, flipY)
1061
+ writeFrame(spriteData, at, spriteData[at]!, spriteData[at + 1]!, spriteData[at + 2]!, spriteData[at + 3]!, flipX, flipY)
974
1062
  changed = true
975
1063
  }
976
1064
  if (opts.tint !== undefined) {
977
- styleData[at + 4] = opts.tint[0]
978
- styleData[at + 5] = opts.tint[1]
979
- styleData[at + 6] = opts.tint[2]
980
- styleData[at + 7] = opts.tint[3]
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]
981
1069
  changed = true
982
1070
  }
983
1071
  if (opts.renderOrder !== undefined) {
984
- styleData[at + STYLE_KEY_FIELD] = opts.renderOrder
1072
+ spriteData[at + SPRITE_KEY_FIELD] = opts.renderOrder
985
1073
  changed = true
986
1074
  }
987
1075
  if (opts.minScreenPx !== undefined) {
988
- styleData[at + STYLE_MIN_PX_FIELD] = opts.minScreenPx
1076
+ spriteData[at + SPRITE_MIN_PX_FIELD] = opts.minScreenPx
989
1077
  changed = true
990
1078
  }
991
1079
  if (opts.maxScreenPx !== undefined) {
992
- styleData[at + STYLE_MAX_PX_FIELD] = opts.maxScreenPx
1080
+ spriteData[at + SPRITE_MAX_PX_FIELD] = opts.maxScreenPx
993
1081
  changed = true
994
1082
  }
995
- if (changed) style.mark(sprite._slot, sprite._slot + 1)
996
- let min = styleData[at + STYLE_MIN_PX_FIELD]!
997
- 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) {
998
1086
  clamped.add(sprite)
999
1087
  let r = floorReach(sprite._w, sprite._h, min)
1000
1088
  if (r >= reach) reach = r
@@ -1008,13 +1096,14 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1008
1096
  label,
1009
1097
  pipeline: gpu.pipeline,
1010
1098
  quad: gpu.quad,
1011
- atlases: atlases.map(a => a.texture),
1012
- buffers: () => [pose, style.buffer],
1099
+ textures: { ...atlasBindings(atlases.map(a => a.texture)), ...material.textures },
1100
+ buffers,
1013
1101
  count: () => published,
1014
1102
  tint: () => tint,
1103
+ params: () => params,
1015
1104
  pick: (x, y, zoom) => layer.pick(x, y, zoom),
1016
1105
  // The order key one view entry declares: "y" on world y in the pose
1017
- // 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
1018
1107
  // record (slot 1); either way the core gathers BOTH buffers under the
1019
1108
  // one permutation at every publish and republishes the sibling itself
1020
1109
  // when the key buffer re-orders.
@@ -1022,12 +1111,13 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1022
1111
  opts?.orderBy === "y"
1023
1112
  ? { field: POSE_Y_FIELD }
1024
1113
  : opts?.orderBy === "renderOrder"
1025
- ? { field: STYLE_KEY_FIELD, buffer: 2 }
1114
+ ? { field: SPRITE_KEY_FIELD, buffer: 2 }
1026
1115
  : undefined,
1027
1116
  })
1028
1117
 
1029
1118
  let layer: SpriteLayer = {
1030
1119
  atlases,
1120
+ material,
1031
1121
  get count() {
1032
1122
  return byNode.size
1033
1123
  },
@@ -1037,6 +1127,12 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1037
1127
  tint = next
1038
1128
  views.setTint(next)
1039
1129
  },
1130
+ setParams(next) {
1131
+ if (disposed) return
1132
+ checkParams("setParams", next)
1133
+ Object.assign(params, next)
1134
+ views.setParams(next)
1135
+ },
1040
1136
  createView(vopts) {
1041
1137
  if (disposed) throw new Error("createView: layer is disposed")
1042
1138
  return views.create(vopts)
@@ -1072,7 +1168,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1072
1168
  // the vertex stage draws.
1073
1169
  if (reachDirty) {
1074
1170
  reach = 0
1075
- 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]!))
1076
1172
  reachDirty = false
1077
1173
  }
1078
1174
  let candidates = new Set<NodeId>()
@@ -1088,8 +1184,8 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1088
1184
  spatial.worldMatrix(sprite.node!, WORLD)
1089
1185
  let w = Math.hypot(WORLD[0]!, WORLD[1]!)
1090
1186
  let h = Math.hypot(WORLD[4]!, WORLD[5]!)
1091
- let at = sprite._slot * STYLE_FLOATS
1092
- 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]!)
1093
1189
  if (pointInSprite(x, y, WORLD[12]!, WORLD[13]!, w * scale, h * scale, Math.atan2(WORLD[1]!, WORLD[0]!))) out.push(sprite)
1094
1190
  }
1095
1191
  }
@@ -1199,18 +1295,18 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1199
1295
  spatial.flush()
1200
1296
  views.dispose()
1201
1297
  destroyBuffer(pose)
1202
- style.destroy()
1203
- gpu.dispose()
1298
+ sprites.destroy()
1299
+ for (let s of styles) s.stream.destroy()
1204
1300
  },
1205
1301
  _add(opts) {
1206
1302
  if (disposed) throw new Error("addSprite: layer is disposed")
1207
- // 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
1208
1304
  // explicitly, since a recycled slot holds the previous occupant's
1209
1305
  // values otherwise - resolved before a slot or node is taken, so a
1210
1306
  // bad frame allocates nothing. An absent key and an explicit
1211
1307
  // undefined both take the default (the options convention
1212
1308
  // everywhere; a plain spread would let undefined win).
1213
- let style: SpriteOptions = {
1309
+ let fields: SpriteOptions = {
1214
1310
  ...opts,
1215
1311
  frame: opts?.frame ?? fullFrame(atlases[0]!),
1216
1312
  tint: opts?.tint ?? [1, 1, 1, 1],
@@ -1218,7 +1314,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1218
1314
  minScreenPx: opts?.minScreenPx ?? 0,
1219
1315
  maxScreenPx: opts?.maxScreenPx ?? 0,
1220
1316
  }
1221
- let atlas = frameAtlas("addSprite", style)
1317
+ let atlas = frameAtlas("addSprite", fields)
1222
1318
  if (opts?.parent && opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
1223
1319
  let slot = freeSlots.pop() ?? highWater++
1224
1320
  if (slot >= gpuCapacity) grow(gpuCapacity * 2)
@@ -1248,20 +1344,22 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1248
1344
  spatial.setBounds(node, COLUMN_BOUNDS)
1249
1345
  spatial.bindPoseRecord(node, pose, slot)
1250
1346
  byNode.set(node, sprite)
1251
- writeStyle(sprite, style, atlas)
1347
+ writeSprite(sprite, fields, atlas)
1252
1348
  // No outline until a text run sets one: a recycled slot would
1253
- // otherwise leak the previous occupant's.
1254
- 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)
1255
1353
  layer._schedule()
1256
1354
  return sprite
1257
1355
  },
1258
1356
  _outline(sprite, r, g, b, width) {
1259
- let at = sprite._slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD
1260
- styleData[at] = r
1261
- styleData[at + 1] = g
1262
- styleData[at + 2] = b
1263
- styleData[at + 3] = width
1264
- style.mark(sprite._slot, sprite._slot + 1)
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)
1265
1363
  layer._schedule()
1266
1364
  },
1267
1365
  _write(sprite, opts) {
@@ -1289,25 +1387,25 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1289
1387
  opts.maxScreenPx !== undefined ||
1290
1388
  (moved && clamped.has(sprite))
1291
1389
  ) {
1292
- writeStyle(sprite, opts, atlas)
1390
+ writeSprite(sprite, opts, atlas)
1293
1391
  }
1294
- if (moved || style.dirty) layer._schedule()
1392
+ if (moved || sprites.dirty) layer._schedule()
1295
1393
  },
1296
1394
  _read(sprite) {
1297
- let at = sprite._slot * STYLE_FLOATS
1395
+ let at = sprite._slot * SPRITE_FLOATS
1298
1396
  return {
1299
1397
  x: sprite._x,
1300
1398
  y: sprite._y,
1301
1399
  w: sprite._w,
1302
1400
  h: sprite._h,
1303
- 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),
1304
1402
  flipX: sprite._flipX,
1305
1403
  flipY: sprite._flipY,
1306
1404
  rotation: sprite._rot,
1307
- tint: [styleData[at + 4]!, styleData[at + 5]!, styleData[at + 6]!, styleData[at + 7]!],
1308
- minScreenPx: styleData[at + STYLE_MIN_PX_FIELD]!,
1309
- maxScreenPx: styleData[at + STYLE_MAX_PX_FIELD]!,
1310
- 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]!,
1311
1409
  visible: sprite._visible,
1312
1410
  }
1313
1411
  },
@@ -1351,6 +1449,13 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1351
1449
  if (disposed) return
1352
1450
  spatial.setTransition(root, ms === null ? null : { stagger: ms })
1353
1451
  },
1452
+ setTimeScale(scale) {
1453
+ if (disposed) return
1454
+ spatial.setTimeScale(root, scale)
1455
+ },
1456
+ timeRate() {
1457
+ return disposed ? 1 : spatial.timeRate(root)
1458
+ },
1354
1459
  _schedule() {
1355
1460
  if (disposed || scheduled) return
1356
1461
  scheduled = true
@@ -1358,10 +1463,11 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1358
1463
  },
1359
1464
  _groups: new Set(),
1360
1465
  _root: root,
1361
- _stream: style,
1466
+ _stream: sprites,
1362
1467
  get _records() {
1363
- return styleData
1468
+ return spriteData
1364
1469
  },
1470
+ _styles: styles,
1365
1471
  }
1366
1472
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
1367
1473
  return layer
@@ -1425,7 +1531,8 @@ export function destroySprite(sprite: Sprite): void {
1425
1531
  * through every enclosing group (@solidrt/3d's worldPosition, Three's
1426
1532
  * getWorldPosition), read from the core's world matrix as the tree stands
1427
1533
  * now, pending writes included. Under a transition this is the
1428
- * 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
1429
1536
  * own fields hold the target. A record layer's sprite has no groups, so
1430
1537
  * its world position is its own, read as getSprite reads it (the records
1431
1538
  * array). A fresh pair per call; null once the handle is inert.
@@ -1588,6 +1695,38 @@ export function setGroupTransition(group: SpriteGroup, transition: SpriteTransit
1588
1695
  declareTransition(group.node, group, node)
1589
1696
  }
1590
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
+
1591
1730
  /** Add a transform group (see SpriteGroup). */
1592
1731
  export function addGroup(layer: SpriteLayer, opts?: GroupOptions): SpriteGroup {
1593
1732
  let group = {