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>
149 lines
5.2 KiB
TypeScript
149 lines
5.2 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|