@solidrt/2d 0.0.55 → 0.0.57

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/
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
  //
@@ -18,101 +20,92 @@
18
20
  // (addSprite/setSprite/...) are shared with the node layer; picking here is
19
21
  // the JS reverse walk (pointInSprite), since records have no nodes.
20
22
  import { getOwner, onCleanup } from "@solidrt/core"
21
- import {
22
- beginBufferWrite,
23
- createBuffer,
24
- createPipelineTexture,
25
- destroyBuffer,
26
- destroyTexture,
27
- endBufferWrite,
28
- setDraw,
29
- setTargetParams,
30
- setTargetSize,
31
- } from "@solidrt/core/gpu"
23
+ import { beginBufferWrite, createBuffer, destroyBuffer, endBufferWrite } from "@solidrt/core/gpu"
32
24
  import type { BufferId, TextureId } from "@solidrt/core/gpu"
33
25
  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"
26
+ import { checkTint, readFrame } from "./layer.ts"
27
+ import type { LayerBase, Sprite, SpriteLayerOptions, SpriteOptions, SpriteState } from "./layer.ts"
36
28
  import { pointInSprite } from "./pick.ts"
37
- import { checkOversample } from "./oversample.ts"
38
- import { FRAGMENT, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
29
+ import { createSpritePipeline, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
30
+ import { createViews } from "./views.ts"
39
31
 
40
32
  // Floats per instance record:
41
33
  // [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA]
42
34
  export const FLOATS_PER_SPRITE = 13
43
35
 
36
+ // Float offset of cy in a record - what `orderBy: "y"` keys on.
37
+ const Y_FIELD_OFFSET = 1
38
+
44
39
  const RESOLVED = Promise.resolve()
45
40
 
41
+ export type RecordLayerOptions = Omit<SpriteLayerOptions, "orderBy"> & {
42
+ /**
43
+ * Draw records in KEY order instead of record order, produced by core at
44
+ * each publish (the gpu `instanceOrder` primitive - the flush's lease
45
+ * copy arrives gathered, no per-record JS anywhere): `"y"` keys on the
46
+ * record's cy, so a perspective crowd paints back to front (smaller y =
47
+ * further up the screen = drawn first); or an explicit `{ field,
48
+ * descending? }` float offset into the record for a custom sort key.
49
+ * Record slots stay stable - record i keeps meaning sprite i, and
50
+ * removeSprite still shifts - only the draw order changes. Ties keep
51
+ * record order, so an unset key draws exactly as before. Known
52
+ * limitation: pick() resolves overlapping sprites by record order, not
53
+ * visual order, when a key is set.
54
+ */
55
+ orderBy?: "y" | { field: number; descending?: boolean }
56
+ }
57
+
46
58
  export type RecordLayer = LayerBase & {
47
59
  /**
48
60
  * The canonical record array - the raw power path. Layout per sprite is
49
61
  * 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.
62
+ * tintG, tintB, tintA], record i at i * FLOATS_PER_SPRITE. Record order
63
+ * is draw order - unless the layer was created with `orderBy`, which
64
+ * draws in key order while record i keeps meaning sprite i. Write fields
65
+ * directly for large per-frame populations, then call touch() once. Do
66
+ * not cache indices across removeSprite - records shift - and do not
67
+ * cache the array across addSprite - growth replaces it.
55
68
  */
56
69
  records: Float32Array
70
+ /**
71
+ * Run `fn` with the CURRENT record array and return its result - the
72
+ * hoist-proof form of `records`. A cached array reference silently
73
+ * becomes a dead copy after growth (writes publish nothing); reading
74
+ * through withRecords at use time always hits the live array.
75
+ */
76
+ withRecords<T>(fn: (records: Float32Array) => T): T
57
77
  /** Mark the records dirty and schedule the publish (the raw-path commit). */
58
78
  touch(): void
59
- _order: Sprite[]
79
+ _order: SpriteState[]
60
80
  }
61
81
 
62
82
  /**
63
- * Create a records layer rendering into a `width` x `height` texture from
64
- * one atlas texture (same target shape as createSpriteLayer; the atlas is
65
- * NOT owned). Disposed automatically with the owning reactive scope (opt
66
- * out with `{ autoFree: false }`).
83
+ * Create a records layer over one atlas texture (the atlas is NOT owned).
84
+ * Like createSpriteLayer it renders nothing by itself: `createView` is
85
+ * where it shows. Disposed automatically with the owning reactive scope
86
+ * (opt out with `{ autoFree: false }`).
67
87
  */
68
- export function createRecordLayer(
69
- width: number,
70
- height: number,
71
- atlas: TextureId,
72
- opts?: SpriteLayerOptions,
73
- ): RecordLayer {
88
+ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions): RecordLayer {
74
89
  let capacity = opts?.capacity ?? 1024
75
90
  if (!(capacity > 0 && Number.isInteger(capacity))) {
76
91
  throw new Error(`createRecordLayer: capacity must be a positive integer, got ${capacity}`)
77
92
  }
78
93
  let label = opts?.label ?? "sprites"
79
- // One unit quad (triangle strip), reused by every instance.
80
- let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
81
- label: `${label}-quad`,
82
- autoFree: false,
83
- })
84
94
  let records: BufferId = createBuffer(capacity * FLOATS_PER_SPRITE * 4, {
85
95
  label: `${label}-records`,
86
96
  autoFree: false,
87
97
  })
88
- let oversample = opts?.oversample ?? 1
89
- checkOversample("createRecordLayer", oversample, width, height)
90
- let texture = createPipelineTexture(
91
- VERTEX,
92
- FRAGMENT,
93
- width * oversample,
94
- height * oversample,
95
- { uViewport: [width, height], uCamera: [0, 0, 1, 1] },
96
- {
97
- label,
98
- topology: "triangle-strip",
99
- vertexCount: 4,
100
- attributes: [{ name: "aPos", format: "vec2" }],
101
- buffer: quad,
102
- instanceAttributes: INSTANCE_ATTRIBUTES,
103
- instanceBuffer: records,
104
- instanceCount: 0,
105
- blend: "alpha",
106
- textures: { uAtlas: atlas },
107
- clearColor: opts?.clearColor ?? [0, 0, 0, 0],
108
- autoFree: false,
109
- },
110
- )
98
+ let tint = opts?.tint ?? [1, 1, 1, 1]
99
+ checkTint("createRecordLayer", tint)
100
+ let orderBy = opts?.orderBy
101
+ let instanceOrder =
102
+ orderBy === undefined
103
+ ? undefined
104
+ : orderBy === "y"
105
+ ? { field: Y_FIELD_OFFSET }
106
+ : { field: orderBy.field, descending: orderBy.descending }
107
+ let gpu = createSpritePipeline(label, VERTEX, INSTANCE_ATTRIBUTES)
111
108
 
112
- // Camera state, mirrored for picking (the inverse mapping).
113
- let camX = 0
114
- let camY = 0
115
- let camZoom = 1
116
109
  let disposed = false
117
110
  let dirty = false
118
111
  let scheduled = false
@@ -121,7 +114,7 @@ export function createRecordLayer(
121
114
  // The GPU buffer's record capacity; the canonical array grows ahead of it
122
115
  // (addSprite) and the publish catches the buffer up: a larger buffer is
123
116
  // created, written in full, swapped in, and the old one destroyed. The
124
- // entry holds the old buffer alive until the swap lands, so the destroy
117
+ // entries hold the old buffer alive until the swaps land, so the destroy
125
118
  // is safe to issue right after.
126
119
  let gpuCapacity = capacity
127
120
  let flush = () => {
@@ -139,18 +132,35 @@ export function createRecordLayer(
139
132
  out.set(layer.records.subarray(0, count * FLOATS_PER_SPRITE))
140
133
  endBufferWrite(target, count * FLOATS_PER_SPRITE * 4)
141
134
  if (grown !== null) {
142
- setDraw(texture, { instanceBuffer: grown, instanceCount: count })
135
+ views.setBuffers({ instanceBuffer: grown })
136
+ views.setCount(count)
137
+ if (instanceOrder !== undefined) {
138
+ // The growth publish above landed BEFORE the swap (the entry must
139
+ // never point at an unwritten buffer), so the order had not yet
140
+ // followed to the grown buffer and that publish went out ungathered.
141
+ // One more publish, now under the swapped-in order, restores key
142
+ // order - growth frames only.
143
+ let again = beginBufferWrite(grown)
144
+ again.set(layer.records.subarray(0, count * FLOATS_PER_SPRITE))
145
+ endBufferWrite(grown, count * FLOATS_PER_SPRITE * 4)
146
+ }
143
147
  destroyBuffer(records)
144
148
  records = grown
145
149
  published = count
146
150
  } else if (count !== published) {
147
- setDraw(texture, { instanceCount: count })
151
+ views.setCount(count)
148
152
  published = count
149
153
  }
150
154
  }
151
155
 
152
156
  // Record layout: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA]
153
- let writeRecord = (sprite: Sprite, opts: SpriteOptions) => {
157
+ let writeRecord = (sprite: SpriteState, opts: SpriteOptions) => {
158
+ if (opts.renderOrder !== undefined) {
159
+ throw new Error("setSprite: record layers have no renderOrder field; order by a record field with orderBy { field }")
160
+ }
161
+ if (opts.visible !== undefined) {
162
+ throw new Error("setSprite: record sprites have no visibility; hide by zeroing w or h")
163
+ }
154
164
  let at = sprite._slot * FLOATS_PER_SPRITE
155
165
  let r = layer.records
156
166
  if (opts.x !== undefined) r[at] = opts.x
@@ -177,67 +187,52 @@ export function createRecordLayer(
177
187
  }
178
188
  }
179
189
 
180
- let dispatch = spriteDispatch({
181
- size: () => [width, height],
182
- camera: () => [camX, camY, camZoom],
190
+ let views = createViews({
191
+ label,
192
+ pipeline: gpu.pipeline,
193
+ quad: gpu.quad,
194
+ atlas,
195
+ buffers: () => ({ instanceBuffer: records }),
196
+ count: () => published,
197
+ tint: () => tint,
183
198
  pick: (x, y) => layer.pick(x, y),
199
+ order: instanceOrder,
184
200
  })
185
201
 
186
202
  let layer: RecordLayer = {
187
- texture,
188
- handlers: undefined as unknown as SpriteHandlers,
189
203
  get count() {
190
204
  return layer._order.length
191
205
  },
192
- setSize(w, h) {
193
- if (disposed || (w === width && h === height)) return
194
- checkOversample("setSize", oversample, w, h)
195
- width = w
196
- height = h
197
- setTargetSize(texture, w * oversample, h * oversample)
198
- setTargetParams(texture, { uViewport: [w, h] })
199
- },
200
- get oversample() {
201
- return oversample
202
- },
203
- setOversample(n) {
204
- if (disposed || n === oversample) return
205
- checkOversample("setOversample", n, width, height)
206
- oversample = n
207
- setTargetSize(texture, width * n, height * n)
208
- },
209
- setCamera(update) {
206
+ setTint(next) {
210
207
  if (disposed) return
211
- if (update.x !== undefined) camX = update.x
212
- 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] })
208
+ checkTint("setTint", next)
209
+ tint = next
210
+ views.setTint(next)
211
+ },
212
+ createView(vopts) {
213
+ if (disposed) throw new Error("createView: layer is disposed")
214
+ return views.create(vopts)
218
215
  },
219
216
  pick(x, y) {
220
217
  // Topmost first: reverse draw order, exact rotated-rect containment.
218
+ let out: Sprite[] = []
221
219
  let r = layer.records
222
220
  for (let i = layer._order.length - 1; i >= 0; i--) {
223
221
  let at = i * FLOATS_PER_SPRITE
224
222
  if (pointInSprite(x, y, r[at]!, r[at + 1]!, r[at + 2]!, r[at + 3]!, r[at + 8]!)) {
225
- return layer._order[i]!
223
+ out.push(layer._order[i]!)
226
224
  }
227
225
  }
228
- return null
229
- },
230
- handlersFor(layout) {
231
- return dispatch(layout)
226
+ return out
232
227
  },
233
228
  dispose() {
234
229
  if (disposed) return
235
230
  disposed = true
236
231
  for (let sprite of layer._order) sprite.layer = null
237
232
  layer._order.length = 0
238
- destroyTexture(texture)
233
+ views.dispose()
239
234
  destroyBuffer(records)
240
- destroyBuffer(quad)
235
+ gpu.dispose()
241
236
  },
242
237
  _add(opts) {
243
238
  if (opts?.parent) {
@@ -249,7 +244,7 @@ export function createRecordLayer(
249
244
  next.set(layer.records)
250
245
  layer.records = next
251
246
  }
252
- let sprite: Sprite = { layer, node: null, _slot: index, _x: 0, _y: 0, _w: 0, _h: 0, _rot: 0, _flipX: false, _flipY: false }
247
+ 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
248
  layer._order.push(sprite)
254
249
  writeRecord(sprite, {
255
250
  x: 0,
@@ -281,6 +276,10 @@ export function createRecordLayer(
281
276
  flipY: sprite._flipY,
282
277
  rotation: r[at + 8]!,
283
278
  tint: [r[at + 9]!, r[at + 10]!, r[at + 11]!, r[at + 12]!],
279
+ // Record sprites have no key field and no visibility (see
280
+ // writeRecord's throws).
281
+ renderOrder: 0,
282
+ visible: true,
284
283
  }
285
284
  },
286
285
  _remove(sprite) {
@@ -302,13 +301,14 @@ export function createRecordLayer(
302
301
  RESOLVED.then(flush)
303
302
  },
304
303
  records: new Float32Array(capacity * FLOATS_PER_SPRITE),
304
+ withRecords(fn) {
305
+ return fn(layer.records)
306
+ },
305
307
  touch() {
306
308
  layer._schedule()
307
309
  },
308
310
  _order: [],
309
311
  }
310
- layer.handlers = dispatch(null)
311
-
312
312
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
313
313
  return layer
314
314
  }
package/src/shaders.ts CHANGED
@@ -1,20 +1,27 @@
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.
16
- import { glsl } from "@solidrt/core/gpu"
17
- import type { InstanceAttribute, VertexAttribute } from "@solidrt/core/gpu"
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.
23
+ import { compileShader, createBuffer, createRenderPipeline, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyShader, glsl, linkProgram } from "@solidrt/core/gpu"
24
+ import type { BufferId, InstanceAttribute, RenderPipelineId, VertexAttribute } from "@solidrt/core/gpu"
18
25
 
19
26
  export let VERTEX = glsl`
20
27
  in vec2 aPos;
@@ -24,29 +31,54 @@ export let VERTEX = glsl`
24
31
  in float iRot;
25
32
  in vec4 iTint;
26
33
  out vec2 vUv;
34
+ flat out vec4 vFrame;
27
35
  out vec4 vTint;
28
36
  uniform vec2 uViewport;
29
37
  uniform vec4 uCamera;
38
+ uniform vec4 uCameraRot;
30
39
 
31
40
  void main() {
32
41
  vec2 corner = aPos * iSize;
33
42
  float c = cos(iRot), s = sin(iRot);
34
43
  vec2 world = iCenter + vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
35
- vec2 screen = (world - uCamera.xy) * uCamera.zw;
44
+ vec2 view = (world - uCamera.xy) * uCamera.zw;
45
+ vec2 screen = uCameraRot.zw + vec2(view.x * uCameraRot.x - view.y * uCameraRot.y, view.x * uCameraRot.y + view.y * uCameraRot.x);
36
46
  // World and clip are both y-down, so the mapping carries no flip.
37
47
  gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
38
48
  vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
49
+ vFrame = iUv;
39
50
  vTint = iTint;
40
51
  }
41
52
  `
42
53
 
54
+ // uTint is the LAYER tint, multiplied over the per-instance vTint (identity
55
+ // [1, 1, 1, 1] - GLSL uniforms default to ZERO, so every pipeline creation
56
+ // must pin it explicitly or everything renders transparent black).
57
+ //
58
+ // The sample is clamped to its frame, pulled in by half a texel: a quad at
59
+ // a fractional position lands edge samples so close to the frame's edge
60
+ // that a linear tap (and float interpolation under nearest) reaches the
61
+ // cell next door, painting a one-texel line of the neighbour along the
62
+ // sprite's edge. The clamp keeps the quad's texel mapping 1:1 and only
63
+ // moves those edge samples onto the edge texel, so touching cells do not
64
+ // bleed and no gutter or inset is needed at mip level 0. It cannot help a
65
+ // mip chain, whose texels straddle the cell edge before sampling - that is
66
+ // okf/backlog/2d-atlas-extrude.md. min/max because a flipped frame stores
67
+ // its UVs swapped, and the inner min/max because a sub-texel frame would
68
+ // otherwise hand clamp() a lower bound above its upper one.
43
69
  export let FRAGMENT = glsl`
44
70
  in vec2 vUv;
71
+ flat in vec4 vFrame;
45
72
  in vec4 vTint;
46
73
  uniform sampler2D uAtlas;
74
+ uniform vec4 uTint;
47
75
 
48
76
  void main() {
49
- fragColor = texture(uAtlas, vUv) * vTint;
77
+ vec2 halfTexel = 0.5 / vec2(textureSize(uAtlas, 0));
78
+ vec2 lo = min(vFrame.xy, vFrame.zw) + halfTexel;
79
+ vec2 hi = max(vFrame.xy, vFrame.zw) - halfTexel;
80
+ vec2 uv = clamp(vUv, min(lo, hi), max(lo, hi));
81
+ fragColor = texture(uAtlas, uv) * vTint * uTint;
50
82
  }
51
83
  `
52
84
 
@@ -67,27 +99,74 @@ export let VERTEX_SPLIT = glsl`
67
99
  in vec4 iUv;
68
100
  in vec4 iTint;
69
101
  out vec2 vUv;
102
+ flat out vec4 vFrame;
70
103
  out vec4 vTint;
71
104
  uniform vec2 uViewport;
72
105
  uniform vec4 uCamera;
106
+ uniform vec4 uCameraRot;
73
107
 
74
108
  void main() {
75
109
  vec2 corner = aPos * iScale;
76
110
  float c = cos(iRot), s = sin(iRot);
77
111
  vec2 world = iPos + vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
78
- vec2 screen = (world - uCamera.xy) * uCamera.zw;
112
+ vec2 view = (world - uCamera.xy) * uCamera.zw;
113
+ vec2 screen = uCameraRot.zw + vec2(view.x * uCameraRot.x - view.y * uCameraRot.y, view.x * uCameraRot.y + view.y * uCameraRot.x);
79
114
  // World and clip are both y-down, so the mapping carries no flip.
80
115
  gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
81
116
  vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
117
+ vFrame = iUv;
82
118
  vTint = iTint;
83
119
  }
84
120
  `
85
121
 
86
- /** The split layout: slot 0 the Pose2D record, slot 1 the style record. */
122
+ /** The split layout: slot 0 the Pose2D record, slot 1 the style record
123
+ * (iRenderOrder is layout-only - see the header note). */
87
124
  export const INSTANCE_ATTRIBUTES_SPLIT: InstanceAttribute[] = [
88
125
  { name: "iPos", format: "vec2" },
89
126
  { name: "iRot", format: "f32" },
90
127
  { name: "iScale", format: "vec2" },
91
128
  { name: "iUv", format: "vec4", slot: 1 },
92
129
  { name: "iTint", format: "vec4", slot: 1 },
130
+ { name: "iRenderOrder", format: "f32", slot: 1 },
93
131
  ]
132
+
133
+ /** What a layer's targets draw with: the unit quad every instance reuses
134
+ * and the pipeline over one of the two layouts above. */
135
+ export type SpritePipeline = { quad: BufferId; pipeline: RenderPipelineId; dispose(): void }
136
+
137
+ /**
138
+ * Compile one sprite pipeline: `vertex` with its matching attribute list
139
+ * (VERTEX + INSTANCE_ATTRIBUTES, or the split pair) over the shared
140
+ * fragment stage, alpha-blended triangle strips. Spelled out (not the
141
+ * fused createPipelineTexture) so a layer's targets - its own and every
142
+ * view - add entries over the one pipeline. The program lives as long as
143
+ * the pipeline; `dispose` frees both and the quad.
144
+ */
145
+ export function createSpritePipeline(label: string, vertex: string, instanceAttributes: InstanceAttribute[]): SpritePipeline {
146
+ // One unit quad (triangle strip), reused by every instance.
147
+ let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
148
+ label: `${label}-quad`,
149
+ autoFree: false,
150
+ })
151
+ let vs = compileShader("vertex", vertex, { header: true })
152
+ let fs = compileShader("fragment", FRAGMENT, { header: true })
153
+ let program = linkProgram(vs, fs, { label })
154
+ destroyShader(vs)
155
+ destroyShader(fs)
156
+ let pipeline = createRenderPipeline(program, {
157
+ label,
158
+ topology: "triangle-strip",
159
+ attributes: [{ name: "aPos", format: "vec2" }],
160
+ instanceAttributes,
161
+ blend: "alpha",
162
+ })
163
+ return {
164
+ quad,
165
+ pipeline,
166
+ dispose() {
167
+ destroyRenderPipeline(pipeline)
168
+ destroyProgram(program)
169
+ destroyBuffer(quad)
170
+ },
171
+ }
172
+ }