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

+21 -5
View File
@@ -12,6 +12,7 @@ import {
type Expr,
floatLiteral,
isShaderNodeDef,
paddingOf,
SHADER_OUTPUT,
SHADER_SAMPLE,
SHADER_TIME,
@@ -19,7 +20,9 @@ import {
SHEET_EDGE,
STRAIGHT_FN,
TEXEL_ALPHA,
TEXEL_AT,
TEXEL_COLOR,
texelAtFn,
TIME_UNIFORM,
uniformName,
vec3Literal,
@@ -40,6 +43,8 @@ export interface CompiledShader {
* past the border, and the plain sheet where it cannot.
*/
edge: EdgeMode;
/** Pixels of the picture the wearer draws beyond it on every side (#69). */
padding: number;
}
export interface ShaderBuild {
@@ -47,10 +52,14 @@ export interface ShaderBuild {
compiled: CompiledShader | null;
/** Per node id, what keeps the graph from compiling. */
problems: Record<string, string>;
/** Per node id, what the graph compiles with but its author will hardly have meant. */
hints: Record<string, string>;
/** One line for the tab: empty once compiled. */
reason: string;
}
export const PADDING_CLAMP_HINT = 'Padding with Clamp smears the border';
/**
* A shader graph to the fragment of the sprite's own draw: every node reachable from the Output
* becomes one local per output, in dependency order, and every Property a float uniform. The texel
@@ -101,14 +110,18 @@ export function compileShader(graph: Graph): ShaderBuild {
);
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
if (!output) return { compiled: null, problems, reason: 'No Output node' };
if (!output) return { compiled: null, problems, hints: {}, reason: 'No Output node' };
const mode = edgeOf(output.params);
const padding = paddingOf(output.params);
const hints: Record<string, string> =
padding > 0 && mode === 'clamp' ? { [output.id]: PADDING_CLAMP_HINT } : {};
const outputDef = nodeDef(SHADER_OUTPUT)!;
const color = walk.input(output, outputDef.inputs[0]!);
const alpha = walk.input(output, outputDef.inputs[1]!);
const failed = walk.firstProblem();
if (failed !== null || !color || !alpha)
return { compiled: null, problems, reason: failed ?? 'Cannot compile' };
return { compiled: null, problems, hints, reason: failed ?? 'Cannot compile' };
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
const time = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
@@ -126,9 +139,10 @@ export function compileShader(graph: Graph): ShaderBuild {
...uniforms.map((u) => `uniform float ${uniformName(u.name)};`),
STRAIGHT_FN,
DIVIDE_FN,
texelAtFn(mode),
'void main()',
'{',
' vec4 texel = texture(uTexture, vTextureCoord) * vColor;',
` vec4 texel = ${TEXEL_AT}(vTextureCoord) * vColor;`,
` vec3 ${TEXEL_COLOR.code} = straight(texel);`,
...lines.map((line) => ` ${line}`),
` float alpha = clamp(${alpha.code}, 0.0, 1.0);`,
@@ -138,12 +152,14 @@ export function compileShader(graph: Graph): ShaderBuild {
].join('\n');
// The edge only shows where a Sample reads somewhere other than its own pixel. A graph that never
// shifts a UV stays on the plain sheet, so the common shader costs no second copy of the picture.
// With padding every pixel of the margin reads past the border; Transparent is cut in the
// fragment and takes the plain sheet as well.
const shifted = graph.nodes.some(
(n) =>
n.type === SHADER_SAMPLE &&
walk.reached(n) &&
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
);
const edge = shifted ? edgeOf(output.params) : SHEET_EDGE;
return { compiled: { fragment, uniforms, time, edge }, problems, reason: '' };
const edge = (shifted || padding > 0) && mode !== 'transparent' ? mode : SHEET_EDGE;
return { compiled: { fragment, uniforms, time, edge, padding }, problems, hints, reason: '' };
}
+30 -4
View File
@@ -54,8 +54,14 @@ export const STRAIGHT_FN = 'vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c
* How the picture continues past its edge when a `Sample texture` reads outside 0..1 – a setting
* of the Output node, because the sampler state is one per draw.
*/
export type EdgeMode = 'repeat' | 'mirror' | 'clamp';
export type EdgeMode = 'repeat' | 'mirror' | 'clamp' | 'transparent';
export const EDGE_PARAM = 'edge';
/**
* Room around the picture the sprite draws into as well (#69), in pixels of the picture per side –
* a setting of the Output node, so an outline or a glow has somewhere to go.
*/
export const PADDING_PARAM = 'padding';
export const MAX_PADDING = 256;
/**
* The address mode the shared sheet carries anyway (`TextureLibrary`): within a texel it reads
* like clamp, and no other mode can have it without a cut of its own. Asking for it costs nothing,
@@ -68,6 +74,7 @@ const EDGE_OPTIONS: { value: EdgeMode; label: string }[] = [
{ value: 'clamp', label: 'Clamp' },
{ value: 'mirror', label: 'Mirror' },
{ value: 'repeat', label: 'Repeat' },
{ value: 'transparent', label: 'Transparent' },
];
export function edgeOf(params: PropertyBag): EdgeMode {
@@ -75,6 +82,22 @@ export function edgeOf(params: PropertyBag): EdgeMode {
return EDGE_OPTIONS.some((o) => o.value === value) ? (value as EdgeMode) : DEFAULT_EDGE;
}
export function paddingOf(params: PropertyBag): number {
const value = Number(params[PADDING_PARAM] ?? 0);
return Number.isFinite(value) ? Math.min(MAX_PADDING, Math.max(0, value)) : 0;
}
/**
* Reads the picture at a UV. No sampler state makes the outside empty, so *Transparent* is cut
* here, and with it needs no copy of the picture with an address mode of its own.
*/
export const TEXEL_AT = 'texelAt';
export function texelAtFn(edge: EdgeMode): string {
return edge === 'transparent'
? `vec4 ${TEXEL_AT}(vec2 uv) { vec2 inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); return texture(uTexture, uv) * (inside.x * inside.y); }`
: `vec4 ${TEXEL_AT}(vec2 uv) { return texture(uTexture, uv); }`;
}
const NUMBER_STEP = 0.05;
const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' });
@@ -182,10 +205,13 @@ export const ShaderOutput: ShaderNodeDef = {
label: 'Output',
category: 'shader',
description:
'The color and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.',
'The color and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it. Padding is room around the picture to draw into, in pixels of the picture.',
inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }],
outputs: [],
params: [{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS }],
params: [
{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS },
{ key: PADDING_PARAM, label: 'Padding', kind: 'number', default: 0, min: 0, max: MAX_PADDING, step: 1 },
],
unfed: { color: TEXEL_COLOR, alpha: TEXEL_ALPHA },
emit: () => ({}),
};
@@ -216,7 +242,7 @@ export const ShaderSample: ShaderNodeDef = {
params: [],
unfed: { u: OWN_U, v: OWN_V },
emit: ({ u, v }) => {
const sample = `texture(uTexture, vec2(${u!.code}, ${v!.code})) * vColor`;
const sample = `${TEXEL_AT}(vec2(${u!.code}, ${v!.code})) * vColor`;
return {
color: { code: `straight(${sample})`, type: 'vec3' },
alpha: { code: `(${sample}).a`, type: 'float' },