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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 20:33:20 +02:00
1 parent 46c620ae3b
commit e933fad446
20 files changed
+644 -55

No files matched your search

+109 -4
View File
@@ -35,10 +35,13 @@ import {
} from '../../src/core/properties/shaders';
import { inlineInputs } from '../../src/core/graph/NodeRegistry';
import { graphFingerprint } from '../../src/core/graph/Graph';
import { compileShader } from '../../src/core/shader/compile';
import { compileShader, PADDING_CLAMP_HINT } from '../../src/core/shader/compile';
import {
DEFAULT_EDGE,
EDGE_PARAM,
MAX_PADDING,
PADDING_PARAM,
paddingOf,
SHADER_NODES,
SHADER_NUMBER,
SHADER_OUTPUT,
@@ -220,7 +223,8 @@ describe('compileShader', () => {
for (const name of ['brightness', 'contrast', 'saturation'])
expect(fragment).toContain(`uniform float u_${name};`);
// Drawn in the sprite's own pass: the tint goes in before the graph, premultiplied like the texel.
expect(fragment).toContain('vec4 texel = texture(uTexture, vTextureCoord) * vColor;');
expect(fragment).toContain('vec4 texelAt(vec2 uv) { return texture(uTexture, uv); }');
expect(fragment).toContain('vec4 texel = texelAt(vTextureCoord) * vColor;');
expect(fragment).toContain('vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : c.rgb; }');
expect(fragment).toContain('vec3 rgb = straight(texel);');
expect(compiled).toMatchObject({ time: false, edge: 'mirror' });
@@ -470,10 +474,10 @@ describe('compileShader', () => {
expect(fragment).toMatch(/float n\d+_u = vTextureCoord\.x;/);
expect(fragment).toMatch(/float n\d+_result = \(1\.0 - n\d+_u\);/);
expect(fragment).toMatch(
/vec3 n\d+_color = straight\(texture\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\);/,
/vec3 n\d+_color = straight\(texelAt\(vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\);/,
);
expect(fragment).toMatch(
/float n\d+_alpha = \(texture\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)\.a;/,
/float n\d+_alpha = \(texelAt\(vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)\.a;/,
);
expect(fragment).toMatch(/float n\d+_width = float\(textureSize\(uTexture, 0\)\.x\);/);
expect(fragment).toMatch(/float n\d+_value = uTime;/);
@@ -510,6 +514,107 @@ describe('compileShader', () => {
expect(compileShader(s.asset().graph).compiled!.edge).toBe('clamp');
});
describe('padding and the Transparent edge (#69)', () => {
const plain = (params: Record<string, unknown> = {}) => {
const s = shaderEditor(makeProject(), 'Glow');
const out = s.add(SHADER_OUTPUT, params);
return { s, out, build: () => compileShader(s.asset().graph) };
};
it('has no padding on a new Output, and hands on what is set there', () => {
const fresh = plain();
const params = fresh.s.asset().graph.nodes.find((n) => n.id === fresh.out)!.params;
expect(params[PADDING_PARAM]).toBe(0);
expect(fresh.build().compiled!.padding).toBe(0);
expect(plain({ padding: 3 }).build().compiled!.padding).toBe(3);
// A graph from before the setting existed has no value and draws as it did.
delete params[PADDING_PARAM];
expect(fresh.build().compiled!.padding).toBe(0);
});
it('reads a padding nobody could mean as none, and stops at the limit', () => {
expect(paddingOf({ padding: -4 })).toBe(0);
expect(paddingOf({ padding: 'wide' })).toBe(0);
expect(paddingOf({ padding: Number.POSITIVE_INFINITY })).toBe(0);
expect(paddingOf({ padding: MAX_PADDING + 1 })).toBe(MAX_PADDING);
expect(paddingOf({ padding: 1.5 })).toBe(1.5);
});
it('shows the Edge in the margin: with padding the mode counts without any shifted Sample', () => {
expect(plain({ edge: 'repeat' }).build().compiled!.edge).toBe(SHEET_EDGE);
expect(plain({ edge: 'repeat', padding: 2 }).build().compiled!.edge).toBe('repeat');
expect(plain({ edge: 'clamp', padding: 2 }).build().compiled!.edge).toBe('clamp');
});
it('cuts Transparent in the fragment, on the plain sheet', () => {
const { compiled } = plain({ edge: 'transparent', padding: 2 }).build();
// No copy of the picture with a sampler of its own: the fragment empties the outside itself.
expect(compiled!.edge).toBe(SHEET_EDGE);
expect(compiled!.fragment).toContain(
'vec4 texelAt(vec2 uv) { vec2 inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); return texture(uTexture, uv) * (inside.x * inside.y); }',
);
// The sprite's own pixel goes through the same cut, so the margin itself is empty.
expect(compiled!.fragment).toContain('vec4 texel = texelAt(vTextureCoord) * vColor;');
for (const edge of ['clamp', 'mirror', 'repeat'])
expect(plain({ edge, padding: 2 }).build().compiled!.fragment).toContain(
'vec4 texelAt(vec2 uv) { return texture(uTexture, uv); }',
);
});
it('reads a shifted Sample through the same cut', () => {
const { s, out, build } = plain({ edge: 'transparent' });
const uv = s.add(SHADER_UV);
const shift = s.add('ShaderAdd', { b: 0.5 });
const sample = s.add(SHADER_SAMPLE);
s.link(uv, 'u', shift, 'a');
s.link(shift, 'result', sample, 'u');
s.link(sample, 'color', out, 'color');
const { compiled } = build();
expect(compiled!.edge).toBe(SHEET_EDGE);
expect(compiled!.fragment).toMatch(
/straight\(texelAt\(vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)/,
);
expect(compiled!.fragment).not.toMatch(/straight\(texture\(/);
});
it('says at the Output that padding with Clamp smears, and still compiles', () => {
const smeared = plain({ edge: 'clamp', padding: 2 });
const build = smeared.build();
expect(build.hints).toEqual({ [smeared.out]: PADDING_CLAMP_HINT });
expect(build.problems).toEqual({});
expect(build.reason).toBe('');
expect(build.compiled).not.toBeNull();
expect(plain({ edge: 'clamp' }).build().hints).toEqual({});
for (const edge of ['transparent', 'mirror', 'repeat'])
expect(plain({ edge, padding: 2 }).build().hints).toEqual({});
});
it('offers Transparent and Padding on the Output', () => {
const [edge, padding] = ShaderOutput.params;
expect(edge).toMatchObject({ key: EDGE_PARAM, kind: 'enum' });
expect((edge as { options: { value: string }[] }).options.map((o) => o.value)).toEqual([
'clamp',
'mirror',
'repeat',
'transparent',
]);
expect(padding).toMatchObject({
key: PADDING_PARAM,
kind: 'number',
default: 0,
min: 0,
max: MAX_PADDING,
});
});
it('compiles anew when the padding changes, so every wearer hears of it', () => {
const { s, out } = plain({ padding: 1 });
const before = graphFingerprint(s.asset().graph);
s.asset().graph.nodes.find((n) => n.id === out)!.params[PADDING_PARAM] = 2;
expect(graphFingerprint(s.asset().graph)).not.toBe(before);
});
});
it('writes Fract, Step and Floor as the GLSL calls, per channel for a colour', () => {
const f = makeProject();
const s = shaderEditor(f, 'Lines');