@solidrt/2d 0.0.70 → 0.0.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -267,7 +267,20 @@ mark their own record, so a `setSprite` here publishes one record, not
267
267
  the prefix. The POPULATION verbs differ by kind and stay so: a record
268
268
  mesh's records are opaque data (`setRecords` copies in and grows), a
269
269
  record layer's are sprites (`addSprite`/`destroySprite`), so the copy-in
270
- here is `records(layer).set(src)` then `updateRecords(layer)`. It is the
270
+ here is `records(layer).set(src)` then `updateRecords(layer)`. The
271
+ publish itself is the core's record stream (`createRecordStream` in
272
+ `@solidrt/core/gpu`: the GPU buffer, the byte mirror, the dirty range,
273
+ the range-or-whole publish, growth into a replacement), the one model
274
+ the 3d instance streams run on too; this file only decides what to
275
+ mark and when to flush. The node layer speaks the same two verbs over
276
+ its STYLE records: `records(spriteLayer)` is the style mirror
277
+ (`STYLE_FLOATS` floats per slot: `[u0, v0, u1, v1, tint rgba,
278
+ renderOrder, minScreenPx, maxScreenPx, atlas, outline rgb,
279
+ outlineWidth]`, slot-indexed, poses stay the core's) and
280
+ `updateRecords(spriteLayer, { first?, count? })` publishes the slot
281
+ range, so a bulk restyle (a palette cycle over thousands of sprites) is
282
+ one loop and one range write instead of a setSprite each - the 3d
283
+ `records(instancedMesh)` pairing, one dimension down. It is the
271
284
  escape hatch for motion only JS can compute at scale (measured 30k
272
285
  sprites: 12.9ms raw records vs 30.8ms via setSprite; both figures are
273
286
  the WRITE path only - whatever computes the motion is excluded and is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/2d",
3
- "version": "0.0.70",
3
+ "version": "0.0.71",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -15,10 +15,10 @@
15
15
  "AGENTS.md"
16
16
  ],
17
17
  "devDependencies": {
18
- "@solidrt/test": "0.0.70"
18
+ "@solidrt/test": "0.0.71"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "@solidjs/signals": "2.0.0-rc.13",
22
- "@solidrt/core": "0.0.70"
22
+ "@solidrt/core": "0.0.71"
23
23
  }
24
24
  }
package/src/layer.ts CHANGED
@@ -35,8 +35,8 @@
35
35
  // (dispatch.ts) undoes it with unprojectCamera.
36
36
  import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core"
37
37
  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"
38
+ import { checkScreenSize, createBuffer, createRecordStream, destroyBuffer, screenSizeScale } from "@solidrt/core/gpu"
39
+ import type { BlendMode, BufferId, RecordStream, TextureId } from "@solidrt/core/gpu"
40
40
  import * as spatial from "flux:spatial"
41
41
  import type {
42
42
  Impact as CoreImpact,
@@ -591,6 +591,13 @@ export type LayerBase = {
591
591
  _read(sprite: Sprite): Required<SpriteOptions>
592
592
  _destroy(sprite: SpriteState): void
593
593
  _schedule(): void
594
+ /** 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. */
597
+ _stream: RecordStream
598
+ /** The stream's mirror as floats, re-pointed by growth; records() hands
599
+ * it out. Internal. */
600
+ _records: Float32Array
594
601
  }
595
602
 
596
603
  /** A query volume for overlap/sweep/moveAndSlide, in layer pixels: a
@@ -827,7 +834,12 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
827
834
  let tint = opts?.tint ?? [1, 1, 1, 1]
828
835
  checkTint("createSpriteLayer", tint)
829
836
  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 })
837
+ // The style records: JS-written through the sprite verbs (writeStyle),
838
+ // records()/updateRecords for a bulk restyle, published by the flush as
839
+ // 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 })
842
+ let ordered = opts?.orderBy !== undefined
831
843
  let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_LAYOUTS_SPLIT, opts?.blend ?? "alpha", atlases)
832
844
  // The sprites with a screen-size clamp (either bound on), and the
833
845
  // furthest any of them reaches from its center at its floor (floorReach,
@@ -853,7 +865,6 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
853
865
 
854
866
  let disposed = false
855
867
  let scheduled = false
856
- let styleDirty = false
857
868
  let published = 0
858
869
  // The publish pass of every frame runs the pending flush ahead of the
859
870
  // paint, so a write made anywhere in the frame's JS is in that frame's
@@ -878,7 +889,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
878
889
  // slot stays taken until the core says it is gone; dispose frees them.
879
890
  let leaving = new Set<NodeId>()
880
891
  let gpuCapacity = capacity
881
- let styleData = new Float32Array(capacity * STYLE_FLOATS)
892
+ let styleData = new Float32Array(style.bytes.buffer)
882
893
  let byNode = new Map<NodeId, SpriteState>()
883
894
  // The layer's root node (identity): parentless sprites and groups hang
884
895
  // off it, so a query scoped to it sees exactly this layer.
@@ -909,12 +920,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
909
920
  let flush = () => {
910
921
  scheduled = false
911
922
  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
- }
923
+ if (style.dirty) style.publish(ordered, highWater)
918
924
  if (published !== highWater) {
919
925
  views.setCount(highWater)
920
926
  published = highWater
@@ -926,23 +932,19 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
926
932
 
927
933
  // Grow both instance buffers to `next` slots: the pose sinks move in one
928
934
  // 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.
935
+ // flush), the style stream grows into a replacement marked whole, which
936
+ // the next flush publishes. The entries hold the old buffers alive until
937
+ // the swaps land, so the destroys are safe to issue right after.
932
938
  let grow = (next: number) => {
933
939
  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
940
  spatial.retargetRecords(pose, newPose)
936
- let grownStyle = new Float32Array(next * STYLE_FLOATS)
937
- grownStyle.set(styleData)
938
- styleData = grownStyle
939
- views.setBuffers([newPose, newStyle])
941
+ let oldStyle = style.grow(next)
942
+ styleData = new Float32Array(style.bytes.buffer)
943
+ views.setBuffers([newPose, style.buffer])
940
944
  destroyBuffer(pose)
941
- destroyBuffer(style)
945
+ destroyBuffer(oldStyle)
942
946
  pose = newPose
943
- style = newStyle
944
947
  gpuCapacity = next
945
- styleDirty = true
946
948
  }
947
949
 
948
950
  // A frame's atlas index, or a throw: run before ANY field of a write
@@ -956,6 +958,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
956
958
 
957
959
  let writeStyle = (sprite: SpriteState, opts: SpriteOptions, atlas: number) => {
958
960
  let at = sprite._slot * STYLE_FLOATS
961
+ let changed = false
959
962
  let flipX = opts.flipX !== undefined && opts.flipX !== sprite._flipX
960
963
  let flipY = opts.flipY !== undefined && opts.flipY !== sprite._flipY
961
964
  if (flipX) sprite._flipX = !sprite._flipX
@@ -964,31 +967,32 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
964
967
  let f = opts.frame
965
968
  writeFrame(styleData, at, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
966
969
  styleData[at + STYLE_ATLAS_FIELD] = atlas
967
- styleDirty = true
970
+ changed = true
968
971
  } else if (flipX || flipY) {
969
972
  // No new frame: toggle the changed axes on the stored UVs.
970
973
  writeFrame(styleData, at, styleData[at]!, styleData[at + 1]!, styleData[at + 2]!, styleData[at + 3]!, flipX, flipY)
971
- styleDirty = true
974
+ changed = true
972
975
  }
973
976
  if (opts.tint !== undefined) {
974
977
  styleData[at + 4] = opts.tint[0]
975
978
  styleData[at + 5] = opts.tint[1]
976
979
  styleData[at + 6] = opts.tint[2]
977
980
  styleData[at + 7] = opts.tint[3]
978
- styleDirty = true
981
+ changed = true
979
982
  }
980
983
  if (opts.renderOrder !== undefined) {
981
984
  styleData[at + STYLE_KEY_FIELD] = opts.renderOrder
982
- styleDirty = true
985
+ changed = true
983
986
  }
984
987
  if (opts.minScreenPx !== undefined) {
985
988
  styleData[at + STYLE_MIN_PX_FIELD] = opts.minScreenPx
986
- styleDirty = true
989
+ changed = true
987
990
  }
988
991
  if (opts.maxScreenPx !== undefined) {
989
992
  styleData[at + STYLE_MAX_PX_FIELD] = opts.maxScreenPx
990
- styleDirty = true
993
+ changed = true
991
994
  }
995
+ if (changed) style.mark(sprite._slot, sprite._slot + 1)
992
996
  let min = styleData[at + STYLE_MIN_PX_FIELD]!
993
997
  if (min > 0 || styleData[at + STYLE_MAX_PX_FIELD]! > 0) {
994
998
  clamped.add(sprite)
@@ -1005,7 +1009,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1005
1009
  pipeline: gpu.pipeline,
1006
1010
  quad: gpu.quad,
1007
1011
  atlases: atlases.map(a => a.texture),
1008
- buffers: () => [pose, style],
1012
+ buffers: () => [pose, style.buffer],
1009
1013
  count: () => published,
1010
1014
  tint: () => tint,
1011
1015
  pick: (x, y, zoom) => layer.pick(x, y, zoom),
@@ -1195,7 +1199,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1195
1199
  spatial.flush()
1196
1200
  views.dispose()
1197
1201
  destroyBuffer(pose)
1198
- destroyBuffer(style)
1202
+ style.destroy()
1199
1203
  gpu.dispose()
1200
1204
  },
1201
1205
  _add(opts) {
@@ -1257,7 +1261,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1257
1261
  styleData[at + 1] = g
1258
1262
  styleData[at + 2] = b
1259
1263
  styleData[at + 3] = width
1260
- styleDirty = true
1264
+ style.mark(sprite._slot, sprite._slot + 1)
1261
1265
  layer._schedule()
1262
1266
  },
1263
1267
  _write(sprite, opts) {
@@ -1287,7 +1291,7 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1287
1291
  ) {
1288
1292
  writeStyle(sprite, opts, atlas)
1289
1293
  }
1290
- if (moved || styleDirty) layer._schedule()
1294
+ if (moved || style.dirty) layer._schedule()
1291
1295
  },
1292
1296
  _read(sprite) {
1293
1297
  let at = sprite._slot * STYLE_FLOATS
@@ -1354,6 +1358,10 @@ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions):
1354
1358
  },
1355
1359
  _groups: new Set(),
1356
1360
  _root: root,
1361
+ _stream: style,
1362
+ get _records() {
1363
+ return styleData
1364
+ },
1357
1365
  }
1358
1366
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
1359
1367
  return layer
package/src/records.ts CHANGED
@@ -5,12 +5,14 @@
5
5
  // algorithm, later over earlier; `orderBy` swaps that for a core-produced
6
6
  // key order at publish, records untouched); mutations batch, and the flush
7
7
  // (ahead of the frame's paint, or at a microtask outside a frame)
8
- // publishes what changed: the dirty record range as one buffer write, or
9
- // under `orderBy` the drawn prefix whole through the zero-copy write lease
10
- // (the core gathers it into key order during the copy, and a byte range
11
- // has no stable position under a permutation). A moved sprite is 20 float
12
- // stores plus its share of one memcpy per dirty frame; a static layer
13
- // publishes nothing and therefore costs nothing.
8
+ // publishes what changed through the core's record stream
9
+ // (`createRecordStream` in @solidrt/core/gpu, the publish model every
10
+ // JS-stepped population shares): the dirty record range as one buffer
11
+ // write, or under `orderBy` the drawn prefix whole through the zero-copy
12
+ // write lease (the core gathers it into key order during the copy, and a
13
+ // byte range has no stable position under a permutation). A moved sprite
14
+ // is 20 float stores plus its share of one memcpy per dirty frame; a
15
+ // static layer publishes nothing and therefore costs nothing.
14
16
  //
15
17
  // The record vocabulary is @solidrt/3d's record mesh's, one dimension
16
18
  // down: `records(layer)` is the mirror, `updateRecords(layer, { first?,
@@ -32,13 +34,13 @@
32
34
  // (addSprite/setSprite/...) are shared with the node layer; picking here is
33
35
  // the JS reverse walk (pointInSprite), since records have no nodes.
34
36
  import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core"
35
- import { beginBufferWrite, checkScreenSize, createBuffer, destroyBuffer, endBufferWrite, screenSizeScale, writeBuffer } from "@solidrt/core/gpu"
37
+ import { checkScreenSize, createRecordStream, destroyBuffer, screenSizeScale } from "@solidrt/core/gpu"
36
38
  import type { BufferId } from "@solidrt/core/gpu"
37
39
  import { checkAtlases, frameIndex } from "./atlas.ts"
38
40
  import type { Atlas } from "./atlas.ts"
39
41
  import { fullFrame, isFrame, writeFrame } from "./frames.ts"
40
42
  import { checkTint, readFrame } from "./layer.ts"
41
- import type { LayerBase, Sprite, SpriteLayerOptions, SpriteOptions, SpriteState } from "./layer.ts"
43
+ import type { LayerBase, Sprite, SpriteLayer, SpriteLayerOptions, SpriteOptions, SpriteState } from "./layer.ts"
42
44
  import { pointInSprite } from "./pick.ts"
43
45
  import { createSpritePipeline, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
44
46
  import { createViews } from "./views.ts"
@@ -83,10 +85,6 @@ export type RecordLayerOptions = Omit<SpriteLayerOptions, "orderBy"> & {
83
85
  }
84
86
 
85
87
  export type RecordLayer = LayerBase & {
86
- /** The record mirror; read it through records(). Replaced by growth. */
87
- _records: Float32Array
88
- /** The record range [lo, hi) written since the last publish, or null. */
89
- _dirty: [number, number] | null
90
88
  /** The count dial (setRecordCount): how many leading sprites draw and
91
89
  * pick; Infinity (the identity of the min against the live count)
92
90
  * until a dial is set. */
@@ -99,21 +97,34 @@ export type RecordLayer = LayerBase & {
99
97
  export type UpdateRecordsOptions = { first?: number; count?: number }
100
98
 
101
99
  /**
102
- * The layer's record mirror - the raw power path. Layout per sprite is
103
- * INSTANCE_FLOATS floats: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR,
104
- * tintG, tintB, tintA, minScreenPx, maxScreenPx, atlas, outlineR,
105
- * outlineG, outlineB, outlineWidth], record i at i * INSTANCE_FLOATS;
106
- * `atlas` is the frame's texture as its index in the layer's `atlases`
107
- * list, and the outline is read only by a distance-field atlas (see
108
- * `Atlas.sdf`): rgb in 0..1 and a width in world pixels, zero for none. Record order is draw order - unless the
100
+ * The layer's record mirror - the raw power path. On a records layer the
101
+ * layout per sprite is INSTANCE_FLOATS floats: [cx, cy, w, h, u0, v0, u1,
102
+ * v1, rot, tintR, tintG, tintB, tintA, minScreenPx, maxScreenPx, atlas,
103
+ * outlineR, outlineG, outlineB, outlineWidth], record i at i *
104
+ * INSTANCE_FLOATS; `atlas` is the frame's texture as its index in the
105
+ * layer's `atlases` list, and the outline is read only by a
106
+ * distance-field atlas (see `Atlas.sdf`): rgb in 0..1 and a width in
107
+ * world pixels, zero for none. Record order is draw order - unless the
109
108
  * layer was created with `orderBy`, which draws in key order while record
110
- * i keeps meaning sprite i. Write fields directly for large per-frame
111
- * populations, then updateRecords the range. Read it AT USE TIME: addSprite
112
- * past capacity replaces the array, and a hoisted reference becomes a dead
113
- * copy whose writes publish nothing. Do not cache indices across
114
- * destroySprite - records shift. @solidrt/3d's records(mesh).
109
+ * i keeps meaning sprite i. Do not cache indices across destroySprite -
110
+ * records shift.
111
+ *
112
+ * On a node layer (createSpriteLayer) the records are its STYLE records,
113
+ * STYLE_FLOATS floats per sprite slot: [u0, v0, u1, v1, tintR, tintG,
114
+ * tintB, tintA, renderOrder, minScreenPx, maxScreenPx, atlas, outlineR,
115
+ * outlineG, outlineB, outlineWidth], slot i at i * STYLE_FLOATS (a
116
+ * sprite's slot is fixed for its life; a freed slot recycles) - the pose
117
+ * lives in the core and is written through setSprite. The 3d
118
+ * `records(instancedMesh)`, one dimension down: a bulk restyle (a palette
119
+ * cycle over thousands of sprites) is one loop over this and one
120
+ * updateRecords.
121
+ *
122
+ * Write fields directly for large per-frame populations, then
123
+ * updateRecords the range. Read it AT USE TIME: an add past capacity
124
+ * replaces the array, and a hoisted reference becomes a dead copy whose
125
+ * writes publish nothing. @solidrt/3d's records(mesh).
115
126
  */
116
- export function records(layer: RecordLayer): Float32Array {
127
+ export function records(layer: RecordLayer | SpriteLayer): Float32Array {
117
128
  return layer._records
118
129
  }
119
130
 
@@ -123,13 +134,14 @@ export function records(layer: RecordLayer): Float32Array {
123
134
  * moved records of ten thousand cost ten); the whole mirror by default.
124
135
  * Write the mirror first, through records(), then call this. The range
125
136
  * is against the mirror's capacity, not the live sprites: a record past
126
- * the last live sprite is accepted and never drawn. Under `orderBy` any
127
- * range republishes the drawn prefix whole - the core gathers it into
128
- * key order, and a byte range has no stable position under a
129
- * permutation. @solidrt/3d's updateRecords(mesh).
137
+ * the last live sprite (on a node layer, past the highest slot in use)
138
+ * is accepted and never drawn. Under `orderBy` any range republishes the
139
+ * drawn prefix whole - the core gathers it into key order, and a byte
140
+ * range has no stable position under a permutation. @solidrt/3d's
141
+ * updateRecords(mesh).
130
142
  */
131
- export function updateRecords(layer: RecordLayer, options: UpdateRecordsOptions = {}): void {
132
- let capacity = layer._records.length / INSTANCE_FLOATS
143
+ export function updateRecords(layer: RecordLayer | SpriteLayer, options: UpdateRecordsOptions = {}): void {
144
+ let capacity = layer._stream.capacity
133
145
  let first = options.first ?? 0
134
146
  let count = options.count ?? capacity - first
135
147
  if (!Number.isInteger(first) || !Number.isInteger(count) || first < 0 || count < 0 || first + count > capacity) {
@@ -154,14 +166,10 @@ export function setRecordCount(layer: RecordLayer, count: number): void {
154
166
  layer._schedule()
155
167
  }
156
168
 
157
- // Extend the layer's dirty range over records [lo, hi) and schedule the
158
- // flush that publishes it.
159
- function markRecords(layer: RecordLayer, lo: number, hi: number): void {
160
- if (layer._dirty === null) layer._dirty = [lo, hi]
161
- else {
162
- if (lo < layer._dirty[0]) layer._dirty[0] = lo
163
- if (hi > layer._dirty[1]) layer._dirty[1] = hi
164
- }
169
+ // Mark records [lo, hi) of the layer's stream and schedule the flush that
170
+ // publishes them.
171
+ function markRecords(layer: RecordLayer | SpriteLayer, lo: number, hi: number): void {
172
+ layer._stream.mark(lo, hi)
165
173
  layer._schedule()
166
174
  }
167
175
 
@@ -179,10 +187,7 @@ export function createRecordLayer(atlases: Atlas[], opts?: RecordLayerOptions):
179
187
  throw new Error(`createRecordLayer: capacity must be a positive integer, got ${capacity}`)
180
188
  }
181
189
  let label = opts?.label ?? "sprites"
182
- let buffer: BufferId = createBuffer(capacity * RECORD_BYTES, {
183
- label: `${label}-records`,
184
- autoFree: false,
185
- })
190
+ let stream = createRecordStream(RECORD_BYTES, capacity, { label: `${label}-records`, autoFree: false })
186
191
  let tint = opts?.tint ?? [1, 1, 1, 1]
187
192
  checkTint("createRecordLayer", tint)
188
193
  let orderBy = opts?.orderBy
@@ -215,60 +220,45 @@ export function createRecordLayer(atlases: Atlas[], opts?: RecordLayerOptions):
215
220
 
216
221
  // How many leading sprites draw: the dial against the live count.
217
222
  let drawn = () => Math.min(layer._dial, layer._order.length)
218
- // Publish the first `n` records whole through the write lease: a
219
- // replacement buffer's first fill (its contents are unspecified), and
220
- // every publish of an ordered layer (the gather runs in the copy).
221
- let publishWhole = (target: BufferId, n: number) => {
222
- let out = beginBufferWrite(target)
223
- out.set(layer._records.subarray(0, n * INSTANCE_FLOATS))
224
- endBufferWrite(target, n * RECORD_BYTES)
225
- }
226
223
 
227
- // The GPU buffer's record capacity; the mirror grows ahead of it
228
- // (addSprite) and the flush catches the buffer up: a larger buffer is
229
- // created, written in full, swapped in, and the old one destroyed. The
230
- // entries hold the old buffer alive until the swaps land, so the destroy
231
- // is safe to issue right after.
232
- let gpuCapacity = capacity
224
+ // The buffers growth replaced since the last flush (addSprite grows the
225
+ // stream at once; the views still draw from the first of these until the
226
+ // flush swaps the replacement in). The entries hold a swapped-out buffer
227
+ // alive until the swap lands, so the destroys are safe to issue right
228
+ // after.
229
+ let grownFrom: BufferId[] = []
233
230
  let flush = () => {
234
231
  scheduled = false
235
232
  if (disposed) return
236
- let dirty = layer._dirty
237
- layer._dirty = null
238
- let mirror = layer._records
239
233
  let live = layer._order.length
240
234
  let count = drawn()
241
- if (mirror.length > gpuCapacity * INSTANCE_FLOATS) {
242
- gpuCapacity = mirror.length / INSTANCE_FLOATS
243
- let grown = createBuffer(mirror.length * Float32Array.BYTES_PER_ELEMENT, { label: `${label}-records`, autoFree: false })
235
+ if (grownFrom.length > 0) {
244
236
  // The replacement is filled whole before the swap (the entry must
245
237
  // never point at an unwritten buffer): the live prefix, or under
246
238
  // orderBy the drawn one, which is all the gather may see.
247
- publishWhole(grown, ordered ? count : live)
248
- views.setBuffers([grown])
239
+ stream.publish(true, ordered ? count : live)
240
+ views.setBuffers([stream.buffer])
249
241
  views.setCount(count)
250
242
  if (ordered) {
251
243
  // That publish landed BEFORE the swap, so the order had not yet
252
244
  // followed to the grown buffer and it went out ungathered. One
253
245
  // more publish, now under the swapped-in order, restores key
254
246
  // order - growth frames only.
255
- publishWhole(grown, count)
247
+ stream.publish(true, count)
256
248
  }
257
- destroyBuffer(buffer)
258
- buffer = grown
249
+ for (let old of grownFrom) destroyBuffer(old)
250
+ grownFrom.length = 0
259
251
  published = count
260
252
  return
261
253
  }
262
254
  if (ordered) {
263
255
  // The gathered set IS the drawn prefix: a dirty record or a moved
264
256
  // dial republishes it whole.
265
- if (dirty !== null || count !== published) publishWhole(buffer, count)
266
- } else if (dirty !== null) {
257
+ if (stream.dirty || count !== published) stream.publish(true, count)
258
+ } else {
267
259
  // The dirty range, clipped to the live sprites (a record past them
268
260
  // is never drawn): one partial write at its byte offset.
269
- let lo = dirty[0]
270
- let hi = Math.min(dirty[1], live)
271
- if (hi > lo) writeBuffer(buffer, mirror.subarray(lo * INSTANCE_FLOATS, hi * INSTANCE_FLOATS), lo * RECORD_BYTES)
261
+ stream.publish(false, live)
272
262
  }
273
263
  if (count !== published) {
274
264
  views.setCount(count)
@@ -335,7 +325,7 @@ export function createRecordLayer(atlases: Atlas[], opts?: RecordLayerOptions):
335
325
  pipeline: gpu.pipeline,
336
326
  quad: gpu.quad,
337
327
  atlases: atlases.map(a => a.texture),
338
- buffers: () => [buffer],
328
+ buffers: () => [stream.buffer],
339
329
  count: () => published,
340
330
  tint: () => tint,
341
331
  pick: (x, y, zoom) => layer.pick(x, y, zoom),
@@ -383,7 +373,9 @@ export function createRecordLayer(atlases: Atlas[], opts?: RecordLayerOptions):
383
373
  for (let sprite of layer._order) sprite.layer = null
384
374
  layer._order.length = 0
385
375
  views.dispose()
386
- destroyBuffer(buffer)
376
+ for (let old of grownFrom) destroyBuffer(old)
377
+ grownFrom.length = 0
378
+ stream.destroy()
387
379
  gpu.dispose()
388
380
  },
389
381
  _add(opts) {
@@ -407,12 +399,11 @@ export function createRecordLayer(atlases: Atlas[], opts?: RecordLayerOptions):
407
399
  }
408
400
  let atlas = checkWrite("addSprite", record)
409
401
  let index = layer._order.length
410
- if ((index + 1) * INSTANCE_FLOATS > layer._records.length) {
411
- // Growth replaces the mirror (doubling); the flush catches the GPU
412
- // buffer up with a whole publish.
413
- let next = new Float32Array(layer._records.length * 2)
414
- next.set(layer._records)
415
- layer._records = next
402
+ if (index + 1 > stream.capacity) {
403
+ // Growth replaces the mirror and the buffer (doubling); the flush
404
+ // fills the replacement whole and swaps it in.
405
+ grownFrom.push(stream.grow(stream.capacity * 2))
406
+ layer._records = new Float32Array(stream.bytes.buffer)
416
407
  }
417
408
  let sprite: SpriteState = { layer, node: null, _slot: index, _x: 0, _y: 0, _w: 0, _h: 0, _rot: 0, _flipX: false, _flipY: false, _visible: true, _parent: null }
418
409
  layer._order.push(sprite)
@@ -469,8 +460,8 @@ export function createRecordLayer(atlases: Atlas[], opts?: RecordLayerOptions):
469
460
  scheduled = true
470
461
  RESOLVED.then(flush)
471
462
  },
472
- _records: new Float32Array(capacity * INSTANCE_FLOATS),
473
- _dirty: null,
463
+ _stream: stream,
464
+ _records: new Float32Array(stream.bytes.buffer),
474
465
  _dial: Infinity,
475
466
  _order: [],
476
467
  }
package/src/text.ts CHANGED
@@ -9,6 +9,9 @@
9
9
  //
10
10
  // The node layer only: a run is a group. Three verbs like the sprites':
11
11
  // addText, setText (re-laid out, the sprite pool reused), destroyText.
12
+ // A setText that changes only the paint (tint, outline, renderOrder)
13
+ // re-writes the glyph sprites without shaping: a fade is sprite writes,
14
+ // not an engine call per frame.
12
15
  import { addGroup, addSprite, destroyGroup, destroySprite, setGroup, setSprite } from "./layer.ts"
13
16
  import type { Sprite, SpriteGroup, SpriteLayer } from "./layer.ts"
14
17
  import type { SpriteFont } from "./font.ts"
@@ -119,7 +122,9 @@ export function addText(layer: SpriteLayer, opts: AddTextOptions): TextRun {
119
122
  }
120
123
 
121
124
  /** Change a run: the style fields re-lay it out (the sprite pool is
122
- * reused), the pose fields move its group. Absent keys keep values. */
125
+ * reused), the pose fields move its group. Absent keys keep values, and so
126
+ * does a key passed with its current value; a change to only tint,
127
+ * outline or renderOrder re-writes the glyph sprites without shaping. */
123
128
  export function setText(run: TextRun, opts: Partial<TextStyle> & TextPose): void {
124
129
  let state = run as RunState
125
130
  if (state.group.layer === null) return
@@ -127,12 +132,42 @@ export function setText(run: TextRun, opts: Partial<TextStyle> & TextPose): void
127
132
  if (x !== undefined || y !== undefined || rotation !== undefined || visible !== undefined) {
128
133
  setGroup(state.group, { x, y, rotation, visible })
129
134
  }
130
- let keys = Object.keys(style) as (keyof TextStyle)[]
131
- if (keys.some(k => style[k] !== undefined)) {
132
- let next: TextStyle = { ...state._style }
133
- for (let k of keys) if (style[k] !== undefined) (next as Record<string, unknown>)[k] = style[k]
134
- relayout(state, next)
135
+ // Compare by value, not by presence: a component effect passes every
136
+ // style key on any change, and only a key that differs costs anything.
137
+ let current = state._style
138
+ let next: TextStyle = { ...current }
139
+ let reshape = false
140
+ let repaint = false
141
+ for (let k of Object.keys(style) as (keyof TextStyle)[]) {
142
+ let value = style[k]
143
+ if (value === undefined || sameStyleValue(k, value, current[k])) continue
144
+ ;(next as Record<string, unknown>)[k] = value
145
+ if (PAINT_KEYS.has(k)) repaint = true
146
+ else reshape = true
135
147
  }
148
+ if (reshape) relayout(state, next)
149
+ else if (repaint) {
150
+ checkStyle(next)
151
+ state._style = next
152
+ paint(state)
153
+ }
154
+ }
155
+
156
+ // The style keys that change only how a glyph sprite is drawn, never which
157
+ // glyphs or where: every other key re-shapes.
158
+ const PAINT_KEYS: ReadonlySet<keyof TextStyle> = new Set<keyof TextStyle>(["tint", "outline", "renderOrder"])
159
+
160
+ // Equal as a style value: tint by channel, outline by colour and width,
161
+ // the rest by identity.
162
+ function sameStyleValue(key: keyof TextStyle, a: unknown, b: unknown): boolean {
163
+ if (a === b) return true
164
+ if (key === "tint" && Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((c, i) => c === b[i])
165
+ if (key === "outline" && a != null && b != null) {
166
+ let { color: ca, width: wa } = a as NonNullable<TextStyle["outline"]>
167
+ let { color: cb, width: wb } = b as NonNullable<TextStyle["outline"]>
168
+ return wa === wb && ca[0] === cb[0] && ca[1] === cb[1] && ca[2] === cb[2]
169
+ }
170
+ return false
136
171
  }
137
172
 
138
173
  /** Remove the run: its group and every glyph sprite. */
@@ -164,6 +199,27 @@ function relayout(run: RunState, style: TextStyle): void {
164
199
  place(run)
165
200
  }
166
201
 
202
+ // The paint fields of the run's style, as every glyph sprite takes them.
203
+ function paintOf(style: TextStyle) {
204
+ return { tint: style.tint ?? [1, 1, 1, 1], outline: style.outline ?? null, renderOrder: style.renderOrder ?? 0 }
205
+ }
206
+
207
+ // Write a sprite's outline from the style (none clears it).
208
+ function writeOutline(layer: SpriteLayer, sprite: Sprite, outline: TextStyle["outline"]): void {
209
+ if (outline != null) layer._outline(sprite, outline.color[0], outline.color[1], outline.color[2], outline.width)
210
+ else layer._outline(sprite, 0, 0, 0, 0)
211
+ }
212
+
213
+ // Re-paint the glyph sprites in place: the layout, the pool and the
214
+ // pending cells all stand, only tint, outline and order are written.
215
+ function paint(run: RunState): void {
216
+ let { tint, outline, renderOrder } = paintOf(run._style)
217
+ for (let sprite of run.sprites) {
218
+ setSprite(sprite, { tint, renderOrder })
219
+ writeOutline(run.layer, sprite, outline)
220
+ }
221
+ }
222
+
167
223
  // Write every glyph sprite from the layout and the font's cells: a cell
168
224
  // the atlas lacks leaves its sprite hidden and asks the font for it.
169
225
  function place(run: RunState): void {
@@ -172,9 +228,7 @@ function place(run: RunState): void {
172
228
  let fontSize = style.fontSize ?? font.fontSize
173
229
  // Cells are made at `font.size` texels per em; the run draws at fontSize.
174
230
  let scale = fontSize / font.size
175
- let tint = style.tint ?? [1, 1, 1, 1]
176
- let outline = style.outline ?? null
177
- let renderOrder = style.renderOrder ?? 0
231
+ let { tint, outline, renderOrder } = paintOf(style)
178
232
  let placements = run._layout.glyphs
179
233
  run._pending.clear()
180
234
  let missing: number[] = []
@@ -210,8 +264,7 @@ function place(run: RunState): void {
210
264
  } else {
211
265
  setSprite(sprite, fields)
212
266
  }
213
- if (outline !== null) layer._outline(sprite, outline.color[0], outline.color[1], outline.color[2], outline.width)
214
- else layer._outline(sprite, 0, 0, 0, 0)
267
+ writeOutline(layer, sprite, outline)
215
268
  used++
216
269
  }
217
270
  for (let i = used; i < sprites.length; i++) destroySprite(sprites[i]!)