@solidrt/2d 0.0.67 → 0.0.69

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/src/shaders.ts CHANGED
@@ -1,46 +1,60 @@
1
- // The sprite pipelines: a unit quad instanced over one atlas, mapped
2
- // world -> clip through uCamera (offset + zoom), uCameraRot ([cos, sin,
3
- // pivotX, pivotY]: the camera rotation about the pivot, identity
1
+ // The sprite pipelines: a unit quad instanced over a layer's atlases,
2
+ // mapped world -> clip through uCamera (offset + zoom), uCameraRot ([cos,
3
+ // sin, pivotX, pivotY]: the camera rotation about the pivot, identity
4
4
  // [1, 0, 0, 0]) and uViewport - the projectCamera mapping (camera.ts)
5
5
  // spelled in GLSL; the two must agree. World and clip space are both
6
6
  // y-down (core gpu.ts pixel contract), so the mapping carries NO flip
7
7
  // anywhere - do not add one. Two record layouts share the fragment stage:
8
- // - VERTEX + INSTANCE_ATTRIBUTES: the 13-float interleaved record
9
- // [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA],
10
- // used by the records layer (records.ts) and the tile layer (tiles.ts).
11
- // - VERTEX_SPLIT + INSTANCE_ATTRIBUTES_SPLIT: the node-backed live layer
8
+ // - VERTEX + INSTANCE_ATTRIBUTES: the 20-float interleaved record
9
+ // [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA,
10
+ // minScreenPx, maxScreenPx, atlas, outlineR, outlineG, outlineB,
11
+ // outlineWidth], used by the records layer (records.ts) and the tile
12
+ // layer (tiles.ts).
13
+ // - VERTEX_SPLIT + INSTANCE_LAYOUTS_SPLIT: the node-backed live layer
12
14
  // (layer.ts), pose and style in separate instance-buffer slots - slot 0
13
15
  // is the core-written Pose2D record [x, y, angle, sx, sy], slot 1 the
14
16
  // 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.
17
+ // renderOrder, minScreenPx, maxScreenPx, atlas, outlineR, outlineG,
18
+ // outlineB, outlineWidth]. The shader never reads iRenderOrder - it
19
+ // exists for the core's instanceOrder (orderBy: "renderOrder");
20
+ // declared attributes without an active program attribute are skipped,
21
+ // their bytes pad the stride.
22
+ // `atlas` is the sampler index of the record's texture in the layer's
23
+ // atlas list: the fragment stage is GENERATED per layer with one sampler
24
+ // per declared atlas (fragmentFor), the flat per-instance index picking
25
+ // the sampler - the multi-texture batch of PixiJS and Phaser, where Unity
26
+ // and Godot break the batch on every texture change. Any number of sheets
27
+ // interleave in one draw, key order included. An atlas declared as a
28
+ // distance field (`Atlas.sdf`) is decoded in its branch instead of
29
+ // sampled as colour: the median of rgb is the edge, anti-aliased over one
30
+ // screen pixel from the quad's own texel footprint (the msdfgen reference
31
+ // shader), the true field in alpha grows the per-instance outline.
32
+ // `outline` is rgb plus a width in WORLD pixels; the vertex stage turns
33
+ // the width into screen pixels through the camera zoom and the screen-size
34
+ // clamp, so an outline scales with its glyph. A colour atlas ignores it.
35
+ // Both vertex stages apply the screen-size clamp (SpriteOptions.
36
+ // minScreenPx/maxScreenPx) through core's screenSizeScale: the quad
37
+ // scales uniformly so its smaller axis stays within the bounds at the
38
+ // camera's zoom (the pixels per world unit), and picking applies the JS
39
+ // twin to the rect it tests.
18
40
  // The rotations here (clockwise, y-down) and their JS partners must
19
41
  // agree: iRot with pointInSprite in pick.ts, uCameraRot with
20
42
  // projectCamera in camera.ts. The differential checks guard the JS side
21
43
  // against oracles but NOT against these shaders - if you touch one
22
44
  // rotation, touch all.
23
- import { compileShader, createBuffer, createRenderPipeline, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyShader, glsl, linkProgram } from "@solidrt/core/gpu"
24
- import type { BufferId, RenderPipelineId, VertexAttribute } from "@solidrt/core/gpu"
45
+ import { compileShader, createBuffer, createRenderPipeline, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyShader, glsl, linkProgram, SCREEN_SIZE_GLSL } from "@solidrt/core/gpu"
46
+ import type { BlendMode, BufferId, RenderPipelineId, TextureBindings, TextureId, VertexAttribute } from "@solidrt/core/gpu"
25
47
 
26
- export let VERTEX = glsl`
27
- in vec2 aPos;
28
- in vec2 iCenter;
29
- in vec2 iSize;
30
- in vec4 iUv;
31
- in float iRot;
32
- in vec4 iTint;
33
- out vec2 vUv;
34
- flat out vec4 vFrame;
35
- out vec4 vTint;
36
- uniform vec2 uViewport;
37
- uniform vec4 uCamera;
38
- uniform vec4 uCameraRot;
48
+ /** What the fragment stage reads of a declared atlas: whether it is a
49
+ * distance field, and its range (see `Atlas.sdf`). */
50
+ export type AtlasShading = { sdf?: { range: number } }
39
51
 
40
- void main() {
41
- vec2 corner = aPos * iSize;
42
- float c = cos(iRot), s = sin(iRot);
43
- vec2 world = iCenter + vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
52
+ // The world -> clip mapping and the varyings both vertex stages share;
53
+ // `corner` is the quad corner after the screen-size clamp, `center` the
54
+ // sprite's world position.
55
+ const VERTEX_BODY = glsl`
56
+ float c = cos(rot), s = sin(rot);
57
+ vec2 world = center + vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
44
58
  vec2 view = (world - uCamera.xy) * uCamera.zw;
45
59
  vec2 screen = uCameraRot.zw + vec2(view.x * uCameraRot.x - view.y * uCameraRot.y, view.x * uCameraRot.y + view.y * uCameraRot.x);
46
60
  // World and clip are both y-down, so the mapping carries no flip.
@@ -48,47 +62,182 @@ export let VERTEX = glsl`
48
62
  vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
49
63
  vFrame = iUv;
50
64
  vTint = iTint;
65
+ vAtlas = int(iAtlas);
66
+ vOutlineColor = iOutline.rgb;
67
+ // World pixels to screen pixels: the camera zoom, and the clamp the
68
+ // quad itself scaled by.
69
+ vOutlinePx = iOutline.a * uCamera.z * clampScale;
70
+ `
71
+
72
+ const VARYINGS = glsl`
73
+ out vec2 vUv;
74
+ flat out vec4 vFrame;
75
+ out vec4 vTint;
76
+ flat out int vAtlas;
77
+ flat out vec3 vOutlineColor;
78
+ flat out float vOutlinePx;
79
+ uniform vec2 uViewport;
80
+ uniform vec4 uCamera;
81
+ uniform vec4 uCameraRot;
82
+ `
83
+
84
+ export let VERTEX = glsl`
85
+ in vec2 aPos;
86
+ in vec2 iCenter;
87
+ in vec2 iSize;
88
+ in vec4 iUv;
89
+ in float iRot;
90
+ in vec4 iTint;
91
+ in vec2 iScreenPx;
92
+ in float iAtlas;
93
+ in vec4 iOutline;
94
+ ${VARYINGS}
95
+ ${SCREEN_SIZE_GLSL}
96
+ void main() {
97
+ float clampScale = screenSizeScale(iSize, uCamera.z, iScreenPx);
98
+ vec2 corner = aPos * iSize * clampScale;
99
+ vec2 center = iCenter;
100
+ float rot = iRot;
101
+ ${VERTEX_BODY}
51
102
  }
52
103
  `
53
104
 
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.
69
- export let FRAGMENT = glsl`
105
+ /** The sampler uniform of atlas `i` in a layer's pipeline. */
106
+ export function atlasUniform(i: number): string {
107
+ return `uAtlas${i}`
108
+ }
109
+
110
+ /** The texture bindings of a layer's atlases, by sampler uniform: what
111
+ * every target drawing with the layer's pipeline binds. */
112
+ export function atlasBindings(atlases: readonly TextureId[]): TextureBindings {
113
+ let out: TextureBindings = {}
114
+ atlases.forEach((texture, i) => (out[atlasUniform(i)] = texture))
115
+ return out
116
+ }
117
+
118
+ /**
119
+ * The fragment stage of a layer over its atlases: one sampler per atlas,
120
+ * the record's atlas index (flat, so one per instance) picking it, and a
121
+ * distance-field decode in the branch of every atlas declared `sdf`.
122
+ *
123
+ * uTint is the LAYER tint, multiplied over the per-instance vTint
124
+ * (identity [1, 1, 1, 1] - GLSL uniforms default to ZERO, so every target
125
+ * creation must pin it explicitly or everything renders transparent
126
+ * black).
127
+ *
128
+ * The sample is clamped to its frame, pulled in by half a texel of ITS
129
+ * atlas: a quad at a fractional position lands edge samples so close to
130
+ * the frame's edge that a linear tap (and float interpolation under
131
+ * nearest) reaches the cell next door, painting a one-texel line of the
132
+ * neighbour along the sprite's edge. The clamp keeps the quad's texel
133
+ * mapping 1:1 and only moves those edge samples onto the edge texel, so
134
+ * touching cells do not bleed and no gutter is needed at mip level 0. It
135
+ * cannot help a mip chain, whose texels straddle the cell edge before
136
+ * sampling - that is extrude.ts. min/max because a flipped frame stores
137
+ * its UVs swapped, and the inner min/max because a sub-texel frame would
138
+ * otherwise hand clamp() a lower bound above its upper one.
139
+ *
140
+ * The derivatives are taken from the UNCLAMPED uv before any branch and
141
+ * the tap is textureGrad: the mip footprint is the quad's own (a clamped
142
+ * uv has a zero derivative along the clamped axis, which would pick a
143
+ * sharper level along every edge), and the lookup is well defined inside
144
+ * the per-instance branch (an implicit-derivative texture() in
145
+ * non-uniform control flow is not, which the PixiJS-style batch lives
146
+ * with). A single-atlas layer skips the branch and keeps the same tap.
147
+ *
148
+ * A colour atlas yields the texel times the instance tint (premultiplied
149
+ * in, premultiplied out). A distance-field atlas yields the instance
150
+ * tint as the fill, anti-aliased over one screen pixel: the field's range
151
+ * mapped through the quad's texel footprint gives the screen pixels per
152
+ * field unit (floored at 1, so a glyph far below its texel size fades
153
+ * instead of vanishing), the median of rgb is the edge, and when the
154
+ * instance carries an outline the true field in alpha, pushed out by the
155
+ * outline's screen pixels, paints the outline colour under the fill.
156
+ */
157
+ export function fragmentFor(atlases: readonly AtlasShading[]): string {
158
+ if (!Array.isArray(atlases) || atlases.length === 0) throw new Error(`fragmentFor: atlases must be a non-empty list, got ${JSON.stringify(atlases)}`)
159
+ let samplers = ""
160
+ atlases.forEach((_, i) => (samplers += `uniform sampler2D ${atlasUniform(i)};\n `))
161
+ let shade = (i: number) => {
162
+ let sdf = atlases[i]!.sdf
163
+ if (sdf === undefined) return `tap(${atlasUniform(i)}, dx, dy) * vTint`
164
+ if (!(typeof sdf.range === "number" && sdf.range > 0 && Number.isFinite(sdf.range))) {
165
+ throw new Error(`fragmentFor: atlases[${i}].sdf.range must be a positive number, got ${sdf.range}`)
166
+ }
167
+ return `field(${atlasUniform(i)}, ${sdf.range.toFixed(2)}, dx, dy)`
168
+ }
169
+ let cases = ""
170
+ atlases.forEach((_, i) => (cases += `case ${i}: color = ${shade(i)}; break;\n `))
171
+ let pick =
172
+ atlases.length === 1
173
+ ? `vec4 color = ${shade(0)};`
174
+ : `vec4 color;
175
+ switch (vAtlas) {
176
+ ${cases}default: color = ${shade(0)}; break;
177
+ }`
178
+ return glsl`
70
179
  in vec2 vUv;
71
180
  flat in vec4 vFrame;
72
181
  in vec4 vTint;
73
- uniform sampler2D uAtlas;
74
- uniform vec4 uTint;
182
+ flat in int vAtlas;
183
+ flat in vec3 vOutlineColor;
184
+ flat in float vOutlinePx;
185
+ ${samplers}uniform vec4 uTint;
75
186
 
76
- void main() {
77
- vec2 halfTexel = 0.5 / vec2(textureSize(uAtlas, 0));
187
+ vec4 tap(sampler2D atlas, vec2 dx, vec2 dy) {
188
+ vec2 halfTexel = 0.5 / vec2(textureSize(atlas, 0));
78
189
  vec2 lo = min(vFrame.xy, vFrame.zw) + halfTexel;
79
190
  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;
191
+ return textureGrad(atlas, clamp(vUv, min(lo, hi), max(lo, hi)), dx, dy);
192
+ }
193
+
194
+ float median3(vec3 v) {
195
+ return max(min(v.r, v.g), min(max(v.r, v.g), v.b));
196
+ }
197
+
198
+ // Screen pixels per field unit (0..1 spans the range): the range in
199
+ // texels over the quad's texel footprint per screen pixel, floored at
200
+ // one so a minified glyph fades rather than aliasing away.
201
+ float screenPxRange(sampler2D atlas, float range, vec2 dx, vec2 dy) {
202
+ vec2 unitRange = vec2(range) / vec2(textureSize(atlas, 0));
203
+ vec2 screenTexSize = vec2(1.0) / (abs(dx) + abs(dy));
204
+ return max(0.5 * dot(unitRange, screenTexSize), 1.0);
205
+ }
206
+
207
+ vec4 field(sampler2D atlas, float range, vec2 dx, vec2 dy) {
208
+ vec4 t = tap(atlas, dx, dy);
209
+ float px = screenPxRange(atlas, range, dx, dy);
210
+ float fill = clamp((median3(t.rgb) - 0.5) * px + 0.5, 0.0, 1.0);
211
+ vec3 rgb = vTint.rgb;
212
+ float coverage = fill;
213
+ if (vOutlinePx > 0.0) {
214
+ float outline = clamp((t.a - 0.5) * px + vOutlinePx + 0.5, 0.0, 1.0);
215
+ coverage = max(fill, outline);
216
+ rgb = mix(vOutlineColor, vTint.rgb, fill);
217
+ }
218
+ float a = coverage * vTint.a;
219
+ return vec4(rgb * a, a);
220
+ }
221
+
222
+ void main() {
223
+ vec2 dx = dFdx(vUv);
224
+ vec2 dy = dFdy(vUv);
225
+ ${pick}
226
+ fragColor = color * uTint;
82
227
  }
83
228
  `
229
+ }
84
230
 
85
- /** The instance attribute list matching the 13-float record layout. */
231
+ /** The instance attribute list matching the 20-float record layout. */
86
232
  export const INSTANCE_ATTRIBUTES: VertexAttribute[] = [
87
233
  { name: "iCenter", format: "float32x2" },
88
234
  { name: "iSize", format: "float32x2" },
89
235
  { name: "iUv", format: "float32x4" },
90
236
  { name: "iRot", format: "float32" },
91
237
  { name: "iTint", format: "float32x4" },
238
+ { name: "iScreenPx", format: "float32x2" },
239
+ { name: "iAtlas", format: "float32" },
240
+ { name: "iOutline", format: "float32x4" },
92
241
  ]
93
242
 
94
243
  export let VERTEX_SPLIT = glsl`
@@ -98,24 +247,17 @@ export let VERTEX_SPLIT = glsl`
98
247
  in vec2 iScale;
99
248
  in vec4 iUv;
100
249
  in vec4 iTint;
101
- out vec2 vUv;
102
- flat out vec4 vFrame;
103
- out vec4 vTint;
104
- uniform vec2 uViewport;
105
- uniform vec4 uCamera;
106
- uniform vec4 uCameraRot;
107
-
250
+ in vec2 iScreenPx;
251
+ in float iAtlas;
252
+ in vec4 iOutline;
253
+ ${VARYINGS}
254
+ ${SCREEN_SIZE_GLSL}
108
255
  void main() {
109
- vec2 corner = aPos * iScale;
110
- float c = cos(iRot), s = sin(iRot);
111
- vec2 world = iPos + vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
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);
114
- // World and clip are both y-down, so the mapping carries no flip.
115
- gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
116
- vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
117
- vFrame = iUv;
118
- vTint = iTint;
256
+ float clampScale = screenSizeScale(iScale, uCamera.z, iScreenPx);
257
+ vec2 corner = aPos * iScale * clampScale;
258
+ vec2 center = iPos;
259
+ float rot = iRot;
260
+ ${VERTEX_BODY}
119
261
  }
120
262
  `
121
263
 
@@ -132,6 +274,9 @@ export const INSTANCE_LAYOUTS_SPLIT: VertexAttribute[][] = [
132
274
  { name: "iUv", format: "float32x4" },
133
275
  { name: "iTint", format: "float32x4" },
134
276
  { name: "iRenderOrder", format: "float32" },
277
+ { name: "iScreenPx", format: "float32x2" },
278
+ { name: "iAtlas", format: "float32" },
279
+ { name: "iOutline", format: "float32x4" },
135
280
  ],
136
281
  ]
137
282
 
@@ -141,20 +286,22 @@ export type SpritePipeline = { quad: BufferId; pipeline: RenderPipelineId; dispo
141
286
 
142
287
  /**
143
288
  * Compile one sprite pipeline: `vertex` with its matching attribute list
144
- * (VERTEX + INSTANCE_ATTRIBUTES, or the split pair) over the shared
145
- * fragment stage, alpha-blended triangle strips. Spelled out (not the
146
- * fused createPipelineTexture) so a layer's targets - its own and every
147
- * view - add entries over the one pipeline. The program lives as long as
148
- * the pipeline; `dispose` frees both and the quad.
289
+ * (VERTEX + INSTANCE_ATTRIBUTES, or the split pair) over the fragment
290
+ * stage generated for `atlases` (count and distance-field decodes),
291
+ * triangle strips blended with `blend` (the layer's `blend` option,
292
+ * "alpha" by default). Spelled out (not the fused createPipelineTexture)
293
+ * so a layer's targets - its views, a tile layer's chunks - share the one
294
+ * compile. The program lives as long as the pipeline; `dispose` frees
295
+ * both and the quad.
149
296
  */
150
- export function createSpritePipeline(label: string, vertex: string, instanceLayouts: VertexAttribute[][]): SpritePipeline {
297
+ export function createSpritePipeline(label: string, vertex: string, instanceLayouts: VertexAttribute[][], blend: BlendMode, atlases: readonly AtlasShading[]): SpritePipeline {
151
298
  // One unit quad (triangle strip), reused by every instance.
152
299
  let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
153
300
  label: `${label}-quad`,
154
301
  autoFree: false,
155
302
  })
156
303
  let vs = compileShader("vertex", vertex, { header: true })
157
- let fs = compileShader("fragment", FRAGMENT, { header: true })
304
+ let fs = compileShader("fragment", fragmentFor(atlases), { header: true })
158
305
  let program = linkProgram(vs, fs, { label })
159
306
  destroyShader(vs)
160
307
  destroyShader(fs)
@@ -162,7 +309,7 @@ export function createSpritePipeline(label: string, vertex: string, instanceLayo
162
309
  label,
163
310
  topology: "triangle-strip",
164
311
  buffers: [{ attributes: [{ name: "aPos", format: "float32x2" }] }, ...instanceLayouts.map(attributes => ({ stepMode: "instance" as const, attributes }))],
165
- blend: "alpha",
312
+ blend,
166
313
  })
167
314
  return {
168
315
  quad,
package/src/shots.ts CHANGED
@@ -17,7 +17,7 @@ const INITIAL: CameraState = { x: 0, y: 0, zoom: 1, rotation: 0, pivotX: 0, pivo
17
17
  let lerp = (a: number, b: number, t: number) => a + (b - a) * t
18
18
 
19
19
  /** Interpolate two 2d cameras: everything linear, the zoom in log space. */
20
- export let mixCamera2d = (a: CameraState, b: CameraState, t: number): CameraState => ({
20
+ export let mixCamera = (a: CameraState, b: CameraState, t: number): CameraState => ({
21
21
  x: lerp(a.x, b.x, t),
22
22
  y: lerp(a.y, b.y, t),
23
23
  zoom: a.zoom * Math.pow(b.zoom / a.zoom, t),
@@ -26,25 +26,39 @@ export let mixCamera2d = (a: CameraState, b: CameraState, t: number): CameraStat
26
26
  pivotY: lerp(a.pivotY, b.pivotY, t),
27
27
  })
28
28
 
29
- export type ShotsHandle = ShotBlend<CameraState>
29
+ /** A shot's target: the recording half plus the first view's size, so a
30
+ * createCamera2d drives it exactly like the view. */
30
31
  export type ShotTarget = RecordingTarget<CameraState> & Camera2dTarget
31
32
 
33
+ export type ShotsHandle = Omit<ShotBlend<CameraState>, "shot"> & { shot(name: string, opts?: { priority?: number }): ShotTarget }
34
+
32
35
  /**
33
36
  * Shots over a view (or several: one output over a scene's layers):
34
- * `shot(name, { priority? })` returns the target a `createCamera2d` (or
35
- * a `<Camera2d>` through a custom viewport) drives; `activate(name, {
36
- * blend? })` makes it live when its priority wins, blending from the
37
- * current output; `update(dt)` from a frame loop while `active()`.
37
+ * `shot(name, { priority? })` returns the target a `createCamera2d`
38
+ * drives (its `camera()` is the shot's own, its `size()` the first
39
+ * view's); `activate(name, { blend? })` makes it live when its priority
40
+ * wins, blending from the current output; `update(dt)` from a frame
41
+ * loop while `active()`.
38
42
  */
39
43
  export function createShots(target: Camera2dTarget | Camera2dTarget[], options: ShotBlendOptions = {}): ShotsHandle {
40
44
  let targets = Array.isArray(target) ? target : [target]
41
- for (let t of targets) if (!t || typeof t.setCamera !== "function") throw new Error("createShots: every target needs setCamera() (a view)")
42
- return createShotBlend<CameraState>(
45
+ for (let t of targets) {
46
+ if (!t || typeof t.setCamera !== "function" || typeof t.size !== "function") throw new Error("createShots: every target needs setCamera() and size() (a view)")
47
+ }
48
+ let first = targets[0]!
49
+ let blend = createShotBlend<CameraState>(
43
50
  camera => {
44
51
  for (let t of targets) t.setCamera(camera)
45
52
  },
46
- mixCamera2d,
53
+ mixCamera,
47
54
  INITIAL,
48
55
  options,
49
56
  )
57
+ return {
58
+ ...blend,
59
+ shot(name, opts) {
60
+ let rec = blend.shot(name, opts)
61
+ return { setCamera: rec.setCamera, camera: rec.camera, size: () => first.size() }
62
+ },
63
+ }
50
64
  }
@@ -0,0 +1,163 @@
1
+ // Text layout, pure: the units of a text prepared on the glyph engine
2
+ // (every unit carrying its glyphs) to one placement per glyph, in run
3
+ // pixels from the run's anchor point, plus the lines and the run's box.
4
+ // The line breaking is core's `layoutNextLine` (greedy over wrap units,
5
+ // glued pieces kept together, a hard break ending a line, an overlong
6
+ // unit placed whole), restated here rather than imported because core
7
+ // loads the gui modules and this file is checked headless on the flux
8
+ // binary (tests/text-layout.test.ts), like frames.ts. No GPU or GUI
9
+ // imports: the PreparedText import is a type.
10
+ import type { PreparedText, TextUnit } from "@solidrt/core"
11
+
12
+ /** The anchor of the run's x: where the point sits on each line's
13
+ * extent (SVG's text-anchor, d-text's `anchor`). */
14
+ export type TextAnchor = "start" | "middle" | "end"
15
+ /** The anchor of the run's y: the top of the first line, the middle of
16
+ * the box, the first baseline, or the bottom of the last line. */
17
+ export type TextAnchorY = "top" | "middle" | "baseline" | "bottom"
18
+ /** How lines sit inside the run's width when they differ (a wrapped or
19
+ * multi-line run). */
20
+ export type TextAlign = "left" | "center" | "right"
21
+
22
+ export type TextLayoutOptions = {
23
+ /** Wrap at this width in run pixels; unset, only `\n` breaks. */
24
+ maxWidth?: number
25
+ /** Line alignment inside the run's width; default "left". */
26
+ align?: TextAlign
27
+ /** Default "start". */
28
+ anchor?: TextAnchor
29
+ /** Default "top". */
30
+ anchorY?: TextAnchorY
31
+ /** Added after every glyph, run pixels (CSS letter-spacing); default 0. */
32
+ letterSpacing?: number
33
+ /** Baseline to baseline, run pixels; default each line's own ascent
34
+ * plus descent. */
35
+ lineHeight?: number
36
+ }
37
+
38
+ /** One glyph to draw: the font's glyph id and its origin (pen x, baseline
39
+ * y) in run pixels from the anchor point. */
40
+ export type GlyphPlacement = { id: number; x: number; y: number }
41
+
42
+ /** One laid-out line. */
43
+ export type TextLine = {
44
+ /** Unit range [from, to) into the prepared units. */
45
+ from: number
46
+ to: number
47
+ /** The line's x (its start after alignment) and baseline y, from the anchor point. */
48
+ x: number
49
+ y: number
50
+ /** Ink width (advances plus the last unit's width, trailing whitespace off). */
51
+ width: number
52
+ ascent: number
53
+ descent: number
54
+ }
55
+
56
+ export type TextLayout = {
57
+ glyphs: GlyphPlacement[]
58
+ lines: TextLine[]
59
+ /** The run's box: the widest line and the lines stacked. */
60
+ width: number
61
+ height: number
62
+ /** The first line's ascent: the first baseline below the box's top. */
63
+ ascent: number
64
+ }
65
+
66
+ /**
67
+ * Lay out a prepared text (from `SpriteFont.prepare`) into glyph
68
+ * placements. Throws on units without glyphs (a text prepared on
69
+ * Impeller, which exposes none).
70
+ */
71
+ export function layoutText(prepared: PreparedText, opts?: TextLayoutOptions): TextLayout {
72
+ let units = prepared.units
73
+ let maxWidth = opts?.maxWidth ?? Infinity
74
+ if (!(maxWidth > 0)) throw new Error(`layoutText: maxWidth must be positive, got ${maxWidth}`)
75
+ let letterSpacing = opts?.letterSpacing ?? 0
76
+ if (!Number.isFinite(letterSpacing)) throw new Error(`layoutText: letterSpacing must be finite, got ${letterSpacing}`)
77
+ for (let i = 0; i < units.length; i++) {
78
+ if (units[i]!.glyphs === undefined) throw new Error(`layoutText: unit ${i} ("${units[i]!.text}") carries no glyphs; prepare the text on a sprite font`)
79
+ }
80
+ // Break into lines: the greedy pass, lines as unit ranges.
81
+ type Range = { from: number; to: number; ascent: number; descent: number; width: number }
82
+ let ranges: Range[] = []
83
+ let cursor = 0
84
+ while (cursor < units.length) {
85
+ ranges.push(nextLine(units, cursor, maxWidth, letterSpacing))
86
+ cursor = ranges[ranges.length - 1]!.to
87
+ }
88
+ // A text with no units (empty) still has one empty line of no height.
89
+ if (ranges.length === 0) ranges.push({ from: 0, to: 0, ascent: 0, descent: 0, width: 0 })
90
+ let runWidth = 0
91
+ for (let r of ranges) runWidth = Math.max(runWidth, r.width)
92
+ let lineHeight = opts?.lineHeight
93
+ // Stack the lines: each baseline is its ascent below the line's top.
94
+ let lines: TextLine[] = []
95
+ let top = 0
96
+ let align = opts?.align ?? "left"
97
+ for (let r of ranges) {
98
+ let slack = runWidth - r.width
99
+ let x = align === "center" ? slack / 2 : align === "right" ? slack : 0
100
+ let height = lineHeight ?? r.ascent + r.descent
101
+ lines.push({ from: r.from, to: r.to, x, y: top + r.ascent, width: r.width, ascent: r.ascent, descent: r.descent })
102
+ top += height
103
+ }
104
+ let height = top
105
+ // The anchor point's offset: what every x and y shifts by.
106
+ let anchor = opts?.anchor ?? "start"
107
+ let anchorY = opts?.anchorY ?? "top"
108
+ let dx = anchor === "middle" ? -runWidth / 2 : anchor === "end" ? -runWidth : 0
109
+ let firstAscent = lines[0]!.ascent
110
+ let dy = anchorY === "middle" ? -height / 2 : anchorY === "baseline" ? -firstAscent : anchorY === "bottom" ? -height : 0
111
+ let glyphs: GlyphPlacement[] = []
112
+ for (let line of lines) {
113
+ line.x += dx
114
+ line.y += dy
115
+ let pen = line.x
116
+ for (let i = line.from; i < line.to; i++) {
117
+ let unit = units[i]!
118
+ for (let g of unit.glyphs!) {
119
+ glyphs.push({ id: g.id, x: pen + g.x, y: line.y + g.y })
120
+ pen += letterSpacing
121
+ }
122
+ pen += unit.advance
123
+ }
124
+ }
125
+ return { glyphs, lines, width: runWidth, height, ascent: firstAscent }
126
+ }
127
+
128
+ // Ink width of the wrap unit starting at `index` (core's unitInk): its
129
+ // advance through every glued piece after it, plus the last piece's ink,
130
+ // with the letter spacing its glyphs add.
131
+ function unitInk(units: TextUnit[], index: number, letterSpacing: number): number {
132
+ let ink = units[index]!.width + letterSpacing * units[index]!.glyphs!.length
133
+ let advance = 0
134
+ for (let j = index + 1; j < units.length && units[j]!.glue; j++) {
135
+ advance += units[j - 1]!.advance + letterSpacing * units[j - 1]!.glyphs!.length
136
+ ink = advance + units[j]!.width + letterSpacing * units[j]!.glyphs!.length
137
+ }
138
+ return ink
139
+ }
140
+
141
+ // The next line from unit `cursor` that fits `width` (core's
142
+ // layoutNextLine): units go on while the pen plus the unit's ink stays
143
+ // within the width; a hard break ends the line; a unit wider than the
144
+ // line on its own goes on whole.
145
+ function nextLine(units: TextUnit[], cursor: number, width: number, letterSpacing: number) {
146
+ let pen = 0
147
+ let ascent = 0
148
+ let descent = 0
149
+ let i = cursor
150
+ while (i < units.length) {
151
+ let unit = units[i]!
152
+ if (i > cursor && !unit.glue && pen + unitInk(units, i, letterSpacing) > width) break
153
+ pen += unit.advance + letterSpacing * unit.glyphs!.length
154
+ if (unit.ascent > ascent) ascent = unit.ascent
155
+ if (unit.descent > descent) descent = unit.descent
156
+ i++
157
+ if (unit.hardBreak) break
158
+ }
159
+ let last = units[i - 1]!
160
+ // The ink width: trailing whitespace off, like the unit's own `width`.
161
+ let lineWidth = pen - (last.advance + letterSpacing * last.glyphs!.length) + last.width + letterSpacing * last.glyphs!.length
162
+ return { from: cursor, to: i, ascent, descent, width: Math.max(lineWidth, 0) }
163
+ }