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>
95 lines
4.1 KiB
TypeScript
95 lines
4.1 KiB
TypeScript
/**
|
|
* A shader's Padding as geometry (#69): the quad grows by so many pixels of the picture and its
|
|
* UVs grow with it, so the picture stays where it was and the margin reads past its border.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { NineSlice } from '../../src/core/model/texture';
|
|
import { paddedSpans, sliceGeometry, sliceKey, sliceSpans } from '../../src/viewport/nineSlice';
|
|
import { paddedQuad, paddingKey, quadPadding } from '../../src/viewport/quadPadding';
|
|
|
|
const slice = (scale = 1): NineSlice => ({
|
|
border: { left: 8, top: 4, right: 8, bottom: 4 },
|
|
fill: 'stretch',
|
|
sourceW: 24,
|
|
sourceH: 16,
|
|
scale,
|
|
});
|
|
|
|
describe('quadPadding', () => {
|
|
it('counts in pixels of the picture, per axis', () => {
|
|
expect(quadPadding(2, 16, 32)).toEqual({ u: 0.125, v: 0.0625 });
|
|
});
|
|
|
|
it('is nothing without padding, and without a picture to count in', () => {
|
|
expect(quadPadding(0, 16, 16)).toBeNull();
|
|
expect(quadPadding(-1, 16, 16)).toBeNull();
|
|
expect(quadPadding(2, 0, 16)).toBeNull();
|
|
expect(quadPadding(2, 16, 0)).toBeNull();
|
|
expect(quadPadding(Number.NaN, 16, 16)).toBeNull();
|
|
});
|
|
|
|
it('keys two margins apart, and no margin as the shared quad', () => {
|
|
expect(paddingKey(null)).toBe('');
|
|
expect(paddingKey(quadPadding(2, 16, 16))).not.toBe(paddingKey(quadPadding(2, 16, 32)));
|
|
expect(paddingKey(quadPadding(2, 16, 16))).toBe(paddingKey(quadPadding(4, 32, 32)));
|
|
});
|
|
});
|
|
|
|
describe('paddedQuad', () => {
|
|
it('grows on every side and reads past the border by the same amount', () => {
|
|
const { positions, uvs, indices } = paddedQuad({ u: 0.125, v: 0.25 });
|
|
expect([...positions]).toEqual([-0.125, -0.25, 1.125, -0.25, 1.125, 1.25, -0.125, 1.25]);
|
|
// Same numbers: 0..1 of the box is still 0..1 of the picture, so the picture does not move.
|
|
expect([...uvs]).toEqual([...positions]);
|
|
expect([...indices]).toEqual([0, 1, 2, 0, 2, 3]);
|
|
});
|
|
});
|
|
|
|
describe('a sliced sprite with padding', () => {
|
|
it('gets a ring as wide as that many border pixels, reading that far past the frame', () => {
|
|
const spans = paddedSpans(sliceSpans(100, 24, 8, 8, 'stretch'), 100, 24, 3);
|
|
expect(spans.map((s) => [s.p0, s.p1])).toEqual([
|
|
[-3, 0],
|
|
[0, 8],
|
|
[8, 92],
|
|
[92, 100],
|
|
[100, 103],
|
|
]);
|
|
expect(spans[0]).toMatchObject({ t0: -3 / 24, t1: 0 });
|
|
expect(spans.at(-1)).toMatchObject({ t0: 1, t1: 1 + 3 / 24 });
|
|
});
|
|
|
|
it('draws the ring at the scale the borders are drawn at', () => {
|
|
const spans = paddedSpans(sliceSpans(100, 24, 8, 8, 'stretch', 2), 100, 24, 3, 2);
|
|
expect([spans[0]!.p0, spans.at(-1)!.p1]).toEqual([-6, 106]);
|
|
// Twice as wide on screen, the same three pixels of the picture.
|
|
expect(spans[0]!.t0).toBe(-3 / 24);
|
|
});
|
|
|
|
it('leaves the parts alone without padding, and adds nothing to a sprite of no size', () => {
|
|
const plain = sliceSpans(100, 24, 8, 8, 'stretch');
|
|
expect(paddedSpans(plain, 100, 24, 0)).toBe(plain);
|
|
expect(paddedSpans([], 0, 24, 3)).toEqual([]);
|
|
});
|
|
|
|
it('builds the ring into the geometry: sixteen more quads around the nine', () => {
|
|
const plain = sliceGeometry(slice(), 100, 50);
|
|
const padded = sliceGeometry(slice(), 100, 50, 2);
|
|
expect(plain.indices.length / 6).toBe(9);
|
|
expect(padded.indices.length / 6).toBe(25);
|
|
const xs = [...padded.positions].filter((_, i) => i % 2 === 0);
|
|
const ys = [...padded.positions].filter((_, i) => i % 2 === 1);
|
|
expect([Math.min(...xs), Math.max(...xs)]).toEqual([-2 / 100, 1 + 2 / 100].map(Math.fround));
|
|
expect([Math.min(...ys), Math.max(...ys)]).toEqual([-2 / 50, 1 + 2 / 50].map(Math.fround));
|
|
const us = [...padded.uvs].filter((_, i) => i % 2 === 0);
|
|
const vs = [...padded.uvs].filter((_, i) => i % 2 === 1);
|
|
expect([Math.min(...us), Math.max(...us)]).toEqual([-2 / 24, 1 + 2 / 24].map(Math.fround));
|
|
expect([Math.min(...vs), Math.max(...vs)]).toEqual([-2 / 16, 1 + 2 / 16].map(Math.fround));
|
|
});
|
|
|
|
it('keeps the key of an unpadded slice as it was', () => {
|
|
expect(sliceKey(slice(), 100, 50)).toBe(sliceKey(slice(), 100, 50));
|
|
expect(sliceGeometry(slice(), 100, 50, 0).positions).toEqual(sliceGeometry(slice(), 100, 50).positions);
|
|
});
|
|
});
|