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

+6 -6
View File
@@ -279,12 +279,12 @@ export class GraphCanvas extends Widget {
const seen = new Set<string>();
const lookup = graphLookup(this.state.project);
// In a compiled graph a node's problem is the compiler's, not a call's.
const compilerProblems =
asset.type === 'shader'
? shaderLibrary.buildFor(asset).problems
: asset.type === 'particles'
? particleLibrary.buildFor(asset).problems
: null;
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset) : null;
const compilerProblems = shaderBuild
? { ...shaderBuild.hints, ...shaderBuild.problems }
: asset.type === 'particles'
? particleLibrary.buildFor(asset).problems
: null;
for (const node of asset.graph.nodes) {
const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue;
+4 -2
View File
@@ -9,6 +9,7 @@ import { crisp, hairline, type Size, Theme, Widget } from '../../ui';
import { ParticleField } from '../../viewport/particles';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { wornShader } from '../../viewport/shaders';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
@@ -89,11 +90,12 @@ export class ParticlePreview extends Widget {
const props = previewProps(this.asset, rows);
const params = rows.map((row) => Number(props[row.key] ?? row.default));
const ref = resolveTexture(this.state.project, props);
const texture = ref ? textureLibrary.get(ref, SHEET_EDGE) : null;
const shaders = shaderLibrary.source(this.state.project);
const texture = ref ? textureLibrary.get(ref, wornShader(props, shaders)?.edge ?? SHEET_EDGE) : null;
// The source sits in the middle of the panel; a fountain then has room above and below it.
const m = translation(this.w / 2, this.h / 2);
this.field.hold(this.held);
this.field.wear(props, shaderLibrary.source(this.state.project));
this.field.wear(props, shaders, texture);
this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, particleLibrary.now());
}
+26 -8
View File
@@ -6,7 +6,8 @@ import { type EdgeMode, SHEET_EDGE } from '../../core/shader/nodes';
import { crisp, hairline, type Size, Theme, Widget } from '../../ui';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { syncShader } from '../../viewport/shaders';
import { disposeSpriteMesh, newSpriteMesh } from '../../viewport/symbols';
import { disposeSpriteMesh, newSpriteMesh, syncQuadPadding } from '../../viewport/symbols';
import { quadPadding } from '../../viewport/quadPadding';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import { type PreviewPicture, TEST_IMAGE_SIZE, testImagePixels } from './previewPicture';
@@ -92,17 +93,29 @@ export class ShaderPreview extends Widget {
this.mesh.visible = this.shader !== null;
if (!this.shader) return;
const source = { ...shaderLibrary.source(this.state.project), now: this.time };
const edge = source.compiled(this.shader.id)?.edge ?? SHEET_EDGE;
const worn = source.compiled(this.shader.id);
const edge = worn?.edge ?? SHEET_EDGE;
const picture = this.picture;
const w = picture?.w ?? TEST_IMAGE_SIZE;
const h = picture?.h ?? TEST_IMAGE_SIZE;
const zoom = Math.max(0, Math.min((this.w - 2 * MARGIN) / w, (this.h - 2 * MARGIN) / h, MAX_ZOOM));
// A sprite whose picture is still loading shows its plain colour, like the scene does.
const texture = picture
? ((picture.texture && textureLibrary.get(picture.texture, edge)) ?? null)
: testTexture(edge);
// The shader's padding in pixels of the picture, as the scene counts it; the panel keeps room for it.
const pad = quadPadding(worn?.padding ?? 0, texture?.width ?? w, texture?.height ?? h);
const zoom = Math.max(
0,
Math.min(
(this.w - 2 * MARGIN) / (w * (1 + 2 * (pad?.u ?? 0))),
(this.h - 2 * MARGIN) / (h * (1 + 2 * (pad?.v ?? 0))),
MAX_ZOOM,
),
);
this.mesh.position.set(Math.round(this.w / 2), Math.round(this.h / 2));
this.mesh.scale.set(w * zoom, h * zoom);
// A sprite whose picture is still loading shows its plain colour, like the scene does.
this.mesh.texture = picture
? ((picture.texture && textureLibrary.get(picture.texture, edge)) ?? Texture.WHITE)
: testTexture(edge);
syncQuadPadding(this.mesh, worn?.padding ?? 0, texture?.width ?? w, texture?.height ?? h);
this.mesh.texture = texture ?? Texture.WHITE;
this.mesh.tint = picture?.tint ?? 0xffffff;
syncShader(this.mesh, { ...this.values, [SHADER_KEY]: this.shader.id }, source);
}
@@ -114,7 +127,12 @@ export class ShaderPreview extends Widget {
}
}
const TEST_ADDRESS = { clamp: 'clamp-to-edge', mirror: 'mirror-repeat', repeat: 'repeat' } as const;
const TEST_ADDRESS = {
clamp: 'clamp-to-edge',
mirror: 'mirror-repeat',
repeat: 'repeat',
transparent: 'mirror-repeat',
} as const;
const testTextures = new Map<EdgeMode, Texture>();
/** The test image, one copy per edge mode: the sampler decides what a UV past the edge reads. */