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

+1 -1
View File
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1680 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1687 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+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' },
+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. */
+2
View File
@@ -21,6 +21,8 @@ export const ADDRESS_MODE: Record<EdgeMode, 'repeat' | 'mirror-repeat' | 'clamp-
mirror: EDGE_MODE,
repeat: 'repeat',
clamp: 'clamp-to-edge',
// Never asked for: the fragment cuts the outside away itself, on the plain sheet.
transparent: EDGE_MODE,
};
/** Keeps uploaded images on the GPU; callers never await and get a redraw when the picture lands. */
+8 -5
View File
@@ -329,8 +329,10 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
// A switched-off object is not drawn at all, in the scene view as in the game; it is
// selected and switched back on from the hierarchy panel. A source is never culled by its
// own little square: its particles may be anywhere on screen while it is not.
const onScreen = item.enabled && (item.type === 'particles' || rectsIntersect(extent, bounds));
// own little square: its particles may be anywhere on screen while it is not. Nor is a wearer
// of a padded shader, which draws past its rectangle (#69).
const unbound = item.type === 'particles' || (wornShader(item.props, shaders)?.padding ?? 0) > 0;
const onScreen = item.enabled && (unbound || rectsIntersect(extent, bounds));
let node = this.nodes.get(item.id);
if (!node) {
node = new Graphics();
@@ -342,7 +344,7 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
}
const hidden = this.hidesShape(item);
node.visible = onScreen && !hidden;
if (item.enabled && !hidden) culled.push({ node, extent: item.type === 'particles' ? null : extent });
if (item.enabled && !hidden) culled.push({ node, extent: unbound ? null : extent });
node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none';
this.syncDecorations(item, onScreen, view.scale, extent);
this.syncText(item, onScreen, view.scale, shaders);
@@ -485,8 +487,9 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
const build = particleLibrary.buildFor(item.particles);
const compiled = build.compiled;
const params = compiled ? compiled.params.map((row) => Number(item.props[row.key] ?? row.default)) : [];
const texture = item.texture ? textureLibrary.get(item.texture, SHEET_EDGE) : null;
field.wear(item.props, shaders);
const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE;
const texture = item.texture ? textureLibrary.get(item.texture, edge) : null;
field.wear(item.props, shaders, texture);
field.sync(
compiled,
item.props,
+32 -2
View File
@@ -64,6 +64,23 @@ export function sliceSpans(
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.
@@ -72,10 +89,23 @@ export function sliceGeometry(
slice: NineSlice,
w: number,
h: number,
padding = 0,
): { positions: Float32Array; uvs: Float32Array; indices: Uint32Array } {
const { border, fill, scale } = slice;
const columns = sliceSpans(w, slice.sourceW, border.left, border.right, fill, scale);
const rows = sliceSpans(h, slice.sourceH, border.top, border.bottom, fill, scale);
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[] = [];
+5 -2
View File
@@ -189,8 +189,11 @@ export class ParticleField {
}
/** Puts the shader the source names on every particle of this field, or takes one off (#88). */
wear(props: PropertyBag, shaders: ShaderSource): void {
syncParticleShader(this.container, props, shaders);
wear(props: PropertyBag, shaders: ShaderSource, texture: Texture | null): void {
// Without a picture a particle is its plain square, and a pixel is one unit of its size.
const size = Math.max(0.01, numberOf(props[PARTICLE_SIZE_KEY], 16));
const picture = texture ? { w: texture.width, h: texture.height } : { w: size, h: size };
syncParticleShader(this.container, props, shaders, picture);
}
/** Particles are reused rather than rebuilt: a stream of 500 would otherwise allocate 500 a frame. */
+38
View File
@@ -0,0 +1,38 @@
/**
* The room a shader's Padding adds around what wears it (#69): no Pixi and no project, so it stays
* unit-testable. The quad grows and its UVs grow with it, so the picture keeps its place and size
* and the margin reads past its border – what is there is the Output's Edge.
*/
/** The margin per side as a fraction of the picture: what both the positions and the UVs grow by. */
export interface QuadPadding {
u: number;
v: number;
}
/**
* `padding` pixels of a picture `w`×`h` pixels big. A wearer without a picture hands in its own
* size, so one pixel is one of its units.
*/
export function quadPadding(padding: number, w: number, h: number): QuadPadding | null {
if (!(padding > 0) || !(w > 0) || !(h > 0)) return null;
return { u: padding / w, v: padding / h };
}
/** The unit quad with that margin around it; 0..1 is still exactly the picture. */
export function paddedQuad(pad: QuadPadding): {
positions: Float32Array;
uvs: Float32Array;
indices: Uint32Array;
} {
const corners = [-pad.u, -pad.v, 1 + pad.u, -pad.v, 1 + pad.u, 1 + pad.v, -pad.u, 1 + pad.v];
return {
positions: new Float32Array(corners),
uvs: new Float32Array(corners),
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
};
}
export function paddingKey(pad: QuadPadding | null): string {
return pad ? `${pad.u},${pad.v}` : '';
}
+29 -4
View File
@@ -13,6 +13,7 @@ import type { PropertyBag } from '../core/model/Properties';
import { shaderOf } from '../core/properties/shaders';
import type { CompiledShader } from '../core/shader/compile';
import { TIME_UNIFORM, uniformName } from '../core/shader/nodes';
import { type QuadPadding, quadPadding } from './quadPadding';
/** Where a node's shader comes from: the compiled form of the asset the sprite names, if it compiles. */
export interface ShaderSource {
@@ -54,7 +55,9 @@ void main()
/**
* Pixi's own particle vertex stage in GLSL ES 3.00, which is what the compiled fragment is written
* in: `attribute`/`varying` become `in`/`out` and `vUV` takes the name the fragment reads. The
* rounding is kept so a particle sits on the same pixel shaded as unshaded.
* rounding is kept so a particle sits on the same pixel shaded as unshaded. `uPad` is the shader's
* padding as a fraction of the picture (#69): a particle is anchored at its centre, so its corners
* and its UVs grow about it by the same factor.
*/
const PARTICLE_VERTEX = `
in vec2 aVertex;
@@ -68,6 +71,7 @@ uniform mat3 uTranslationMatrix;
uniform float uRound;
uniform vec2 uResolution;
uniform vec4 uColor;
uniform vec2 uPad;
vec2 roundPixels(vec2 position, vec2 targetSize)
{
return (floor(((position * 0.5 + 0.5) * targetSize) + 0.5) / targetSize) * 2.0 - 1.0;
@@ -76,10 +80,12 @@ void main(void)
{
float c = cos(aRotation);
float s = sin(aRotation);
vec2 v = vec2(aVertex.x * c - aVertex.y * s, aVertex.x * s + aVertex.y * c) + aPosition;
vec2 grow = 1.0 + 2.0 * uPad;
vec2 corner = aVertex * grow;
vec2 v = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c) + aPosition;
gl_Position = vec4((uTranslationMatrix * vec3(v, 1.0)).xy, 0.0, 1.0);
if (uRound == 1.0) gl_Position.xy = roundPixels(gl_Position.xy, uResolution);
vTextureCoord = aUV;
vTextureCoord = (aUV - 0.5) * grow + 0.5;
vColor = vec4(aColor.rgb * aColor.a, aColor.a) * uColor;
}
`;
@@ -217,6 +223,8 @@ export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource
* draw call at all: five hundred shaded particles are the one call five hundred plain ones were.
*/
class ParticleGraphShader extends CompiledGraphShader {
private readonly pad: Float32Array;
constructor(assetId: string, compiled: CompiledShader) {
// `GlParticleContainerAdaptor` writes the field's texture and the pipe's own uniforms into
// whichever shader the container names, so both slots have to exist, empty, for Pixi to fill.
@@ -233,8 +241,18 @@ class ParticleGraphShader extends CompiledGraphShader {
uRound: { value: 1, type: 'f32' },
uResolution: { value: [0, 0], type: 'vec2<f32>' },
},
padding: new UniformGroup({ uPad: { value: new Float32Array(2), type: 'vec2<f32>' } }),
},
);
this.pad = (
this.resources.padding as UniformGroup<{ uPad: { value: Float32Array; type: 'vec2<f32>' } }>
).uniforms.uPad;
}
/** The margin around every particle, from the shader's padding and the picture it is counted in. */
grow(pad: QuadPadding | null): void {
this.pad[0] = pad?.u ?? 0;
this.pad[1] = pad?.v ?? 0;
}
}
@@ -242,23 +260,30 @@ function wornByField(container: ParticleContainer): ParticleGraphShader | null {
return container.shader instanceof ParticleGraphShader ? container.shader : null;
}
/** The compiled shader a source's particles are drawn with, if it names one that compiles. */
/**
* The compiled shader a source's particles are drawn with, if it names one that compiles. `picture`
* is what its padding is counted in: the particles' picture in pixels.
*/
export function syncParticleShader(
container: ParticleContainer,
props: PropertyBag,
shaders: ShaderSource,
picture: { w: number; h: number },
): void {
const id = shaderOf(props);
const compiled = id ? shaders.compiled(id) : null;
const pad = compiled ? quadPadding(compiled.padding, picture.w, picture.h) : null;
const worn = wornByField(container);
if (worn && compiled && worn.wears(id, compiled)) {
worn.read(props, shaders.now);
worn.grow(pad);
return;
}
disposeParticleShader(container);
if (compiled) {
const shader = new ParticleGraphShader(id, compiled);
shader.read(props, shaders.now);
shader.grow(pad);
container.shader = shader;
}
}
+40 -10
View File
@@ -17,6 +17,7 @@ import { closestOnSegment } from '../core/util/vec';
import { Theme } from '../ui';
import type { DrawItem } from './items';
import { sliceGeometry, sliceKey } from './nineSlice';
import { paddedQuad, paddingKey, quadPadding } from './quadPadding';
import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders';
import type { TextureLibrary } from './TextureLibrary';
@@ -73,11 +74,17 @@ export function syncSpriteMesh(
): void {
// Null while the picture is still loading: the box shows its plain colour until then, and once
// the picture is there the colour tints it.
const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE;
const texture = item.texture ? textures.get(item.texture, edge) : null;
const worn = wornShader(item.props, shaders);
const texture = item.texture ? textures.get(item.texture, worn?.edge ?? SHEET_EDGE) : null;
placeNode(mesh, item.m, size.w, size.h);
// Only once there is a picture: slicing a placeholder would cut up the plain colour for nothing.
syncGeometry(mesh, texture ? (item.texture?.slice ?? null) : null, size);
syncGeometry(
mesh,
texture ? (item.texture?.slice ?? null) : null,
size,
worn?.padding ?? 0,
texture ? { w: texture.width, h: texture.height } : size,
);
mesh.texture = texture ?? Texture.WHITE;
mesh.tint = Number(item.props.color ?? 0xffffff);
syncShader(mesh, item.props, shaders);
@@ -88,16 +95,39 @@ export function syncSpriteMesh(
* the mesh so nothing of this leaks into Pixi's own object, and so a mesh that loses its slice
* goes back to the shared quad rather than keeping a private copy of it.
*/
const sliceKeys = new WeakMap<Mesh, string>();
const geometryKeys = new WeakMap<Mesh, string>();
function syncGeometry(mesh: Mesh, slice: NineSlice | null, size: { w: number; h: number }): void {
const key = slice ? sliceKey(slice, size.w, size.h) : '';
if ((sliceKeys.get(mesh) ?? '') === key) return;
/**
* `padding` is the worn shader's, in pixels of `picture` – which without a picture is the wearer's
* own box, so a pixel is one of its units (#69).
*/
function syncGeometry(
mesh: Mesh,
slice: NineSlice | null,
size: { w: number; h: number },
padding: number,
picture: { w: number; h: number },
): void {
const pad = slice ? null : quadPadding(padding, picture.w, picture.h);
const key = slice ? `${sliceKey(slice, size.w, size.h)}|+${padding}` : paddingKey(pad);
if ((geometryKeys.get(mesh) ?? '') === key) return;
const previous = mesh.geometry;
mesh.geometry = slice ? new MeshGeometry(sliceGeometry(slice, size.w, size.h)) : UNIT_QUAD;
mesh.geometry = slice
? new MeshGeometry(sliceGeometry(slice, size.w, size.h, padding))
: pad
? new MeshGeometry(paddedQuad(pad))
: UNIT_QUAD;
if (previous !== UNIT_QUAD) previous.destroy();
if (key) sliceKeys.set(mesh, key);
else sliceKeys.delete(mesh);
if (key) geometryKeys.set(mesh, key);
else geometryKeys.delete(mesh);
}
/**
* The room a shader's Padding asks for around a plain quad: what a text and the shader tab's
* preview wear, which have no frame to slice.
*/
export function syncQuadPadding(mesh: Mesh, padding: number, w: number, h: number): void {
syncGeometry(mesh, null, { w, h }, padding, { w, h });
}
/**
+5 -2
View File
@@ -12,7 +12,7 @@ import { Theme } from '../ui';
import { fontLibrary } from './FontLibrary';
import type { DrawItem } from './items';
import { type ShaderSource, syncShader, wornShader, wornShaderId } from './shaders';
import { disposeSpriteMesh, newSpriteMesh, placeNode } from './symbols';
import { disposeSpriteMesh, newSpriteMesh, placeNode, syncQuadPadding } from './symbols';
import { ADDRESS_MODE } from './TextureLibrary';
/**
@@ -108,8 +108,11 @@ export class TextPicture {
else this.rasterSharp(text, family, size, color);
}
const texture = this.texture!;
this.sample(texture, item.fontSmooth, wornShader(item.props, shaders)?.edge ?? SHEET_EDGE);
const worn = wornShader(item.props, shaders);
this.sample(texture, item.fontSmooth, worn?.edge ?? SHEET_EDGE);
placeNode(this.mesh, item.m, this.w, this.h);
// A text's pixel is a world unit, whatever resolution the zoom had it rastered at.
syncQuadPadding(this.mesh, worn?.padding ?? 0, this.w, this.h);
this.mesh.pivot.set(textAnchor(item.props), 0.5);
this.mesh.texture = texture;
syncShader(this.mesh, item.props, shaders);
+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');
+94
View File
@@ -0,0 +1,94 @@
/**
* 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);
});
});
+84
View File
@@ -770,6 +770,90 @@ export async function shader(t) {
{ wide, wider },
);
// 28l-iii. Padding is room around the picture the sprite draws into as well (#69), counted in
// pixels of the picture: 8 of this 32-px one is a quarter of the sprite per side. The
// picture itself stays where it is, and what the margin reads is the Output's Edge.
const texelOnly = (outParams) =>
graph(
[node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput', outParams, 780, 0)],
[edge('texel', 'color', 'out', 'color')],
);
const around = async () => ({
left: await pixel(-0.3, -0.3),
right: await pixel(0.3, -0.3),
marginLeft: await pixel(-0.62, -0.3),
marginRight: await pixel(0.62, -0.3),
beyond: await pixel(0.9, -0.3),
});
await texelOnly({ edge: 'transparent' });
const bare = await around();
await texelOnly({ edge: 'transparent', padding: 8 });
const roomy = await around();
check(
'Padding with Edge Transparent leaves the picture where it is and the margin empty',
colourDistance(roomy.left, LEFT) < 12 &&
colourDistance(roomy.right, RIGHT) < 12 &&
roomy.marginLeft === bare.marginLeft &&
roomy.marginRight === bare.marginRight,
{ bare, roomy },
);
// A sprite far off screen is not drawn at all; one whose shader has padding is, because its
// margin may reach in while its rectangle is still outside.
const awayBy = (dx) =>
mutate(`__gm.state.activeScene.objects.find((o) => o.id === 'oshade').position.x += ${dx};`);
await awayBy(100000);
const farPadded = await json('__gm.shaderCount()');
await texelOnly({ edge: 'transparent' });
const farPlain = await json('__gm.shaderCount()');
await awayBy(-100000);
check(
'off screen a shaded sprite is left out, a padded one is still drawn for its margin',
farPadded === farPlain + 1,
{ farPadded, farPlain },
);
const shiftedRight = async (padding) => {
await sampleAt(
[node('shift', 'ShaderAdd', { b: -0.25 }, 260, 0)],
// The sample's own opacity: in the margin the pixel's own is none, with Edge Transparent.
[
edge('uv', 'u', 'shift', 'a'),
edge('shift', 'result', 'sample', 'u'),
edge('sample', 'alpha', 'out', 'alpha'),
],
{ edge: 'transparent', padding },
);
return around();
};
const clipped = await shiftedRight(0);
const drawn = await shiftedRight(8);
check(
'a picture shifted right is cut at the sprite’s rectangle without padding, and drawn past it with',
clipped.marginRight === bare.marginRight &&
colourDistance(drawn.marginRight, RIGHT) < 12 &&
drawn.marginLeft === bare.marginLeft &&
drawn.beyond === bare.beyond,
{ bare, clipped, drawn },
);
await texelOnly({ padding: 8 });
const smeared = await around();
await showTab('shader');
await clickText('Fit');
await sleep(300);
const hint = await json("__gm.shader.nodeProblem('out')");
const readout = await info();
await shot('28-shader-padding-hint');
await showTab('scene');
check(
'with Edge Clamp the border pixel smears into the margin, and the Output says so while the shader still compiles',
colourDistance(smeared.marginLeft, LEFT) < 12 &&
colourDistance(smeared.marginRight, RIGHT) < 12 &&
smeared.beyond === bare.beyond &&
hint === 'Padding with Clamp smears the border' &&
readout !== hint,
{ smeared, hint, readout },
);
await shot('28-shader-padding-scene');
// 28l-ii. The sprite wears a picture and a shader: both open from the object's menu, from the
// asset's menu and from the inspector, whose Texture and Shader labels are links (#221).
const shadedProps = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props");
+43
View File
@@ -284,6 +284,49 @@ export async function font(t) {
JSON.stringify({ longer, redLonger }),
);
// 30i-ii. A shader's Padding gives a text room around its rectangle (#69), in the text's own
// units. The graph fills the whole quad here, so the margin shows as red left of the text.
const shaderGraph = `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(RED_SHADER.id)}).graph`;
const filled = (padding) =>
mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full');
graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2');
graph.nodes.push(${JSON.stringify(node('ft-red-full', 'ShaderNumber', 0, 160, { value: 1 }))});
graph.edges.push(${JSON.stringify(edge('ft-red-e2', 'ft-red-full', 'value', 'ft-red-out', 'alpha'))});
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'transparent', padding: ${padding} });`);
// The red run on the row through the text: where the filled quad begins and ends on screen.
const redSpan = async () => {
const r = await json(`__gm.objectRect(${JSON.stringify(TEXT_OBJECT)})`);
const from = Math.max(0, Math.round(r.x) - 600);
const row = await json(`__gm.pixelRow(${from}, ${Math.round(r.y)}, 1200)`);
const red = row.map(reddish);
return {
first: from + red.indexOf(true),
last: from + red.lastIndexOf(true),
unit: r.w / (await info()).width,
};
};
await evaluate('__gm.state.select(null)');
await filled(0);
const textTight = await redSpan();
await filled(6);
const textRoomy = await redSpan();
await shot('30-font-text-padding');
const reach = 6 * textTight.unit;
check(
'a padded shader draws six units past the text’s rectangle on either side, and none further',
textTight.last > textTight.first &&
Math.abs(textTight.first - textRoomy.first - reach) <= 2 &&
Math.abs(textRoomy.last - textTight.last - reach) <= 2,
JSON.stringify({ textTight, textRoomy, reach }),
);
await mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full');
graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2');
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'clamp', padding: 0 });`);
// 30j. The game draws a text through the same picture, shader included. In front of everything:
// in the chained run, earlier steps leave pictures of their own at the camera.
await place('{ x: cam.x, y: cam.y, z: 1000 }');
+24
View File
@@ -608,6 +608,30 @@ export async function particles(t) {
JSON.stringify({ dimmed }),
);
// 31t-ii. (#69) The shader's Padding grows every particle about its own centre. Without a
// picture a pixel is a unit of the particle's size, so 10 on a size of 40 is a quarter
// per side. Counted along the row through the source, in the grey from 31t.
const greyAcross = async () => {
await at(0.1);
const row = await json(`__gm.pixelRow(${Math.round(spotOn.x) - 240}, ${Math.round(spotOn.y)}, 480)`);
return row.filter((hex) => colourDistance(hex, '#404040') < 16).length;
};
const setPadding = (padding) =>
mutate(`
const shader = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(SHADER)});
shader.graph.nodes.find((n) => n.id === 'lv-out').params.padding = ${padding};`);
const plainRun = await greyAcross();
await setPadding(10);
const paddedRun = await greyAcross();
await shot('31-particles-padded');
await setPadding(0);
const backRun = await greyAcross();
check(
'a shader’s padding grows every particle by a quarter of its size per side, and 0 takes it back',
plainRun > 0 && paddedRun > plainRun + 8 && paddedRun <= plainRun * 1.5 + 4 && backRun === plainRun,
JSON.stringify({ plainRun, paddedRun, backRun }),
);
// 31u. (#88) A source draws rows from two graphs, so one name can arrive twice. The source's own
// row wins and the uniform keeps its default – two rows called Rate would be one row with two
// meanings, which is #63 in another coat.
+43
View File
@@ -281,6 +281,49 @@ export async function nineSlice(t) {
await select({ kind: 'asset', assetId: texture.id });
check("the scale is a row of the texture's inspector", await has('Nine-slice scale'));
// 37g. A shader's Padding on a sliced sprite (#69): a ring around the nine parts, as wide as that
// many border pixels. With Edge Clamp the frame's red rim goes on through it, so the ring
// shows as red left of the box – and the border inside keeps its 8 pixels.
await mutate(`
__gm.state.project.assets.push({
id: 'ns-shader', type: 'shader', name: 'Rim', props: {},
graph: { nodes: [
{ id: 'ns-texel', type: 'ShaderTextureColor', x: 0, y: 0, params: {} },
{ id: 'ns-out', type: 'ShaderOutput', x: 260, y: 0, params: { edge: 'clamp', padding: 0 } },
], edges: [
{ id: 'ns-e1', from: { node: 'ns-texel', port: 'color' }, to: { node: 'ns-out', port: 'color' } },
] },
});
__gm.state.project.assets.find((a) => a.id === 'ns-sprite').props.shader = 'ns-shader';
__gm.state.select(null)`);
const rim = async (padding) => {
await mutate(`
__gm.state.project.assets.find((a) => a.id === 'ns-shader')
.graph.nodes.find((n) => n.id === 'ns-out').params.padding = ${padding};`);
await sleep(400);
// From twelve units left of the box to twelve units into it.
const reach = Math.round(12 * scale);
const row = await json(`__gm.pixelRow(${Math.round(box.x - box.w / 2) - reach}, ${rowY}, ${2 * reach})`);
const outside = row.slice(0, reach).filter((c) => colourDistance(c, RED) < 60).length;
const inside = row.slice(reach).filter((c) => colourDistance(c, RED) < 60).length;
return { outside, inside };
};
const tight = await rim(0);
const ringed = await rim(4);
await shot('41-nine-slice-padding');
check(
'a shader’s padding puts a ring of four border pixels around a sliced sprite and leaves its parts alone',
tight.outside === 0 &&
Math.abs(ringed.outside - 4 * scale) <= 2 &&
Math.abs(tight.inside - 8 * scale) <= 2 &&
Math.abs(ringed.inside - 8 * scale) <= 2,
JSON.stringify({ tight, ringed, expected: [4 * scale, 8 * scale] }),
);
await mutate(`
const project = __gm.state.project;
delete project.assets.find((a) => a.id === 'ns-sprite').props.shader;
project.assets = project.assets.filter((a) => a.id !== 'ns-shader');`);
// The scene back as it was, without the sprite this step brought: later readers count what is in it.
await mutate(`
__gm.state.activeScene.objects = window.__nsOthers ?? [];