Files
feuerwerk/src/viewport/nineSlice.ts
T
adminandClaude Opus 5.5 e933fad446 Give a shader room to draw around the picture it wears (#69)
A shader ran only inside the rectangle of what wears it: the quad
has the size of the sprite, and outside of it no fragment runs. An
outline or a glow built with Sample texture was cut off at the
border. The issue still spoke of Filter.padding, but since #77 the
shader is no filter any more, the limit is the geometry.

- The Output node has a number Padding next to Edge: so many pixels
  of the picture per side the wearer draws beyond it. 0 is the
  default and draws as before, 256 is the most.
- The quad grows and its UVs grow with it, so the picture keeps its
  place and size. In the margin the UVs run on below 0 and above 1.
- Transparent is a fourth Edge mode: outside of the picture there
  is nothing. It is cut in the fragment (texelAt), so it needs no
  copy of the picture with an address mode of its own.
- Edge holds everywhere alike, also for Texture color, Texture
  alpha and unconnected inputs.
- Padding above 0 with Edge on Clamp puts the hint "Padding with
  Clamp smears the border" on the Output. The shader still
  compiles. A chosen Clamp and the default cannot be told apart.
- All four wearers draw the margin. A sprite counts in pixels of
  its frame, a text in its units, a particle source grows every
  particle about its centre.
- A nine-slice sprite gets a ring around its nine fields, as wide
  as so many border pixels. The fields themselves stay.
- Without a picture a pixel is one unit of the wearer's own size,
  or of the particle size.
- Particles now take the Edge mode of their shader. It had no
  effect on them before.
- An object wearing a padded shader is never culled, or its margin
  would vanish at the edge of the screen.
- The preview in the shader tab shows the margin and leaves room
  for it.
- Picking, the selection frame, the size and the collider stay with
  the rectangle without the margin.

Tests:
- Gate on melektaus-atx right before this commit: 216 files,
  3336 tests (3319 + 17), exit 0.
- e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass.
  e2e 1-37,47,48,58 ran before the last change (no culling with
  padding): 1281 checks, all pass.
- 17 new unit tests: the compiler (padding, Transparent, the hint)
  and the geometry (quad, nine-slice ring).
- Seven new e2e checks: four at the sprite in module 28, one each
  for text, particles and nine-slice.
- 17 unit counter-probes all fail their tests. Three browser
  counter-probes fail their checks (5, 3 and 1 of them).
- Three screenshots looked at: the sprite with Clamp and margin in
  the scene, the preview in the shader tab, the text.
- The e2e sum of 1687 is counted (1680 + 7), the full suite and
  the smokes did not run.
- Not seen: the hint at the node in a picture, particles and
  nine-slice in a picture, the game view while playing, sprites
  without a picture, vector and pixel art pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:33:20 +02:00

149 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The geometry a nine-sliced frame draws with (#41): no Pixi and no project, so it stays
* unit-testable. A sprite is one quad per part rather than a shared 4×4 vertex grid, because a
* repeated part needs its own UVs per tile – a grid can only stretch.
*/
import type { FrameFill } from '../core/model/Asset';
import type { NineSlice } from '../core/model/texture';
/** How often a part may repeat before it stretches instead; a guard, not a design (see `sliceSpans`). */
export const MAX_TILES = 64;
/** One part along an axis: `p0`..`p1` in the sprite's units, `t0`..`t1` in the frame's 0..1 texture space. */
export interface Span {
p0: number;
p1: number;
t0: number;
t1: number;
}
/**
* The parts one axis is cut into. `near`/`far` are the two borders in source pixels, and they keep
* that size times `scale` in the sprite's units – that is the whole point of slicing. A sprite too
* small to hold both is not left with overlapping corners: they are scaled down together until the
* middle is exactly zero, so the seam sits where the two corners meet.
*/
export function sliceSpans(
target: number,
source: number,
near: number,
far: number,
fill: FrameFill,
scale = 1,
): Span[] {
if (!(target > 0) || !(source > 0) || !(scale > 0)) return [];
const nearUnits = near * scale;
const farUnits = far * scale;
const shrink = nearUnits + farUnits > target ? target / (nearUnits + farUnits) : 1;
const n = nearUnits * shrink;
const f = farUnits * shrink;
const middle = Math.max(0, target - n - f);
const sourceMiddle = source - near - far;
const spans: Span[] = [];
if (n > 0) spans.push({ p0: 0, p1: n, t0: 0, t1: near / source });
if (middle > 0 && sourceMiddle > 0) {
const t0 = near / source;
const t1 = (source - far) / source;
// Whole tiles of the frame's own size, the last one cut off where the middle ends. Sampling
// with UVs past 1 would do it in one quad, but a frame is a cut-out of a sheet: past its edge
// lies the neighbouring cell, not the frame again. The tile scales with the borders (#96),
// or a border drawn twice as big would sit beside a pattern drawn at its old size.
const tile = sourceMiddle * scale;
const tiles = fill === 'repeat' ? Math.ceil(middle / tile) : 1;
if (fill === 'repeat' && tiles <= MAX_TILES) {
for (let i = 0; i < tiles; i++) {
const p0 = n + i * tile;
const p1 = Math.min(n + middle, p0 + tile);
spans.push({ p0, p1, t0, t1: t0 + ((p1 - p0) / tile) * (t1 - t0) });
}
} else {
spans.push({ p0: n, p1: n + middle, t0, t1 });
}
}
if (f > 0) spans.push({ p0: target - f, p1: target, t0: (source - far) / source, t1: 1 });
return spans;
}
/**
* A ring of `padding` source pixels around the parts (#69): as wide as a border pixel is drawn,
* and reading that far past the frame, so a shader finds the same room there as around a quad.
*/
export function paddedSpans(
spans: Span[],
target: number,
source: number,
padding: number,
scale = 1,
): Span[] {
if (!(padding > 0) || spans.length === 0) return spans;
const room = padding * scale;
const t = padding / source;
return [{ p0: -room, p1: 0, t0: -t, t1: 0 }, ...spans, { p0: target, p1: target + room, t0: 1, t1: 1 + t }];
}
/**
* Positions in 0..1 of the sprite's box, as the unit quad they replace: the mesh carries the
* sprite's size in its scale, so the geometry must not.
*/
export function sliceGeometry(
slice: NineSlice,
w: number,
h: number,
padding = 0,
): { positions: Float32Array; uvs: Float32Array; indices: Uint32Array } {
const { border, fill, scale } = slice;
const columns = paddedSpans(
sliceSpans(w, slice.sourceW, border.left, border.right, fill, scale),
w,
slice.sourceW,
padding,
scale,
);
const rows = paddedSpans(
sliceSpans(h, slice.sourceH, border.top, border.bottom, fill, scale),
h,
slice.sourceH,
padding,
scale,
);
const positions: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
for (const row of rows) {
for (const column of columns) {
const base = positions.length / 2;
positions.push(
column.p0 / w,
row.p0 / h,
column.p1 / w,
row.p0 / h,
column.p1 / w,
row.p1 / h,
column.p0 / w,
row.p1 / h,
);
uvs.push(column.t0, row.t0, column.t1, row.t0, column.t1, row.t1, column.t0, row.t1);
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
}
return {
positions: new Float32Array(positions),
uvs: new Float32Array(uvs),
indices: new Uint32Array(indices),
};
}
/**
* What the geometry depends on. Rebuilding on every frame would throw away a buffer the GPU
* already has; a sprite that only moves keeps the one it was given.
*/
export function sliceKey(slice: NineSlice, w: number, h: number): string {
const b = slice.border;
return `${b.left},${b.top},${b.right},${b.bottom}|${slice.fill}|×${slice.scale}|${slice.sourceW}×${slice.sourceH}|${round(w)}×${round(h)}`;
}
/** Sub-pixel changes of size do not change the picture, and would rebuild on every mouse move. */
function round(value: number): number {
return Math.round(value * 4) / 4;
}