@solidrt/2d 0.0.54 → 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.
- package/AGENTS.md +241 -57
- package/examples/README.md +22 -5
- package/examples/anim.tsx +86 -0
- package/examples/camera-probe.tsx +200 -0
- package/examples/camera.tsx +190 -0
- package/examples/parity.tsx +4 -4
- package/examples/pick.tsx +50 -33
- package/examples/tiles.tsx +19 -10
- package/package.json +3 -3
- package/src/animation.ts +178 -0
- package/src/camera-motion.ts +532 -0
- package/src/camera.ts +86 -0
- package/src/camera2d.ts +140 -0
- package/src/components.tsx +330 -76
- package/src/dispatch.ts +231 -0
- package/src/index.ts +30 -5
- package/src/layer.ts +454 -186
- package/src/oversample-math.ts +73 -0
- package/src/oversample.ts +39 -11
- package/src/records.ts +132 -27
- package/src/shaders.ts +29 -12
- package/src/tiles.ts +79 -24
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
6
|
-
//
|
|
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 {
|
|
35
|
-
import
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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:
|
|
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?:
|
|
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:
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
setTargetParams(texture, {
|
|
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
|
-
|
|
315
|
+
out.push(layer._order[i]!)
|
|
226
316
|
}
|
|
227
317
|
}
|
|
228
|
-
return
|
|
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:
|
|
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)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
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
|
|
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
|
]
|