@solidrt/2d 0.0.52 → 0.0.54
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 +43 -13
- package/examples/sprites.tsx +12 -4
- package/package.json +3 -3
- package/src/components.tsx +75 -5
- package/src/frames.ts +23 -0
- package/src/layer.ts +79 -17
- package/src/oversample.ts +40 -0
- package/src/records.ts +31 -11
- package/src/tiles.ts +47 -5
package/AGENTS.md
CHANGED
|
@@ -43,6 +43,12 @@ moved subtrees in Rust, and picking walks the core BVH.
|
|
|
43
43
|
UNIFORM scale - a group is a frame, never a sprite size; sprite w/h
|
|
44
44
|
lives in the sprite node's scale, which is why sprites cannot parent
|
|
45
45
|
sprites). Child sprite pose fields are local to the group.
|
|
46
|
+
- `flipX`/`flipY` mirror on the UV SIDE: the style/record write swaps
|
|
47
|
+
u0/u1 (v0/v1), so w/h stay the drawn size, a scale transition never sees
|
|
48
|
+
a flip, and picking is unchanged (the vertex stage still carries no
|
|
49
|
+
flip). The flags live on the Sprite and re-apply to every later frame
|
|
50
|
+
write; `getSprite` returns the frame un-mirrored plus the flags. Raw
|
|
51
|
+
`records` writers swap u0/u1 themselves.
|
|
46
52
|
- Mutations batch to a microtask: style lease publish + count setDraw +
|
|
47
53
|
`spatial.flush()`. No mutation, no publish, no frame: a static layer
|
|
48
54
|
costs zero, the same demand-gate story as the rest of the platform.
|
|
@@ -117,10 +123,10 @@ chunks on approach, evict) - okf/backlog/2d-baked-layers.md.
|
|
|
117
123
|
|
|
118
124
|
| Component | Props |
|
|
119
125
|
|---|---|
|
|
120
|
-
| `SpriteLayer` | width, height (layer pixels), atlas (TextureId), capacity?, clearColor?, camera?, label?, ref?, output?, events? |
|
|
126
|
+
| `SpriteLayer` | width, height (layer pixels), atlas (TextureId), capacity?, clearColor?, camera?, oversample?, label?, ref?, output?, events? |
|
|
121
127
|
| `Sprite` | x, y (center; local to the enclosing `<Group>`), w, h, frame?, rotation? (radians, clockwise), tint? ([r,g,b,a] 0..1), transition?, onPointer{Down,Move,Up,Enter,Leave}?, ref? |
|
|
122
128
|
| `Group` | x?, y?, rotation?, scale? (uniform, scales the subtree), transition?, ref? |
|
|
123
|
-
| `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), clearColor?, filter?, chunkTiles?, camera? (TileCamera: x, y, zoom, rotation, pivotX, pivotY), label?, ref? |
|
|
129
|
+
| `TileLayer` | cols, rows, tileW, tileH, atlas (TextureId), clearColor?, filter?, chunkTiles?, oversample?, camera? (TileCamera: x, y, zoom, rotation, pivotX, pivotY), label?, ref? |
|
|
124
130
|
|
|
125
131
|
`SpriteLayer` owns the layer and renders the built-in `<texture>` leaf
|
|
126
132
|
carrying the layer's pointer handlers (opt out with `events={false}`; compose
|
|
@@ -176,12 +182,11 @@ is flat. Event x/y are layer pixels with the camera undone.
|
|
|
176
182
|
bytes and passes `filter: "nearest"` through - use it, or `decodeImage` +
|
|
177
183
|
`createTexture` directly.
|
|
178
184
|
- Tint multiplies the sampled texel (`texture * tint`) and the pipeline
|
|
179
|
-
blends with `blend: "alpha"` in record order
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
fix if it ever matters; note it, do not silently add it.
|
|
185
|
+
blends with `blend: "alpha"` in record order, the premultiplied composite.
|
|
186
|
+
The atlas is premultiplied because `decodeImage` premultiplies by default;
|
|
187
|
+
an atlas uploaded from straight-alpha pixels (`decodeImage(bytes, { alpha:
|
|
188
|
+
"straight" })` + `createTexture`) draws color under transparent texels
|
|
189
|
+
as opaque - the classic "keyed-out backdrop becomes a wash" symptom.
|
|
185
190
|
- `pointInSprite` in pick.ts and the vertex stage's rotation must agree on
|
|
186
191
|
direction (clockwise, y-down). The differential check (pick-check.ts)
|
|
187
192
|
guards the math against an oracle but NOT against the shader - if you
|
|
@@ -196,14 +201,39 @@ is flat. Event x/y are layer pixels with the camera undone.
|
|
|
196
201
|
- `<TileLayer>`'s world view is WORLD sized (cols * tileW) and takes that
|
|
197
202
|
much layout space: put it inside a clipping container (`overflow="clip"`)
|
|
198
203
|
sized to the viewport, or camera panning shows the world hanging out of
|
|
199
|
-
the box.
|
|
204
|
+
the box. Both layers render LAID-OUT elements (`<TileLayer>` a `<view>`,
|
|
205
|
+
`<SpriteLayer>` a `<texture>` leaf) and take no position props, so to
|
|
206
|
+
overlay sprites on tiles wrap each in a `<view position="absolute">`
|
|
207
|
+
inside that container - as plain flex siblings they stack side by side
|
|
208
|
+
instead.
|
|
209
|
+
Both are LAYOUT components: neither can live inside a d-* subtree (the
|
|
210
|
+
insert throws); for a detached parent, `<SpriteLayer output>` hands out
|
|
211
|
+
the texture id for a `<d-texture>` of your own.
|
|
200
212
|
- `clearColor` is PER CHUNK: never-written regions have no chunk and render
|
|
201
213
|
nothing, so a full-bleed ground color belongs on the container behind the
|
|
202
214
|
layer (a `d-rect` under it), not on clearColor.
|
|
203
|
-
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
215
|
+
- Two samplers, two jobs. The ATLAS sampler (createAtlas `filter`) decides
|
|
216
|
+
whether texels are hard blocks ("nearest", pixel art) or smooth
|
|
217
|
+
("linear"). The LAYER's output sampler (the sprite layer's target, the
|
|
218
|
+
tile layer's `filter` option, default "linear") does the composite
|
|
219
|
+
resample to the box, and stays linear: "nearest" there snaps texels to
|
|
220
|
+
uneven widths at any fractional scale (a 3.6x designSize fit draws
|
|
221
|
+
source pixels 3 or 4 device pixels wide - shimmer standing still, boil
|
|
222
|
+
when scrolling). Proper resampling at a fractional or HiDPI scale is the
|
|
223
|
+
`oversample` factor: the layer renders at n texels per layer pixel
|
|
224
|
+
(nearest inside keeps blocks square), the linear composite spreads the
|
|
225
|
+
fraction over one device pixel at block edges. The components pick n
|
|
226
|
+
every layout from their leaf's window box (`getBoundingBoxViewport` x
|
|
227
|
+
`displayScale()`, which composes designSize fits and camera zoom), within
|
|
228
|
+
a budget of the window's own device pixel count; the
|
|
229
|
+
primitives default to 1 and take `{ oversample }` / `setOversample(n)`
|
|
230
|
+
- with `output` on `<SpriteLayer>` there is no built-in leaf, so set it
|
|
231
|
+
yourself. Never fix a shimmer by snapping the fit to an integer: the
|
|
232
|
+
scene should fill its box at any ratio.
|
|
233
|
+
- Retro scrolling habits are the app's, not the layer's: keep the camera
|
|
234
|
+
fractional (rounding it to design pixels makes motion step at the
|
|
235
|
+
rounding rate, not the frame rate), and a game that wants whole-pixel
|
|
236
|
+
scrolling snaps its own camera.
|
|
207
237
|
- A dirty chunk flush publishes and re-bakes that chunk in full. Fine on
|
|
208
238
|
change-only cadence; per-frame setTile churn re-bakes chunks per frame -
|
|
209
239
|
that is sprite-layer work, not tile work.
|
package/examples/sprites.tsx
CHANGED
|
@@ -48,6 +48,10 @@ function App() {
|
|
|
48
48
|
h: SPRITE,
|
|
49
49
|
frame: frames[i % 4],
|
|
50
50
|
rotation: Math.random() * Math.PI * 2,
|
|
51
|
+
// A sprite faces its motion: mirrored on the UV side, so the flip is
|
|
52
|
+
// free of the pose and instant under any transition.
|
|
53
|
+
flipX: vx[i]! < 0,
|
|
54
|
+
flipY: vy[i]! < 0,
|
|
51
55
|
})
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -55,10 +59,14 @@ function App() {
|
|
|
55
59
|
for (let i = 0; i < COUNT; i++) {
|
|
56
60
|
let nx = x[i]! + vx[i]!
|
|
57
61
|
let ny = y[i]! + vy[i]!
|
|
58
|
-
if (nx < SPRITE / 2 || nx > W - SPRITE / 2)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
else
|
|
62
|
+
if (nx < SPRITE / 2 || nx > W - SPRITE / 2) {
|
|
63
|
+
vx[i] = -vx[i]!
|
|
64
|
+
setSprite(sprites[i]!, { flipX: vx[i]! < 0 })
|
|
65
|
+
} else x[i] = nx
|
|
66
|
+
if (ny < SPRITE / 2 || ny > H - SPRITE / 2) {
|
|
67
|
+
vy[i] = -vy[i]!
|
|
68
|
+
setSprite(sprites[i]!, { flipY: vy[i]! < 0 })
|
|
69
|
+
} else y[i] = ny
|
|
62
70
|
setSprite(sprites[i]!, { x: x[i]!, y: y[i]!, rotation: tick / 1000 + i })
|
|
63
71
|
}
|
|
64
72
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/2d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.54",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"AGENTS.md"
|
|
16
16
|
],
|
|
17
17
|
"peerDependencies": {
|
|
18
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
19
|
-
"@solidrt/core": "0.0.
|
|
18
|
+
"@solidjs/signals": "2.0.0-rc.3",
|
|
19
|
+
"@solidrt/core": "0.0.54"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/src/components.tsx
CHANGED
|
@@ -6,12 +6,21 @@
|
|
|
6
6
|
// sprite with `ref` and call setSprite from onFrame - signals carry
|
|
7
7
|
// structure and slow state, per-frame motion goes straight to the layer.
|
|
8
8
|
// The same split, with the same reasoning, as @solidrt/3d's components.
|
|
9
|
-
import { createContext, createEffect, createSignal, For, onCleanup, untrack, useContext } from "@solidrt/core"
|
|
9
|
+
import { createContext, createEffect, createSignal, displayScale, For, getBoundingBoxViewport, onCleanup, onLayout, untrack, useContext, windowSize } from "@solidrt/core"
|
|
10
10
|
import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidrt/core"
|
|
11
11
|
import type { FilterMode } from "@solidrt/core/gpu"
|
|
12
12
|
import { addGroup, addSprite, createSpriteLayer, removeGroup, removeSprite, setGroup, setGroupTransition, setSprite, setSpriteTransition } from "./layer.ts"
|
|
13
13
|
import type { NodeTransition } from "flux:spatial"
|
|
14
14
|
import type { CameraUpdate, Sprite as SpriteHandle, SpriteGroup, SpriteLayer as LayerHandle, SpriteOptions, SpritePointerEvent, TransitionEndEvent } from "./layer.ts"
|
|
15
|
+
import { fitOversample } from "./oversample.ts"
|
|
16
|
+
|
|
17
|
+
// The window's device pixel count: the texel budget an auto-picked
|
|
18
|
+
// oversample target stays within (see fitOversample).
|
|
19
|
+
function windowTexels(): number {
|
|
20
|
+
let win = windowSize()
|
|
21
|
+
let scale = displayScale()
|
|
22
|
+
return win.width * scale * (win.height * scale)
|
|
23
|
+
}
|
|
15
24
|
import { createTileLayer } from "./tiles.ts"
|
|
16
25
|
import type { TileChunk, TileLayer as TileLayerHandle } from "./tiles.ts"
|
|
17
26
|
|
|
@@ -45,6 +54,13 @@ export type SpriteLayerProps = {
|
|
|
45
54
|
clearColor?: [number, number, number, number]
|
|
46
55
|
/** Pan/zoom over the world; a shared-params write, never per-sprite. */
|
|
47
56
|
camera?: CameraUpdate
|
|
57
|
+
/**
|
|
58
|
+
* Target texels per layer pixel. Absent, the component picks it every
|
|
59
|
+
* layout from the built-in leaf's on-screen size (display scale, any
|
|
60
|
+
* designSize fit, layout scaling), so the layer resamples properly at any
|
|
61
|
+
* scale; with `output` there is no built-in leaf, so set it yourself.
|
|
62
|
+
*/
|
|
63
|
+
oversample?: number
|
|
48
64
|
label?: string
|
|
49
65
|
ref?: (layer: LayerHandle) => void
|
|
50
66
|
/**
|
|
@@ -66,6 +82,8 @@ export type SpriteLayerProps = {
|
|
|
66
82
|
* Owns a sprite layer and composites it as an ordinary `<texture>` leaf, so
|
|
67
83
|
* the output takes layout, transforms, blendMode, and pointer events like
|
|
68
84
|
* any element - or hand `output` the texture id and compose it yourself.
|
|
85
|
+
* A layout component: it cannot sit inside a d-* subtree; `output` with a
|
|
86
|
+
* `<d-texture>` is the detached form.
|
|
69
87
|
* Children (`<Sprite>`) render nothing themselves - they populate the
|
|
70
88
|
* retained layer through context.
|
|
71
89
|
*/
|
|
@@ -87,15 +105,37 @@ export let SpriteLayer: ParentComponent<SpriteLayerProps> = props => {
|
|
|
87
105
|
if (camera) layer.setCamera(camera)
|
|
88
106
|
},
|
|
89
107
|
)
|
|
108
|
+
createEffect(
|
|
109
|
+
() => props.oversample,
|
|
110
|
+
n => {
|
|
111
|
+
if (n !== undefined) layer.setOversample(n)
|
|
112
|
+
},
|
|
113
|
+
)
|
|
90
114
|
untrack(() => props.ref)?.(layer)
|
|
91
115
|
let output = untrack(() => props.output)
|
|
92
116
|
let events = untrack(() => props.events) !== false
|
|
117
|
+
// Auto oversample: the built-in leaf's window box, in device pixels, per
|
|
118
|
+
// layer pixel. Picked after every layout, and again when the display scale
|
|
119
|
+
// changes: the first layout runs before the resize event that reports the
|
|
120
|
+
// scale, and a scale change alone lays nothing out. onLayout is not a
|
|
121
|
+
// tracking scope; the reads there are plain.
|
|
122
|
+
let leaf: { id: number } | undefined
|
|
123
|
+
let pick = () => {
|
|
124
|
+
if (!leaf || props.oversample !== undefined) return
|
|
125
|
+
let box = getBoundingBoxViewport(leaf)
|
|
126
|
+
if (!box) return
|
|
127
|
+
let scale = displayScale() * Math.max(box.width / props.width, box.height / props.height)
|
|
128
|
+
layer.setOversample(fitOversample(scale, props.width, props.height, windowTexels()))
|
|
129
|
+
}
|
|
130
|
+
onLayout(pick)
|
|
131
|
+
createEffect(() => displayScale(), pick)
|
|
93
132
|
return (
|
|
94
133
|
<LayerContext value={layer}>
|
|
95
134
|
{output ? (
|
|
96
135
|
untrack(() => output(layer.texture))
|
|
97
136
|
) : (
|
|
98
137
|
<texture
|
|
138
|
+
ref={(n: { id: number }) => (leaf = n)}
|
|
99
139
|
src={layer.texture}
|
|
100
140
|
width={props.width}
|
|
101
141
|
height={props.height}
|
|
@@ -176,8 +216,8 @@ export let Sprite: VoidComponent<SpriteProps> = props => {
|
|
|
176
216
|
let parent = useContext(GroupContext)
|
|
177
217
|
let sprite = untrack(() => addSprite(layer, parent ? { parent } : undefined))
|
|
178
218
|
createEffect(
|
|
179
|
-
() => [props.x, props.y, props.w, props.h, props.frame, props.rotation, props.tint] as const,
|
|
180
|
-
([x, y, w, h, frame, rotation, tint]) => setSprite(sprite, { x, y, w, h, frame, rotation, tint }),
|
|
219
|
+
() => [props.x, props.y, props.w, props.h, props.frame, props.flipX, props.flipY, props.rotation, props.tint] as const,
|
|
220
|
+
([x, y, w, h, frame, flipX, flipY, rotation, tint]) => setSprite(sprite, { x, y, w, h, frame, flipX, flipY, rotation, tint }),
|
|
181
221
|
)
|
|
182
222
|
// After the pose effect, so the mount pose snaps before writes animate.
|
|
183
223
|
createEffect(
|
|
@@ -227,8 +267,16 @@ export type TileLayerProps = {
|
|
|
227
267
|
/** Per-chunk clear color; never-written regions render nothing, so a
|
|
228
268
|
* full-bleed ground color belongs on the container behind the layer. */
|
|
229
269
|
clearColor?: [number, number, number, number]
|
|
230
|
-
/** Sampler filter for the baked chunk textures
|
|
270
|
+
/** Sampler filter for the baked chunk textures at composite time; default
|
|
271
|
+
* "linear" (hard pixels belong to the atlas sampler, see TileLayerOptions). */
|
|
231
272
|
filter?: FilterMode
|
|
273
|
+
/**
|
|
274
|
+
* Target texels per world pixel in the baked chunks. Absent, the component
|
|
275
|
+
* picks it every layout from the world view's on-screen size (display
|
|
276
|
+
* scale, camera zoom, any designSize fit), so tiles resample properly at
|
|
277
|
+
* any scale.
|
|
278
|
+
*/
|
|
279
|
+
oversample?: number
|
|
232
280
|
/** Chunk edge in tiles (default ~512px worth); see TileLayerOptions. */
|
|
233
281
|
chunkTiles?: number
|
|
234
282
|
/**
|
|
@@ -244,7 +292,9 @@ export type TileLayerProps = {
|
|
|
244
292
|
/**
|
|
245
293
|
* Owns a baked tile layer (createTileLayer) and composites its chunks as
|
|
246
294
|
* `d-texture` leaves at their world rects inside a `<view>` carrying the
|
|
247
|
-
* camera transform - a handful of quads however many tiles exist.
|
|
295
|
+
* camera transform - a handful of quads however many tiles exist. A layout
|
|
296
|
+
* component: the world view is laid out, so it cannot sit inside a d-*
|
|
297
|
+
* subtree. Tiles
|
|
248
298
|
* are data, not children: write them through `ref` with `setTile` - there
|
|
249
299
|
* is no `<Tile>` component on purpose (a component per tile would
|
|
250
300
|
* re-introduce the per-element cost the bake removes).
|
|
@@ -259,6 +309,25 @@ export let TileLayer: VoidComponent<TileLayerProps> = props => {
|
|
|
259
309
|
}),
|
|
260
310
|
)
|
|
261
311
|
untrack(() => props.ref)?.(layer)
|
|
312
|
+
createEffect(
|
|
313
|
+
() => props.oversample,
|
|
314
|
+
n => {
|
|
315
|
+
if (n !== undefined) layer.setOversample(n)
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
// Auto oversample: the world view's window box (camera zoom and rotation
|
|
319
|
+
// included - a rotated world's AABB over-estimates, which only rounds up),
|
|
320
|
+
// in device pixels, per world pixel.
|
|
321
|
+
let world: { id: number } | undefined
|
|
322
|
+
let pick = () => {
|
|
323
|
+
if (!world || props.oversample !== undefined) return
|
|
324
|
+
let box = getBoundingBoxViewport(world)
|
|
325
|
+
if (!box) return
|
|
326
|
+
let scale = displayScale() * Math.max(box.width / layer.width, box.height / layer.height)
|
|
327
|
+
layer.setOversample(fitOversample(scale, layer.chunkW, layer.chunkH, windowTexels()))
|
|
328
|
+
}
|
|
329
|
+
onLayout(pick)
|
|
330
|
+
createEffect(() => displayScale(), pick)
|
|
262
331
|
// Chunk allocations arrive through the layer's hook; the signal carries a
|
|
263
332
|
// fresh array so <For> sees the growth.
|
|
264
333
|
let [chunks, setChunks] = createSignal<TileChunk[]>(layer.chunks.slice())
|
|
@@ -270,6 +339,7 @@ export let TileLayer: VoidComponent<TileLayerProps> = props => {
|
|
|
270
339
|
let camY = () => props.camera?.y ?? 0
|
|
271
340
|
return (
|
|
272
341
|
<view
|
|
342
|
+
ref={(n: { id: number }) => (world = n)}
|
|
273
343
|
width={layer.width}
|
|
274
344
|
height={layer.height}
|
|
275
345
|
originX={camX()}
|
package/src/frames.ts
CHANGED
|
@@ -8,6 +8,29 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export type Frame = { u0: number; v0: number; u1: number; v1: number }
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Write a frame's four UVs at `at`, mirrored on the UV side when flipped:
|
|
13
|
+
* `flipX` swaps u0/u1, `flipY` swaps v0/v1. A flip is an involution, so the
|
|
14
|
+
* same call with the stored floats and only the CHANGED axes set toggles a
|
|
15
|
+
* flip in place, and reading back through it recovers the frame. Positional
|
|
16
|
+
* floats so the write allocates nothing.
|
|
17
|
+
*/
|
|
18
|
+
export function writeFrame(
|
|
19
|
+
data: Float32Array,
|
|
20
|
+
at: number,
|
|
21
|
+
u0: number,
|
|
22
|
+
v0: number,
|
|
23
|
+
u1: number,
|
|
24
|
+
v1: number,
|
|
25
|
+
flipX: boolean,
|
|
26
|
+
flipY: boolean,
|
|
27
|
+
): void {
|
|
28
|
+
data[at] = flipX ? u1 : u0
|
|
29
|
+
data[at + 1] = flipY ? v1 : v0
|
|
30
|
+
data[at + 2] = flipX ? u0 : u1
|
|
31
|
+
data[at + 3] = flipY ? v0 : v1
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
export type GridOptions = {
|
|
12
35
|
/** Pixel size of the atlas the pixel-space options below refer to. */
|
|
13
36
|
width: number
|
package/src/layer.ts
CHANGED
|
@@ -38,8 +38,9 @@ import type { BufferId, TextureId } from "@solidrt/core/gpu"
|
|
|
38
38
|
import * as spatial from "flux:spatial"
|
|
39
39
|
import type { NodeId, NodeTransition } from "flux:spatial"
|
|
40
40
|
import { on } from "srt:events"
|
|
41
|
+
import { checkOversample } from "./oversample.ts"
|
|
41
42
|
import type { Frame } from "./frames.ts"
|
|
42
|
-
import { FULL_FRAME } from "./frames.ts"
|
|
43
|
+
import { FULL_FRAME, writeFrame } from "./frames.ts"
|
|
43
44
|
import type { RecordLayer } from "./records.ts"
|
|
44
45
|
import { FRAGMENT, INSTANCE_ATTRIBUTES_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
|
|
45
46
|
|
|
@@ -65,7 +66,7 @@ const BOX = new Float32Array(6)
|
|
|
65
66
|
let declared = new Map<NodeId, Sprite | SpriteGroup>()
|
|
66
67
|
let subscribed = false
|
|
67
68
|
|
|
68
|
-
function
|
|
69
|
+
function declareTransition(node: NodeId, handle: Sprite | SpriteGroup, transition: NodeTransition | string | null): void {
|
|
69
70
|
if (transition === null) {
|
|
70
71
|
declared.delete(node)
|
|
71
72
|
return
|
|
@@ -108,6 +109,9 @@ export type Sprite = {
|
|
|
108
109
|
_w: number
|
|
109
110
|
_h: number
|
|
110
111
|
_rot: number
|
|
112
|
+
/** Mirror flags (both layer kinds): re-applied to every frame write. */
|
|
113
|
+
_flipX: boolean
|
|
114
|
+
_flipY: boolean
|
|
111
115
|
onPointerDown?: (event: SpritePointerEvent) => void
|
|
112
116
|
onPointerMove?: (event: SpritePointerEvent) => void
|
|
113
117
|
onPointerUp?: (event: SpritePointerEvent) => void
|
|
@@ -135,6 +139,11 @@ export type SpriteOptions = {
|
|
|
135
139
|
h?: number
|
|
136
140
|
/** Atlas frame (normalized UVs); default the whole atlas. */
|
|
137
141
|
frame?: Frame
|
|
142
|
+
/** Mirror the frame horizontally / vertically about the sprite's center.
|
|
143
|
+
* A UV-side mirror: w/h stay the drawn size, a scale transition never
|
|
144
|
+
* sees it, picking is unchanged. Default false. */
|
|
145
|
+
flipX?: boolean
|
|
146
|
+
flipY?: boolean
|
|
138
147
|
/** Rotation about the center, radians, clockwise (y-down space). */
|
|
139
148
|
rotation?: number
|
|
140
149
|
/** RGBA multiplier 0..1 each; default opaque white (the texture as-is). */
|
|
@@ -186,6 +195,15 @@ export type SpriteLayerOptions = {
|
|
|
186
195
|
capacity?: number
|
|
187
196
|
clearColor?: [number, number, number, number]
|
|
188
197
|
label?: string
|
|
198
|
+
/**
|
|
199
|
+
* Target texels per layer pixel (positive integer, default 1). The layer
|
|
200
|
+
* renders at `oversample` times its size and is composited down, so a
|
|
201
|
+
* fractional or HiDPI display scale resamples properly instead of snapping
|
|
202
|
+
* (nearest) or smearing (linear); see setOversample. The components pick
|
|
203
|
+
* it from the leaf's on-screen size - set it here when composing the
|
|
204
|
+
* output yourself.
|
|
205
|
+
*/
|
|
206
|
+
oversample?: number
|
|
189
207
|
/** Skip the owner-scoped auto-dispose (see createSpriteLayer). */
|
|
190
208
|
autoFree?: boolean
|
|
191
209
|
}
|
|
@@ -233,6 +251,16 @@ export type LayerBase = {
|
|
|
233
251
|
/** Live sprite count. */
|
|
234
252
|
readonly count: number
|
|
235
253
|
setSize(width: number, height: number): void
|
|
254
|
+
/** Target texels per layer pixel; see setOversample. */
|
|
255
|
+
readonly oversample: number
|
|
256
|
+
/**
|
|
257
|
+
* Re-render at `n` target texels per layer pixel (positive integer): the
|
|
258
|
+
* target resizes in place at its stable id, layer pixels, records, camera
|
|
259
|
+
* and picking are untouched. Pick `n` as the ceiling of the device pixels
|
|
260
|
+
* one layer pixel covers on screen (display scale times any designSize
|
|
261
|
+
* fit or layout scaling), which the components do in onLayout.
|
|
262
|
+
*/
|
|
263
|
+
setOversample(n: number): void
|
|
236
264
|
setCamera(update: CameraUpdate): void
|
|
237
265
|
/** Topmost sprite whose rotated rect contains the layer-pixel point. */
|
|
238
266
|
pick(x: number, y: number): Sprite | null
|
|
@@ -348,6 +376,15 @@ export function spriteDispatch(state: {
|
|
|
348
376
|
}
|
|
349
377
|
}
|
|
350
378
|
|
|
379
|
+
/** The stored UVs at `at` un-mirrored by the sprite's flags: the frame as
|
|
380
|
+
* the caller gave it. Internal - records.ts reads through it too. */
|
|
381
|
+
export function readFrame(data: Float32Array, at: number, sprite: Sprite): Frame {
|
|
382
|
+
let u0 = data[at]!, v0 = data[at + 1]!, u1 = data[at + 2]!, v1 = data[at + 3]!
|
|
383
|
+
return sprite._flipX || sprite._flipY
|
|
384
|
+
? { u0: sprite._flipX ? u1 : u0, v0: sprite._flipY ? v1 : v0, u1: sprite._flipX ? u0 : u1, v1: sprite._flipY ? v0 : v1 }
|
|
385
|
+
: { u0, v0, u1, v1 }
|
|
386
|
+
}
|
|
387
|
+
|
|
351
388
|
/** Fill the shared transform scratch: xy translation, z rotation, xy scale
|
|
352
389
|
* (a sprite's scale is its w/h - every sprite is a scaled unit quad). */
|
|
353
390
|
function fillTransform(x: number, y: number, rot: number, sx: number, sy: number): void {
|
|
@@ -389,6 +426,8 @@ export function createSpriteLayer(
|
|
|
389
426
|
throw new Error(`createSpriteLayer: capacity must be a positive integer, got ${capacity}`)
|
|
390
427
|
}
|
|
391
428
|
let label = opts?.label ?? "sprites"
|
|
429
|
+
let oversample = opts?.oversample ?? 1
|
|
430
|
+
checkOversample("createSpriteLayer", oversample, width, height)
|
|
392
431
|
// One unit quad (triangle strip), reused by every instance.
|
|
393
432
|
let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
|
|
394
433
|
label: `${label}-quad`,
|
|
@@ -399,8 +438,8 @@ export function createSpriteLayer(
|
|
|
399
438
|
let texture = createPipelineTexture(
|
|
400
439
|
VERTEX_SPLIT,
|
|
401
440
|
FRAGMENT,
|
|
402
|
-
width,
|
|
403
|
-
height,
|
|
441
|
+
width * oversample,
|
|
442
|
+
height * oversample,
|
|
404
443
|
{ uViewport: [width, height], uCamera: [0, 0, 1, 1] },
|
|
405
444
|
{
|
|
406
445
|
label,
|
|
@@ -475,21 +514,28 @@ export function createSpriteLayer(
|
|
|
475
514
|
styleDirty = true
|
|
476
515
|
}
|
|
477
516
|
|
|
478
|
-
let writeStyle = (
|
|
479
|
-
let at =
|
|
517
|
+
let writeStyle = (sprite: Sprite, opts: SpriteOptions) => {
|
|
518
|
+
let at = sprite._slot * STYLE_FLOATS
|
|
519
|
+
let flipX = opts.flipX !== undefined && opts.flipX !== sprite._flipX
|
|
520
|
+
let flipY = opts.flipY !== undefined && opts.flipY !== sprite._flipY
|
|
521
|
+
if (flipX) sprite._flipX = !sprite._flipX
|
|
522
|
+
if (flipY) sprite._flipY = !sprite._flipY
|
|
480
523
|
if (opts.frame !== undefined) {
|
|
481
|
-
|
|
482
|
-
styleData
|
|
483
|
-
|
|
484
|
-
|
|
524
|
+
let f = opts.frame
|
|
525
|
+
writeFrame(styleData, at, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
|
|
526
|
+
styleDirty = true
|
|
527
|
+
} else if (flipX || flipY) {
|
|
528
|
+
// No new frame: toggle the changed axes on the stored UVs.
|
|
529
|
+
writeFrame(styleData, at, styleData[at]!, styleData[at + 1]!, styleData[at + 2]!, styleData[at + 3]!, flipX, flipY)
|
|
530
|
+
styleDirty = true
|
|
485
531
|
}
|
|
486
532
|
if (opts.tint !== undefined) {
|
|
487
533
|
styleData[at + 4] = opts.tint[0]
|
|
488
534
|
styleData[at + 5] = opts.tint[1]
|
|
489
535
|
styleData[at + 6] = opts.tint[2]
|
|
490
536
|
styleData[at + 7] = opts.tint[3]
|
|
537
|
+
styleDirty = true
|
|
491
538
|
}
|
|
492
|
-
styleDirty = true
|
|
493
539
|
}
|
|
494
540
|
|
|
495
541
|
let dispatch = spriteDispatch({
|
|
@@ -506,11 +552,21 @@ export function createSpriteLayer(
|
|
|
506
552
|
},
|
|
507
553
|
setSize(w, h) {
|
|
508
554
|
if (disposed || (w === width && h === height)) return
|
|
555
|
+
checkOversample("setSize", oversample, w, h)
|
|
509
556
|
width = w
|
|
510
557
|
height = h
|
|
511
|
-
setTargetSize(texture, w, h)
|
|
558
|
+
setTargetSize(texture, w * oversample, h * oversample)
|
|
512
559
|
setTargetParams(texture, { uViewport: [w, h] })
|
|
513
560
|
},
|
|
561
|
+
get oversample() {
|
|
562
|
+
return oversample
|
|
563
|
+
},
|
|
564
|
+
setOversample(n) {
|
|
565
|
+
if (disposed || n === oversample) return
|
|
566
|
+
checkOversample("setOversample", n, width, height)
|
|
567
|
+
oversample = n
|
|
568
|
+
setTargetSize(texture, width * n, height * n)
|
|
569
|
+
},
|
|
514
570
|
setCamera(update) {
|
|
515
571
|
if (disposed) return
|
|
516
572
|
if (update.x !== undefined) camX = update.x
|
|
@@ -591,6 +647,8 @@ export function createSpriteLayer(
|
|
|
591
647
|
_w: opts?.w ?? 0,
|
|
592
648
|
_h: opts?.h ?? 0,
|
|
593
649
|
_rot: opts?.rotation ?? 0,
|
|
650
|
+
_flipX: false,
|
|
651
|
+
_flipY: false,
|
|
594
652
|
}
|
|
595
653
|
fillTransform(sprite._x, sprite._y, sprite._rot, sprite._w, sprite._h)
|
|
596
654
|
let node = spatial.createNode(TRANSFORM, true)
|
|
@@ -602,7 +660,7 @@ export function createSpriteLayer(
|
|
|
602
660
|
spatial.setBounds(node, FLAT_BOUNDS)
|
|
603
661
|
spatial.bindPoseRecord(node, pose, slot)
|
|
604
662
|
byNode.set(node, sprite)
|
|
605
|
-
writeStyle(
|
|
663
|
+
writeStyle(sprite, { frame: FULL_FRAME, tint: [1, 1, 1, 1], ...opts })
|
|
606
664
|
layer._schedule()
|
|
607
665
|
return sprite
|
|
608
666
|
},
|
|
@@ -614,7 +672,9 @@ export function createSpriteLayer(
|
|
|
614
672
|
if (opts.h !== undefined && opts.h !== sprite._h) (sprite._h = opts.h), (moved = true)
|
|
615
673
|
if (opts.rotation !== undefined && opts.rotation !== sprite._rot) (sprite._rot = opts.rotation), (moved = true)
|
|
616
674
|
if (moved) writeTransform(sprite)
|
|
617
|
-
if (opts.frame !== undefined || opts.tint !== undefined
|
|
675
|
+
if (opts.frame !== undefined || opts.tint !== undefined || opts.flipX !== undefined || opts.flipY !== undefined) {
|
|
676
|
+
writeStyle(sprite, opts)
|
|
677
|
+
}
|
|
618
678
|
if (moved || styleDirty) layer._schedule()
|
|
619
679
|
},
|
|
620
680
|
_read(sprite) {
|
|
@@ -624,7 +684,9 @@ export function createSpriteLayer(
|
|
|
624
684
|
y: sprite._y,
|
|
625
685
|
w: sprite._w,
|
|
626
686
|
h: sprite._h,
|
|
627
|
-
frame:
|
|
687
|
+
frame: readFrame(styleData, at, sprite),
|
|
688
|
+
flipX: sprite._flipX,
|
|
689
|
+
flipY: sprite._flipY,
|
|
628
690
|
rotation: sprite._rot,
|
|
629
691
|
tint: [styleData[at + 4]!, styleData[at + 5]!, styleData[at + 6]!, styleData[at + 7]!],
|
|
630
692
|
}
|
|
@@ -715,7 +777,7 @@ export function setSpriteTransition(sprite: Sprite, transition: NodeTransition |
|
|
|
715
777
|
if (sprite.layer === null) return
|
|
716
778
|
if (sprite.node === null) throw new Error("setSpriteTransition: record sprites have no node transitions")
|
|
717
779
|
spatial.setTransition(sprite.node, transition)
|
|
718
|
-
|
|
780
|
+
declareTransition(sprite.node, sprite, transition)
|
|
719
781
|
}
|
|
720
782
|
|
|
721
783
|
/** The group counterpart of setSpriteTransition (`scale` is the group's
|
|
@@ -723,7 +785,7 @@ export function setSpriteTransition(sprite: Sprite, transition: NodeTransition |
|
|
|
723
785
|
export function setGroupTransition(group: SpriteGroup, transition: NodeTransition | string | null): void {
|
|
724
786
|
if (group.layer === null) return
|
|
725
787
|
spatial.setTransition(group.node, transition)
|
|
726
|
-
|
|
788
|
+
declareTransition(group.node, group, transition)
|
|
727
789
|
}
|
|
728
790
|
|
|
729
791
|
/** Add a transform group (see SpriteGroup). */
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The layer oversample: how many target texels one layer pixel is drawn
|
|
2
|
+
// as. A layer renders at `oversample` times its size and is composited down
|
|
3
|
+
// to its box, so a fractional or HiDPI display scale resamples properly: the
|
|
4
|
+
// atlas is still sampled nearest inside (texels stay square blocks), the
|
|
5
|
+
// composite samples linear (each block edge softens over one device pixel
|
|
6
|
+
// instead of snapping to uneven widths). okf/backlog/2d-layer-display-scale.md.
|
|
7
|
+
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
|
|
12
|
+
|
|
13
|
+
/** Validate an oversample against the target it scales; throws (dev policy). */
|
|
14
|
+
export function checkOversample(verb: string, n: number, width: number, height: number): void {
|
|
15
|
+
if (!(Number.isInteger(n) && n >= 1)) {
|
|
16
|
+
throw new Error(`${verb}: oversample must be a positive integer, got ${n}`)
|
|
17
|
+
}
|
|
18
|
+
let max = limits.maxTextureSize
|
|
19
|
+
if (width * n > max || height * n > max) {
|
|
20
|
+
throw new Error(`${verb}: ${width} x ${height} at oversample ${n} exceeds maxTextureSize ${max}`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The oversample for a layer shown at `scale` device pixels per layer pixel:
|
|
26
|
+
* the ceiling of the scale, so no device pixel goes without a target texel.
|
|
27
|
+
* Bounded by `budget` texels for the `targetW x targetH` target (the
|
|
28
|
+
* window's own device pixel count: a target beyond it buys nothing on
|
|
29
|
+
* screen, and a layer stretched far past the window inside a scroller must
|
|
30
|
+
* not ask for one) and by what the target can grow to on this device.
|
|
31
|
+
*/
|
|
32
|
+
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))
|
|
40
|
+
}
|
package/src/records.ts
CHANGED
|
@@ -30,10 +30,11 @@ import {
|
|
|
30
30
|
setTargetSize,
|
|
31
31
|
} from "@solidrt/core/gpu"
|
|
32
32
|
import type { BufferId, TextureId } from "@solidrt/core/gpu"
|
|
33
|
-
import { FULL_FRAME } from "./frames.ts"
|
|
34
|
-
import { spriteDispatch } from "./layer.ts"
|
|
33
|
+
import { FULL_FRAME, writeFrame } from "./frames.ts"
|
|
34
|
+
import { readFrame, spriteDispatch } from "./layer.ts"
|
|
35
35
|
import type { LayerBase, Sprite, SpriteHandlers, SpriteLayerOptions, SpriteOptions } from "./layer.ts"
|
|
36
36
|
import { pointInSprite } from "./pick.ts"
|
|
37
|
+
import { checkOversample } from "./oversample.ts"
|
|
37
38
|
import { FRAGMENT, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
|
|
38
39
|
|
|
39
40
|
// Floats per instance record:
|
|
@@ -84,11 +85,13 @@ export function createRecordLayer(
|
|
|
84
85
|
label: `${label}-records`,
|
|
85
86
|
autoFree: false,
|
|
86
87
|
})
|
|
88
|
+
let oversample = opts?.oversample ?? 1
|
|
89
|
+
checkOversample("createRecordLayer", oversample, width, height)
|
|
87
90
|
let texture = createPipelineTexture(
|
|
88
91
|
VERTEX,
|
|
89
92
|
FRAGMENT,
|
|
90
|
-
width,
|
|
91
|
-
height,
|
|
93
|
+
width * oversample,
|
|
94
|
+
height * oversample,
|
|
92
95
|
{ uViewport: [width, height], uCamera: [0, 0, 1, 1] },
|
|
93
96
|
{
|
|
94
97
|
label,
|
|
@@ -154,11 +157,16 @@ export function createRecordLayer(
|
|
|
154
157
|
if (opts.y !== undefined) r[at + 1] = opts.y
|
|
155
158
|
if (opts.w !== undefined) r[at + 2] = opts.w
|
|
156
159
|
if (opts.h !== undefined) r[at + 3] = opts.h
|
|
160
|
+
let flipX = opts.flipX !== undefined && opts.flipX !== sprite._flipX
|
|
161
|
+
let flipY = opts.flipY !== undefined && opts.flipY !== sprite._flipY
|
|
162
|
+
if (flipX) sprite._flipX = !sprite._flipX
|
|
163
|
+
if (flipY) sprite._flipY = !sprite._flipY
|
|
157
164
|
if (opts.frame !== undefined) {
|
|
158
|
-
|
|
159
|
-
r
|
|
160
|
-
|
|
161
|
-
|
|
165
|
+
let f = opts.frame
|
|
166
|
+
writeFrame(r, at + 4, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
|
|
167
|
+
} else if (flipX || flipY) {
|
|
168
|
+
// No new frame: toggle the changed axes on the stored UVs.
|
|
169
|
+
writeFrame(r, at + 4, r[at + 4]!, r[at + 5]!, r[at + 6]!, r[at + 7]!, flipX, flipY)
|
|
162
170
|
}
|
|
163
171
|
if (opts.rotation !== undefined) r[at + 8] = opts.rotation
|
|
164
172
|
if (opts.tint !== undefined) {
|
|
@@ -183,11 +191,21 @@ export function createRecordLayer(
|
|
|
183
191
|
},
|
|
184
192
|
setSize(w, h) {
|
|
185
193
|
if (disposed || (w === width && h === height)) return
|
|
194
|
+
checkOversample("setSize", oversample, w, h)
|
|
186
195
|
width = w
|
|
187
196
|
height = h
|
|
188
|
-
setTargetSize(texture, w, h)
|
|
197
|
+
setTargetSize(texture, w * oversample, h * oversample)
|
|
189
198
|
setTargetParams(texture, { uViewport: [w, h] })
|
|
190
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
|
+
},
|
|
191
209
|
setCamera(update) {
|
|
192
210
|
if (disposed) return
|
|
193
211
|
if (update.x !== undefined) camX = update.x
|
|
@@ -231,7 +249,7 @@ export function createRecordLayer(
|
|
|
231
249
|
next.set(layer.records)
|
|
232
250
|
layer.records = next
|
|
233
251
|
}
|
|
234
|
-
let sprite: Sprite = { layer, node: null, _slot: index, _x: 0, _y: 0, _w: 0, _h: 0, _rot: 0 }
|
|
252
|
+
let sprite: Sprite = { layer, node: null, _slot: index, _x: 0, _y: 0, _w: 0, _h: 0, _rot: 0, _flipX: false, _flipY: false }
|
|
235
253
|
layer._order.push(sprite)
|
|
236
254
|
writeRecord(sprite, {
|
|
237
255
|
x: 0,
|
|
@@ -258,7 +276,9 @@ export function createRecordLayer(
|
|
|
258
276
|
y: r[at + 1]!,
|
|
259
277
|
w: r[at + 2]!,
|
|
260
278
|
h: r[at + 3]!,
|
|
261
|
-
frame:
|
|
279
|
+
frame: readFrame(r, at + 4, sprite),
|
|
280
|
+
flipX: sprite._flipX,
|
|
281
|
+
flipY: sprite._flipY,
|
|
262
282
|
rotation: r[at + 8]!,
|
|
263
283
|
tint: [r[at + 9]!, r[at + 10]!, r[at + 11]!, r[at + 12]!],
|
|
264
284
|
}
|
package/src/tiles.ts
CHANGED
|
@@ -30,9 +30,11 @@ import {
|
|
|
30
30
|
endBufferWrite,
|
|
31
31
|
limits,
|
|
32
32
|
renderTarget,
|
|
33
|
+
setTargetSize,
|
|
33
34
|
} from "@solidrt/core/gpu"
|
|
34
35
|
import type { BufferId, FilterMode, TextureId } from "@solidrt/core/gpu"
|
|
35
36
|
import type { Frame } from "./frames.ts"
|
|
37
|
+
import { checkOversample } from "./oversample.ts"
|
|
36
38
|
import { FLOATS_PER_SPRITE } from "./records.ts"
|
|
37
39
|
import { FRAGMENT, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
|
|
38
40
|
|
|
@@ -45,11 +47,19 @@ export type TileLayerOptions = {
|
|
|
45
47
|
/** Per-chunk clear color; empty (never-written) chunks render nothing. */
|
|
46
48
|
clearColor?: [number, number, number, number]
|
|
47
49
|
/**
|
|
48
|
-
* Sampler filter for the baked chunk textures
|
|
49
|
-
*
|
|
50
|
-
*
|
|
50
|
+
* Sampler filter for the baked chunk textures at composite time; default
|
|
51
|
+
* "linear", which with `oversample` is the proper resample at any scale.
|
|
52
|
+
* Hard pixels belong to the ATLAS sampler (createAtlas `filter:
|
|
53
|
+
* "nearest"`), not here: "nearest" on the chunks snaps texels to uneven
|
|
54
|
+
* widths at a fractional scale.
|
|
51
55
|
*/
|
|
52
56
|
filter?: FilterMode
|
|
57
|
+
/**
|
|
58
|
+
* Target texels per world pixel in the baked chunks (positive integer,
|
|
59
|
+
* default 1); see TileLayer.setOversample. `<TileLayer>` picks it from
|
|
60
|
+
* the world view's on-screen size.
|
|
61
|
+
*/
|
|
62
|
+
oversample?: number
|
|
53
63
|
/**
|
|
54
64
|
* Chunk edge in TILES; default sized so a chunk is ~512px. The tuning
|
|
55
65
|
* knob between re-bake granularity (smaller = finer dirty regions) and
|
|
@@ -80,6 +90,20 @@ export type TileLayer = {
|
|
|
80
90
|
/** World size in pixels: cols * tileW x rows * tileH. */
|
|
81
91
|
width: number
|
|
82
92
|
height: number
|
|
93
|
+
/** Chunk size in world pixels (chunkTiles * tile size): the target a
|
|
94
|
+
* chunk bakes into, before `oversample`. */
|
|
95
|
+
chunkW: number
|
|
96
|
+
chunkH: number
|
|
97
|
+
/** Target texels per world pixel; see setOversample. */
|
|
98
|
+
readonly oversample: number
|
|
99
|
+
/**
|
|
100
|
+
* Re-bake at `n` target texels per world pixel (positive integer): every
|
|
101
|
+
* resident chunk resizes in place and re-bakes once; world pixels, records
|
|
102
|
+
* and the camera are untouched. Pick `n` as the ceiling of the device
|
|
103
|
+
* pixels one world pixel covers on screen (display scale times camera
|
|
104
|
+
* zoom times any designSize fit), which `<TileLayer>` does in onLayout.
|
|
105
|
+
*/
|
|
106
|
+
setOversample(n: number): void
|
|
83
107
|
/**
|
|
84
108
|
* The resident chunks, in allocation order - the layer's output.
|
|
85
109
|
* Composite each as a texture leaf at its world rect (`<TileLayer>` does
|
|
@@ -140,6 +164,8 @@ export function createTileLayer(
|
|
|
140
164
|
)
|
|
141
165
|
}
|
|
142
166
|
let label = opts?.label ?? "tiles"
|
|
167
|
+
let oversample = opts?.oversample ?? 1
|
|
168
|
+
checkOversample("createTileLayer", oversample, chunkW, chunkH)
|
|
143
169
|
let chunkCols = Math.ceil(cols / chunkTiles)
|
|
144
170
|
let perChunk = chunkTiles * chunkTiles
|
|
145
171
|
// One unit quad (triangle strip), shared by every chunk's pipeline.
|
|
@@ -185,8 +211,8 @@ export function createTileLayer(
|
|
|
185
211
|
let texture = createPipelineTexture(
|
|
186
212
|
VERTEX,
|
|
187
213
|
FRAGMENT,
|
|
188
|
-
chunkW,
|
|
189
|
-
chunkH,
|
|
214
|
+
chunkW * oversample,
|
|
215
|
+
chunkH * oversample,
|
|
190
216
|
{ uViewport: [chunkW, chunkH], uCamera: [x, y, 1, 1] },
|
|
191
217
|
{
|
|
192
218
|
label: `${label}-chunk`,
|
|
@@ -228,7 +254,23 @@ export function createTileLayer(
|
|
|
228
254
|
tileH,
|
|
229
255
|
width: cols * tileW,
|
|
230
256
|
height: rows * tileH,
|
|
257
|
+
chunkW,
|
|
258
|
+
chunkH,
|
|
231
259
|
chunks: [],
|
|
260
|
+
get oversample() {
|
|
261
|
+
return oversample
|
|
262
|
+
},
|
|
263
|
+
setOversample(n) {
|
|
264
|
+
if (disposed || n === oversample) return
|
|
265
|
+
checkOversample("setOversample", n, chunkW, chunkH)
|
|
266
|
+
oversample = n
|
|
267
|
+
// Resizing a target re-renders it, but a manual target's content is
|
|
268
|
+
// its last bake: mark every chunk so the flush bakes it at the new size.
|
|
269
|
+
for (let chunk of resident.values()) {
|
|
270
|
+
setTargetSize(chunk.texture, chunkW * n, chunkH * n)
|
|
271
|
+
touch(chunk)
|
|
272
|
+
}
|
|
273
|
+
},
|
|
232
274
|
setTile(col, row, frame) {
|
|
233
275
|
if (disposed) return
|
|
234
276
|
let [index, at] = locate(col, row, "setTile")
|