@solidrt/2d 0.0.55 → 0.0.56

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.
@@ -0,0 +1,73 @@
1
+ // The pure oversample math: arithmetic on plain numbers, no GPU or window
2
+ // imports, so the headless check (checks/oversample-check.ts) drives it
3
+ // directly. oversample.ts wraps it with the device limits; components.tsx
4
+ // adds the measured inputs (window box, display scale, camera).
5
+
6
+ /** Tolerance under a whole number when rounding a display scale up, so a
7
+ * scale that is 3 up to float noise picks 3, not 4. */
8
+ const FIT_EPSILON = 1e-6
9
+
10
+ /** How far below the current factor's lower boundary the scale must fall
11
+ * before an auto-picked oversample shrinks. Growth is immediate (an
12
+ * undersampled layer is visibly soft); the margin keeps a scale
13
+ * oscillating around an integer (a breathing ancestor transform, float
14
+ * noise in a measured box) from re-baking the layer on every swing. */
15
+ const SHRINK_MARGIN = 0.25
16
+
17
+ /** fitOversample against an explicit device size ceiling (see
18
+ * fitOversample in oversample.ts, which supplies limits.maxTextureSize). */
19
+ export function fitOversampleWithin(scale: number, targetW: number, targetH: number, budget: number, maxSize: number): number {
20
+ // The budget bounds the scale, not the rounded factor: the ceiling may
21
+ // still round a fit that fills the window up by one (a 320 x 200 design
22
+ // on a 2560 x 1440 panel fits at 7.2 and needs 8).
23
+ let byBudget = Math.sqrt(budget / (targetW * targetH))
24
+ let n = Math.max(1, Math.ceil(Math.min(scale, byBudget) - FIT_EPSILON))
25
+ let byDevice = Math.floor(maxSize / Math.max(targetW, targetH))
26
+ return Math.max(1, Math.min(n, byDevice))
27
+ }
28
+
29
+ /**
30
+ * The auto-pick decision: the oversample a layer at `current` should move
31
+ * to for a measured `scale`, or null for "keep current" (already there, or
32
+ * the shrink hysteresis holding). `cap` is the maxOversample prop,
33
+ * validated here (throws - the dev policy).
34
+ */
35
+ export function pickOversample(
36
+ current: number,
37
+ scale: number,
38
+ targetW: number,
39
+ targetH: number,
40
+ budget: number,
41
+ maxSize: number,
42
+ cap: number | undefined,
43
+ ): number | null {
44
+ if (cap !== undefined && !(Number.isInteger(cap) && cap >= 1)) {
45
+ throw new Error(`maxOversample must be a positive integer, got ${cap}`)
46
+ }
47
+ let n = fitOversampleWithin(scale, targetW, targetH, budget, maxSize)
48
+ // The cap overrides the shrink hysteresis: lowering maxOversample below
49
+ // the current factor is an explicit ask, not measurement noise.
50
+ let capped = false
51
+ if (cap !== undefined && n > cap) {
52
+ n = cap
53
+ capped = true
54
+ }
55
+ if (!capped && n < current && scale > current - 1 - SHRINK_MARGIN) return null
56
+ return n === current ? null : n
57
+ }
58
+
59
+ /**
60
+ * The device-pixels-per-world-pixel factor of a tile world measured through
61
+ * a rotated camera, before the display scale: the measured box is the AABB
62
+ * of the ROTATED world view, which swells by up to sqrt(2) as the camera
63
+ * turns, but texels per world pixel do not change with rotation - so the
64
+ * swell is divided back out for the known rotation, leaving the camera zoom
65
+ * and any ancestor fit.
66
+ */
67
+ export function tileWorldScale(boxW: number, boxH: number, worldW: number, worldH: number, rotation: number): number {
68
+ let cos = Math.abs(Math.cos(rotation))
69
+ let sin = Math.abs(Math.sin(rotation))
70
+ let rotW = worldW * cos + worldH * sin
71
+ let rotH = worldW * sin + worldH * cos
72
+ return Math.max(boxW / rotW, boxH / rotH)
73
+ }
package/src/oversample.ts CHANGED
@@ -4,11 +4,10 @@
4
4
  // atlas is still sampled nearest inside (texels stay square blocks), the
5
5
  // composite samples linear (each block edge softens over one device pixel
6
6
  // instead of snapping to uneven widths). okf/backlog/2d-layer-display-scale.md.
7
+ // The pure math lives in oversample-math.ts (checkable headless); this
8
+ // module binds it to the device limits and adds the thrash sentinel.
7
9
  import { limits } from "@solidrt/core/gpu"
8
-
9
- /** Tolerance under a whole number when rounding a display scale up, so a
10
- * scale that is 3 up to float noise picks 3, not 4. */
11
- const FIT_EPSILON = 1e-6
10
+ import { fitOversampleWithin } from "./oversample-math.ts"
12
11
 
13
12
  /** Validate an oversample against the target it scales; throws (dev policy). */
14
13
  export function checkOversample(verb: string, n: number, width: number, height: number): void {
@@ -30,11 +29,40 @@ export function checkOversample(verb: string, n: number, width: number, height:
30
29
  * not ask for one) and by what the target can grow to on this device.
31
30
  */
32
31
  export function fitOversample(scale: number, targetW: number, targetH: number, budget: number): number {
33
- // The budget bounds the scale, not the rounded factor: the ceiling may
34
- // still round a fit that fills the window up by one (a 320 x 200 design
35
- // on a 2560 x 1440 panel fits at 7.2 and needs 8).
36
- let byBudget = Math.sqrt(budget / (targetW * targetH))
37
- let n = Math.max(1, Math.ceil(Math.min(scale, byBudget) - FIT_EPSILON))
38
- let byDevice = Math.floor(limits.maxTextureSize / Math.max(targetW, targetH))
39
- return Math.max(1, Math.min(n, byDevice))
32
+ return fitOversampleWithin(scale, targetW, targetH, budget, limits.maxTextureSize)
33
+ }
34
+
35
+ /** Oversample changes inside the window before the thrash warning fires. A
36
+ * healthy layer settles in a change or two (mount, then the post-resize
37
+ * scale); reaching this count means something re-picks every frame. */
38
+ const THRASH_CHANGES = 4
39
+ /** The window those changes must land in, ms. */
40
+ const THRASH_WINDOW_MS = 1000
41
+
42
+ /**
43
+ * The thrash sentinel: each layer calls the returned hook on every ACTUAL
44
+ * oversample change, and bunched changes warn once per layer. Every change
45
+ * resizes and redraws the layer's targets (a tile layer re-bakes every
46
+ * resident chunk), so a scale sweeping an integer boundary every frame is
47
+ * this package's most expensive silent mistake - worth a console line the
48
+ * moment it happens rather than a profiling session later.
49
+ */
50
+ export function thrashSentinel(what: string): () => void {
51
+ let stamps: number[] = []
52
+ let warned = false
53
+ return () => {
54
+ if (warned) return
55
+ let now = performance.now()
56
+ stamps.push(now)
57
+ while (stamps.length > 0 && now - stamps[0]! > THRASH_WINDOW_MS) stamps.shift()
58
+ if (stamps.length < THRASH_CHANGES) return
59
+ warned = true
60
+ console.warn(
61
+ `Oversample thrash: ${what} changed oversample ${THRASH_CHANGES} times within a second. ` +
62
+ `Every change resizes and redraws the layer's targets (a tile layer re-bakes every ` +
63
+ `resident chunk). The usual cause is an animated transform or camera sweeping the ` +
64
+ `measured scale across integer boundaries; pin \`oversample\`, or bound the sweep ` +
65
+ `with \`maxOversample\`. Warned once per layer.`,
66
+ )
67
+ }
40
68
  }
package/src/records.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  // (bespoke flocking, per-frame gameplay logic over every entity at large
3
3
  // populations). Sprites are 13 JS-owned floats in one canonical
4
4
  // Float32Array ordered by draw order (insertion order - painter's
5
- // algorithm, later over earlier); mutations batch to a microtask whose
6
- // flush publishes the live prefix through the zero-copy buffer write lease.
5
+ // algorithm, later over earlier; `orderBy` swaps that for a core-produced
6
+ // key order at publish, records untouched); mutations batch to a microtask
7
+ // whose flush publishes the live prefix through the zero-copy buffer write
8
+ // lease.
7
9
  // A moved sprite is 13 float stores plus one bulk memcpy per dirty frame; a
8
10
  // static layer publishes nothing and therefore costs nothing.
9
11
  //
@@ -30,33 +32,63 @@ import {
30
32
  setTargetSize,
31
33
  } from "@solidrt/core/gpu"
32
34
  import type { BufferId, TextureId } from "@solidrt/core/gpu"
35
+ import { checkCamera, projectCamera, unprojectCamera } from "./camera.ts"
33
36
  import { FULL_FRAME, writeFrame } from "./frames.ts"
34
- import { readFrame, spriteDispatch } from "./layer.ts"
35
- import type { LayerBase, Sprite, SpriteHandlers, SpriteLayerOptions, SpriteOptions } from "./layer.ts"
37
+ import { spriteDispatch } from "./dispatch.ts"
38
+ import { checkTint, readFrame } from "./layer.ts"
39
+ import type { LayerBase, LayerPointerListener, Sprite, SpriteHandlers, SpriteLayerOptions, SpriteOptions, SpriteState } from "./layer.ts"
36
40
  import { pointInSprite } from "./pick.ts"
37
- import { checkOversample } from "./oversample.ts"
41
+ import { checkOversample, thrashSentinel } from "./oversample.ts"
38
42
  import { FRAGMENT, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
39
43
 
40
44
  // Floats per instance record:
41
45
  // [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA]
42
46
  export const FLOATS_PER_SPRITE = 13
43
47
 
48
+ // Float offset of cy in a record - what `orderBy: "y"` keys on.
49
+ const Y_FIELD_OFFSET = 1
50
+
44
51
  const RESOLVED = Promise.resolve()
45
52
 
53
+ export type RecordLayerOptions = Omit<SpriteLayerOptions, "orderBy"> & {
54
+ /**
55
+ * Draw records in KEY order instead of record order, produced by core at
56
+ * each publish (the gpu `instanceOrder` primitive - the flush's lease
57
+ * copy arrives gathered, no per-record JS anywhere): `"y"` keys on the
58
+ * record's cy, so a perspective crowd paints back to front (smaller y =
59
+ * further up the screen = drawn first); or an explicit `{ field,
60
+ * descending? }` float offset into the record for a custom sort key.
61
+ * Record slots stay stable - record i keeps meaning sprite i, and
62
+ * removeSprite still shifts - only the draw order changes. Ties keep
63
+ * record order, so an unset key draws exactly as before. Known
64
+ * limitation: pick() resolves overlapping sprites by record order, not
65
+ * visual order, when a key is set.
66
+ */
67
+ orderBy?: "y" | { field: number; descending?: boolean }
68
+ }
69
+
46
70
  export type RecordLayer = LayerBase & {
47
71
  /**
48
72
  * The canonical record array - the raw power path. Layout per sprite is
49
73
  * FLOATS_PER_SPRITE floats: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR,
50
- * tintG, tintB, tintA], record i at i * FLOATS_PER_SPRITE in draw order.
51
- * Write fields directly for large per-frame populations, then call
52
- * touch() once. Do not cache indices across removeSprite - records
53
- * shift - and do not cache the array across addSprite - growth replaces
54
- * it.
74
+ * tintG, tintB, tintA], record i at i * FLOATS_PER_SPRITE. Record order
75
+ * is draw order - unless the layer was created with `orderBy`, which
76
+ * draws in key order while record i keeps meaning sprite i. Write fields
77
+ * directly for large per-frame populations, then call touch() once. Do
78
+ * not cache indices across removeSprite - records shift - and do not
79
+ * cache the array across addSprite - growth replaces it.
55
80
  */
56
81
  records: Float32Array
82
+ /**
83
+ * Run `fn` with the CURRENT record array and return its result - the
84
+ * hoist-proof form of `records`. A cached array reference silently
85
+ * becomes a dead copy after growth (writes publish nothing); reading
86
+ * through withRecords at use time always hits the live array.
87
+ */
88
+ withRecords<T>(fn: (records: Float32Array) => T): T
57
89
  /** Mark the records dirty and schedule the publish (the raw-path commit). */
58
90
  touch(): void
59
- _order: Sprite[]
91
+ _order: SpriteState[]
60
92
  }
61
93
 
62
94
  /**
@@ -69,7 +101,7 @@ export function createRecordLayer(
69
101
  width: number,
70
102
  height: number,
71
103
  atlas: TextureId,
72
- opts?: SpriteLayerOptions,
104
+ opts?: RecordLayerOptions,
73
105
  ): RecordLayer {
74
106
  let capacity = opts?.capacity ?? 1024
75
107
  if (!(capacity > 0 && Number.isInteger(capacity))) {
@@ -87,12 +119,22 @@ export function createRecordLayer(
87
119
  })
88
120
  let oversample = opts?.oversample ?? 1
89
121
  checkOversample("createRecordLayer", oversample, width, height)
122
+ let tint = opts?.tint ?? [1, 1, 1, 1]
123
+ checkTint("createRecordLayer", tint)
124
+ let orderBy = opts?.orderBy
125
+ let instanceOrder =
126
+ orderBy === undefined
127
+ ? undefined
128
+ : orderBy === "y"
129
+ ? { field: Y_FIELD_OFFSET }
130
+ : { field: orderBy.field, descending: orderBy.descending }
131
+ let thrash = thrashSentinel(`record layer "${label}"`)
90
132
  let texture = createPipelineTexture(
91
133
  VERTEX,
92
134
  FRAGMENT,
93
135
  width * oversample,
94
136
  height * oversample,
95
- { uViewport: [width, height], uCamera: [0, 0, 1, 1] },
137
+ { uViewport: [width, height], uCamera: [0, 0, 1, 1], uCameraRot: [1, 0, 0, 0], uTint: tint },
96
138
  {
97
139
  label,
98
140
  topology: "triangle-strip",
@@ -101,6 +143,7 @@ export function createRecordLayer(
101
143
  buffer: quad,
102
144
  instanceAttributes: INSTANCE_ATTRIBUTES,
103
145
  instanceBuffer: records,
146
+ instanceOrder,
104
147
  instanceCount: 0,
105
148
  blend: "alpha",
106
149
  textures: { uAtlas: atlas },
@@ -113,6 +156,9 @@ export function createRecordLayer(
113
156
  let camX = 0
114
157
  let camY = 0
115
158
  let camZoom = 1
159
+ let camRot = 0
160
+ let camPivotX = 0
161
+ let camPivotY = 0
116
162
  let disposed = false
117
163
  let dirty = false
118
164
  let scheduled = false
@@ -140,6 +186,16 @@ export function createRecordLayer(
140
186
  endBufferWrite(target, count * FLOATS_PER_SPRITE * 4)
141
187
  if (grown !== null) {
142
188
  setDraw(texture, { instanceBuffer: grown, instanceCount: count })
189
+ if (instanceOrder !== undefined) {
190
+ // The growth publish above landed BEFORE the swap (the entry must
191
+ // never point at an unwritten buffer), so the order had not yet
192
+ // followed to the grown buffer and that publish went out ungathered.
193
+ // One more publish, now under the swapped-in order, restores key
194
+ // order - growth frames only.
195
+ let again = beginBufferWrite(grown)
196
+ again.set(layer.records.subarray(0, count * FLOATS_PER_SPRITE))
197
+ endBufferWrite(grown, count * FLOATS_PER_SPRITE * 4)
198
+ }
143
199
  destroyBuffer(records)
144
200
  records = grown
145
201
  published = count
@@ -150,7 +206,13 @@ export function createRecordLayer(
150
206
  }
151
207
 
152
208
  // Record layout: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA]
153
- let writeRecord = (sprite: Sprite, opts: SpriteOptions) => {
209
+ let writeRecord = (sprite: SpriteState, opts: SpriteOptions) => {
210
+ if (opts.renderOrder !== undefined) {
211
+ throw new Error("setSprite: record layers have no renderOrder field; order by a record field with orderBy { field }")
212
+ }
213
+ if (opts.visible !== undefined) {
214
+ throw new Error("setSprite: record sprites have no visibility; hide by zeroing w or h")
215
+ }
154
216
  let at = sprite._slot * FLOATS_PER_SPRITE
155
217
  let r = layer.records
156
218
  if (opts.x !== undefined) r[at] = opts.x
@@ -177,11 +239,7 @@ export function createRecordLayer(
177
239
  }
178
240
  }
179
241
 
180
- let dispatch = spriteDispatch({
181
- size: () => [width, height],
182
- camera: () => [camX, camY, camZoom],
183
- pick: (x, y) => layer.pick(x, y),
184
- })
242
+ let listeners = new Set<LayerPointerListener>()
185
243
 
186
244
  let layer: RecordLayer = {
187
245
  texture,
@@ -189,6 +247,12 @@ export function createRecordLayer(
189
247
  get count() {
190
248
  return layer._order.length
191
249
  },
250
+ get width() {
251
+ return width
252
+ },
253
+ get height() {
254
+ return height
255
+ },
192
256
  setSize(w, h) {
193
257
  if (disposed || (w === width && h === height)) return
194
258
  checkOversample("setSize", oversample, w, h)
@@ -197,35 +261,61 @@ export function createRecordLayer(
197
261
  setTargetSize(texture, w * oversample, h * oversample)
198
262
  setTargetParams(texture, { uViewport: [w, h] })
199
263
  },
264
+ listen(listener) {
265
+ listeners.add(listener)
266
+ return () => {
267
+ listeners.delete(listener)
268
+ }
269
+ },
200
270
  get oversample() {
201
271
  return oversample
202
272
  },
203
273
  setOversample(n) {
204
274
  if (disposed || n === oversample) return
205
275
  checkOversample("setOversample", n, width, height)
276
+ thrash()
206
277
  oversample = n
207
278
  setTargetSize(texture, width * n, height * n)
208
279
  },
209
280
  setCamera(update) {
210
281
  if (disposed) return
282
+ checkCamera(update)
211
283
  if (update.x !== undefined) camX = update.x
212
284
  if (update.y !== undefined) camY = update.y
213
- if (update.zoom !== undefined) {
214
- if (!(update.zoom > 0)) throw new Error(`setCamera: zoom must be positive, got ${update.zoom}`)
215
- camZoom = update.zoom
216
- }
217
- setTargetParams(texture, { uCamera: [camX, camY, camZoom, camZoom] })
285
+ if (update.zoom !== undefined) camZoom = update.zoom
286
+ if (update.rotation !== undefined) camRot = update.rotation
287
+ if (update.pivotX !== undefined) camPivotX = update.pivotX
288
+ if (update.pivotY !== undefined) camPivotY = update.pivotY
289
+ setTargetParams(texture, {
290
+ uCamera: [camX, camY, camZoom, camZoom],
291
+ uCameraRot: [Math.cos(camRot), Math.sin(camRot), camPivotX, camPivotY],
292
+ })
293
+ },
294
+ camera() {
295
+ return { x: camX, y: camY, zoom: camZoom, rotation: camRot, pivotX: camPivotX, pivotY: camPivotY }
296
+ },
297
+ project(x, y) {
298
+ return projectCamera(layer.camera(), x, y)
299
+ },
300
+ unproject(x, y) {
301
+ return unprojectCamera(layer.camera(), x, y)
302
+ },
303
+ setTint(next) {
304
+ if (disposed) return
305
+ checkTint("setTint", next)
306
+ setTargetParams(texture, { uTint: next })
218
307
  },
219
308
  pick(x, y) {
220
309
  // Topmost first: reverse draw order, exact rotated-rect containment.
310
+ let out: Sprite[] = []
221
311
  let r = layer.records
222
312
  for (let i = layer._order.length - 1; i >= 0; i--) {
223
313
  let at = i * FLOATS_PER_SPRITE
224
314
  if (pointInSprite(x, y, r[at]!, r[at + 1]!, r[at + 2]!, r[at + 3]!, r[at + 8]!)) {
225
- return layer._order[i]!
315
+ out.push(layer._order[i]!)
226
316
  }
227
317
  }
228
- return null
318
+ return out
229
319
  },
230
320
  handlersFor(layout) {
231
321
  return dispatch(layout)
@@ -233,6 +323,7 @@ export function createRecordLayer(
233
323
  dispose() {
234
324
  if (disposed) return
235
325
  disposed = true
326
+ listeners.clear()
236
327
  for (let sprite of layer._order) sprite.layer = null
237
328
  layer._order.length = 0
238
329
  destroyTexture(texture)
@@ -249,7 +340,7 @@ export function createRecordLayer(
249
340
  next.set(layer.records)
250
341
  layer.records = next
251
342
  }
252
- let sprite: Sprite = { layer, node: null, _slot: index, _x: 0, _y: 0, _w: 0, _h: 0, _rot: 0, _flipX: false, _flipY: false }
343
+ 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 }
253
344
  layer._order.push(sprite)
254
345
  writeRecord(sprite, {
255
346
  x: 0,
@@ -281,6 +372,10 @@ export function createRecordLayer(
281
372
  flipY: sprite._flipY,
282
373
  rotation: r[at + 8]!,
283
374
  tint: [r[at + 9]!, r[at + 10]!, r[at + 11]!, r[at + 12]!],
375
+ // Record sprites have no key field and no visibility (see
376
+ // writeRecord's throws).
377
+ renderOrder: 0,
378
+ visible: true,
284
379
  }
285
380
  },
286
381
  _remove(sprite) {
@@ -302,11 +397,21 @@ export function createRecordLayer(
302
397
  RESOLVED.then(flush)
303
398
  },
304
399
  records: new Float32Array(capacity * FLOATS_PER_SPRITE),
400
+ withRecords(fn) {
401
+ return fn(layer.records)
402
+ },
305
403
  touch() {
306
404
  layer._schedule()
307
405
  },
308
406
  _order: [],
309
407
  }
408
+ let dispatch = spriteDispatch({
409
+ size: () => [width, height],
410
+ camera: () => layer.camera(),
411
+ pick: (x, y) => layer.pick(x, y),
412
+ root: layer,
413
+ listeners,
414
+ })
310
415
  layer.handlers = dispatch(null)
311
416
 
312
417
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
package/src/shaders.ts CHANGED
@@ -1,18 +1,25 @@
1
1
  // The sprite pipelines: a unit quad instanced over one atlas, mapped
2
- // world -> clip through uCamera (offset + zoom) and uViewport. World and
3
- // clip space are both y-down (core gpu.ts pixel contract), so the mapping
4
- // carries NO flip anywhere - do not add one. Two record layouts share the
5
- // fragment stage:
2
+ // world -> clip through uCamera (offset + zoom), uCameraRot ([cos, sin,
3
+ // pivotX, pivotY]: the camera rotation about the pivot, identity
4
+ // [1, 0, 0, 0]) and uViewport - the projectCamera mapping (camera.ts)
5
+ // spelled in GLSL; the two must agree. World and clip space are both
6
+ // y-down (core gpu.ts pixel contract), so the mapping carries NO flip
7
+ // anywhere - do not add one. Two record layouts share the fragment stage:
6
8
  // - VERTEX + INSTANCE_ATTRIBUTES: the 13-float interleaved record
7
9
  // [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA],
8
10
  // used by the records layer (records.ts) and the tile layer (tiles.ts).
9
11
  // - VERTEX_SPLIT + INSTANCE_ATTRIBUTES_SPLIT: the node-backed live layer
10
12
  // (layer.ts), pose and style in separate instance-buffer slots - slot 0
11
13
  // is the core-written Pose2D record [x, y, angle, sx, sy], slot 1 the
12
- // JS-written style record [u0, v0, u1, v1, tintR, tintG, tintB, tintA].
13
- // The rotation here (clockwise, y-down) and pointInSprite in pick.ts must
14
- // agree; the differential check guards pick.ts against an oracle but NOT
15
- // against these shaders - if you touch one rotation, touch all.
14
+ // JS-written style record [u0, v0, u1, v1, tintR, tintG, tintB, tintA,
15
+ // renderOrder]. The shader never reads iRenderOrder - it exists for the
16
+ // core's instanceOrder (orderBy: "renderOrder"); declared attributes without an
17
+ // active program attribute are skipped, their bytes pad the stride.
18
+ // The rotations here (clockwise, y-down) and their JS partners must
19
+ // agree: iRot with pointInSprite in pick.ts, uCameraRot with
20
+ // projectCamera in camera.ts. The differential checks guard the JS side
21
+ // against oracles but NOT against these shaders - if you touch one
22
+ // rotation, touch all.
16
23
  import { glsl } from "@solidrt/core/gpu"
17
24
  import type { InstanceAttribute, VertexAttribute } from "@solidrt/core/gpu"
18
25
 
@@ -27,12 +34,14 @@ export let VERTEX = glsl`
27
34
  out vec4 vTint;
28
35
  uniform vec2 uViewport;
29
36
  uniform vec4 uCamera;
37
+ uniform vec4 uCameraRot;
30
38
 
31
39
  void main() {
32
40
  vec2 corner = aPos * iSize;
33
41
  float c = cos(iRot), s = sin(iRot);
34
42
  vec2 world = iCenter + vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
35
- vec2 screen = (world - uCamera.xy) * uCamera.zw;
43
+ vec2 view = (world - uCamera.xy) * uCamera.zw;
44
+ vec2 screen = uCameraRot.zw + vec2(view.x * uCameraRot.x - view.y * uCameraRot.y, view.x * uCameraRot.y + view.y * uCameraRot.x);
36
45
  // World and clip are both y-down, so the mapping carries no flip.
37
46
  gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
38
47
  vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
@@ -40,13 +49,17 @@ export let VERTEX = glsl`
40
49
  }
41
50
  `
42
51
 
52
+ // uTint is the LAYER tint, multiplied over the per-instance vTint (identity
53
+ // [1, 1, 1, 1] - GLSL uniforms default to ZERO, so every pipeline creation
54
+ // must pin it explicitly or everything renders transparent black).
43
55
  export let FRAGMENT = glsl`
44
56
  in vec2 vUv;
45
57
  in vec4 vTint;
46
58
  uniform sampler2D uAtlas;
59
+ uniform vec4 uTint;
47
60
 
48
61
  void main() {
49
- fragColor = texture(uAtlas, vUv) * vTint;
62
+ fragColor = texture(uAtlas, vUv) * vTint * uTint;
50
63
  }
51
64
  `
52
65
 
@@ -70,12 +83,14 @@ export let VERTEX_SPLIT = glsl`
70
83
  out vec4 vTint;
71
84
  uniform vec2 uViewport;
72
85
  uniform vec4 uCamera;
86
+ uniform vec4 uCameraRot;
73
87
 
74
88
  void main() {
75
89
  vec2 corner = aPos * iScale;
76
90
  float c = cos(iRot), s = sin(iRot);
77
91
  vec2 world = iPos + vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
78
- vec2 screen = (world - uCamera.xy) * uCamera.zw;
92
+ vec2 view = (world - uCamera.xy) * uCamera.zw;
93
+ vec2 screen = uCameraRot.zw + vec2(view.x * uCameraRot.x - view.y * uCameraRot.y, view.x * uCameraRot.y + view.y * uCameraRot.x);
79
94
  // World and clip are both y-down, so the mapping carries no flip.
80
95
  gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
81
96
  vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
@@ -83,11 +98,13 @@ export let VERTEX_SPLIT = glsl`
83
98
  }
84
99
  `
85
100
 
86
- /** The split layout: slot 0 the Pose2D record, slot 1 the style record. */
101
+ /** The split layout: slot 0 the Pose2D record, slot 1 the style record
102
+ * (iRenderOrder is layout-only - see the header note). */
87
103
  export const INSTANCE_ATTRIBUTES_SPLIT: InstanceAttribute[] = [
88
104
  { name: "iPos", format: "vec2" },
89
105
  { name: "iRot", format: "f32" },
90
106
  { name: "iScale", format: "vec2" },
91
107
  { name: "iUv", format: "vec4", slot: 1 },
92
108
  { name: "iTint", format: "vec4", slot: 1 },
109
+ { name: "iRenderOrder", format: "f32", slot: 1 },
93
110
  ]