@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.
- package/AGENTS.md +417 -70
- package/README.md +3 -3
- package/examples/README.md +49 -7
- package/examples/anim.tsx +84 -0
- package/examples/camera-probe.tsx +202 -0
- package/examples/camera.tsx +196 -0
- package/examples/parity.tsx +15 -13
- package/examples/pick.tsx +89 -36
- package/examples/split-screen.tsx +111 -0
- package/examples/springs.tsx +6 -9
- package/examples/sprites.tsx +9 -12
- package/examples/tiles.tsx +42 -18
- package/examples/views.tsx +177 -0
- package/package.json +3 -3
- package/src/animation.ts +178 -0
- package/src/atlas.ts +35 -33
- package/src/camera.ts +111 -0
- package/src/camera2d.ts +713 -0
- package/src/components/auto-oversample.ts +27 -0
- package/src/components/camera2d.tsx +82 -0
- package/src/components/context.ts +48 -0
- package/src/components/group.tsx +68 -0
- package/src/components/index.ts +28 -0
- package/src/components/sprite-layer.tsx +312 -0
- package/src/components/sprite.tsx +72 -0
- package/src/components/tile-layer.tsx +160 -0
- package/src/components/view2d.tsx +151 -0
- package/src/dispatch.ts +231 -0
- package/src/frames.ts +63 -14
- package/src/index.ts +53 -12
- package/src/input.ts +50 -0
- package/src/layer.ts +716 -309
- package/src/oversample-math.ts +73 -0
- package/src/oversample.ts +39 -11
- package/src/records.ts +109 -109
- package/src/shaders.ts +93 -14
- package/src/tiles.ts +256 -48
- package/src/views.ts +294 -0
- package/src/components.tsx +0 -357
|
@@ -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
|
-
|
|
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
|
//
|
|
@@ -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 {
|
|
35
|
-
import type { LayerBase, Sprite,
|
|
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 {
|
|
38
|
-
import {
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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:
|
|
79
|
+
_order: SpriteState[]
|
|
60
80
|
}
|
|
61
81
|
|
|
62
82
|
/**
|
|
63
|
-
* Create a records layer
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
89
|
-
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
223
|
+
out.push(layer._order[i]!)
|
|
226
224
|
}
|
|
227
225
|
}
|
|
228
|
-
return
|
|
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
|
-
|
|
233
|
+
views.dispose()
|
|
239
234
|
destroyBuffer(records)
|
|
240
|
-
|
|
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:
|
|
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)
|
|
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
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|