From e0c459ce9213cda366986fdcf10b833ef97b104c Mon Sep 17 00:00:00 2001 From: meleki Date: Sun, 13 Sep 2026 21:33:41 +0200 Subject: [PATCH] Shader graph: Clamp is the Output's default edge, and the sheet's own mode is a constant of its own (#81) The default of the Edge param and the address mode the shared sheet physically carries were the same constant; changing the value would have turned every sentinel with it and given each shader sprite a full copy of its texture. SHEET_EDGE ('mirror', what TextureLibrary uploads) is now separate from DEFAULT_EDGE ('clamp'). A graph whose Sample reads nothing but its own pixel draws with the plain sheet whatever the Edge says, so the common shader costs no second copy. Without a shader the quad's coordinates never leave 0..1, and mirror-repeat reads like clamp within a texel - so clamp is what a sprite shows anyway. --- README.md | 2 +- src/core/shader/compile.ts | 18 ++++++++++++++++-- src/core/shader/nodes.ts | 14 ++++++++++---- src/viewport/TextureLibrary.ts | 8 ++++---- src/viewport/symbols.ts | 4 ++-- tests/core/shaders.test.ts | 30 +++++++++++++++++++++++------- tools/e2e/28-shader.mjs | 18 ++++++++++++++++++ 7 files changed, 74 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d643973e..09541292 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,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, 784 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 785 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index f7a4791e..60a03dc4 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -10,7 +10,9 @@ import { floatLiteral, isShaderNodeDef, SHADER_OUTPUT, + SHADER_SAMPLE, SHADER_TIME, + SHEET_EDGE, STRAIGHT_FN, TEXEL_ALPHA, TEXEL_COLOR, @@ -29,7 +31,10 @@ export interface CompiledShader { uniforms: ShaderUniform[]; /** A Time node feeds the Output: the fragment takes `uTime`, and a sprite wearing it redraws every tick. */ time: boolean; - /** How the picture continues past its border – the texture variant the sprite's mesh draws with. */ + /** + * The texture variant the sprite's mesh draws with – the Output's Edge where the graph samples + * past the border, and the plain sheet where it cannot. + */ edge: EdgeMode; } @@ -177,5 +182,14 @@ export function compileShader(graph: Graph): ShaderBuild { '}', '', ].join('\n'); - return { compiled: { fragment, uniforms, time, edge: edgeOf(output.params) }, problems, reason: '' }; + // 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. + const shifted = graph.nodes.some( + (n) => + n.type === SHADER_SAMPLE && + emitted.has(n.id) && + (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: '' }; } diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index 1b2d3d82..e8447ffd 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -56,16 +56,22 @@ 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. Mirror is what every picture has - * anyway (`TextureLibrary`); repeat is for tiles. + * of the Output node, because the sampler state is one per draw. */ export type EdgeMode = 'repeat' | 'mirror' | 'clamp'; export const EDGE_PARAM = 'edge'; -export const DEFAULT_EDGE: EdgeMode = 'mirror'; +/** + * 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, + * so it is what a sprite without a shader draws with. + */ +export const SHEET_EDGE: EdgeMode = 'mirror'; +/** Clamp: the border texel goes on, which is what a sprite without a shader shows (#81). */ +export const DEFAULT_EDGE: EdgeMode = 'clamp'; const EDGE_OPTIONS: { value: EdgeMode; label: string }[] = [ + { value: 'clamp', label: 'Clamp' }, { value: 'mirror', label: 'Mirror' }, { value: 'repeat', label: 'Repeat' }, - { value: 'clamp', label: 'Clamp' }, ]; export function edgeOf(params: PropertyBag): EdgeMode { diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index 9255358d..afa31dad 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -1,6 +1,6 @@ import { Assets, CanvasSource, Texture } from 'pixi.js'; import type { FrameRect, TextureRef } from '../core/model/texture'; -import { DEFAULT_EDGE, type EdgeMode } from '../core/shader/nodes'; +import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes'; const DEFAULT_BASE = '/game-maker/data/textures'; @@ -47,10 +47,10 @@ export class TextureLibrary { * shared source, so two assets naming the same file with different `smooth` fight over it and * the last one drawn wins; upload avoids that by reusing the existing asset for a known file. */ - get(ref: TextureRef, edge: EdgeMode = DEFAULT_EDGE): Texture | null { + get(ref: TextureRef, edge: EdgeMode = SHEET_EDGE): Texture | null { const sheet = this.load(ref.file); if (!sheet) return null; - const rect = ref.rect ?? (edge === DEFAULT_EDGE ? null : wholeOf(sheet)); + const rect = ref.rect ?? (edge === SHEET_EDGE ? null : wholeOf(sheet)); const texture = rect ? this.crop(ref.file, sheet, rect, edge) : sheet; if (!texture) return null; const mode = ref.smooth ? 'linear' : 'nearest'; @@ -96,7 +96,7 @@ export class TextureLibrary { */ private crop(file: string, sheet: Texture, rect: FrameRect, edge: EdgeMode): Texture | null { if (rect.w <= 0 || rect.h <= 0) return null; - const key = edge === DEFAULT_EDGE ? cropKey(file, rect) : `${cropKey(file, rect)}|${edge}`; + const key = edge === SHEET_EDGE ? cropKey(file, rect) : `${cropKey(file, rect)}|${edge}`; let texture = this.crops.get(key); if (!texture) { const canvas = new OffscreenCanvas(rect.w, rect.h); diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 999702d8..e4fde9d0 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -3,7 +3,7 @@ import { type Container, type Graphics, Mesh, MeshGeometry, Rectangle, Texture } import { determinant, IDENTITY, type Mat } from '../core/model/matrix'; import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, placementOf, textAnchor } from '../core/properties/bounds'; import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; -import { DEFAULT_EDGE } from '../core/shader/nodes'; +import { SHEET_EDGE } from '../core/shader/nodes'; import { Theme } from '../ui'; import type { DrawItem } from './items'; import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders'; @@ -56,7 +56,7 @@ 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 ?? DEFAULT_EDGE; + const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE; const texture = item.texture ? textures.get(item.texture, edge) : null; placeNode(mesh, item.m, size.w, size.h); mesh.texture = texture ?? Texture.WHITE; diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index f3214687..14033ebf 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -36,6 +36,8 @@ import { import { inlineInputs } from '../../src/core/graph/NodeRegistry'; import { compileShader, shaderFingerprint } from '../../src/core/shader/compile'; import { + DEFAULT_EDGE, + EDGE_PARAM, SHADER_NODES, SHADER_NUMBER, SHADER_OUTPUT, @@ -47,6 +49,7 @@ import { SHADER_UV, ShaderOutput, ShaderSample, + SHEET_EDGE, } from '../../src/core/shader/nodes'; import { deserializeProject } from '../../src/core/store/serialize'; import { paletteEntries } from '../../src/editor/graph/paletteEntries'; @@ -414,16 +417,29 @@ describe('compileShader', () => { const s = shaderEditor(f, 'Idle'); s.add(SHADER_TIME); const out = s.add(SHADER_OUTPUT); - let compiled = compileShader(s.asset().graph).compiled!; - expect(compiled).toMatchObject({ time: false, edge: 'mirror' }); - expect(compiled.fragment).not.toContain('uTime'); const params = s.asset().graph.nodes.find((n) => n.id === out)!.params; - params.edge = 'clamp'; - expect(compileShader(s.asset().graph).compiled!.edge).toBe('clamp'); + // A new Output carries the default, so a graph never silently changes when the default does. + expect(params[EDGE_PARAM]).toBe('clamp'); + let compiled = compileShader(s.asset().graph).compiled!; + expect(compiled).toMatchObject({ time: false, edge: SHEET_EDGE }); + expect(compiled.fragment).not.toContain('uTime'); + // Nothing reads past the border here, so no mode can show – and none costs a second picture. + params[EDGE_PARAM] = 'repeat'; + expect(compileShader(s.asset().graph).compiled!.edge).toBe(SHEET_EDGE); + 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'); + expect(compileShader(s.asset().graph).compiled!.edge).toBe('repeat'); // A value the dropdown never offers reads as the default rather than as a mode. - params.edge = 'sideways'; + params[EDGE_PARAM] = 'sideways'; compiled = compileShader(s.asset().graph).compiled!; - expect(compiled.edge).toBe('mirror'); + expect(compiled.edge).toBe(DEFAULT_EDGE); + // A graph from before the setting existed has no value at all and takes the default too (#81). + delete params[EDGE_PARAM]; + expect(compileShader(s.asset().graph).compiled!.edge).toBe('clamp'); }); it('writes Fract, Step and Floor as the GLSL calls, per channel for a colour', () => { diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 8a2b2edb..e6ccd0d3 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -614,6 +614,24 @@ export async function shader(t) { colourDistance(mirror.left, RIGHT) < 12 && colourDistance(mirror.right, RIGHT) < 12, mirror, ); + // u + 0.5 cannot tell Clamp from Mirror: both hold green past the border. u + 0.8 puts the near + // half on 1.6, where Mirror is already back in the far half and Clamp still holds the last texel. + const scrolledFar = async (outParams) => { + await sampleAt( + [node('far', 'ShaderAdd', { b: 0.8 }, 260, 0)], + [edge('uv', 'u', 'far', 'a'), edge('far', 'result', 'sample', 'u')], + outParams, + ); + await sleep(400); + return pixel(0.3, -0.3); + }; + const byDefault = await scrolledFar({}); + const byMirror = await scrolledFar({ edge: 'mirror' }); + check( + 'with nothing set on the Output the edge is Clamp: at u + 0.8 the border texel goes on, where Mirror shows the near half again', + colourDistance(byDefault, RIGHT) < 12 && colourDistance(byMirror, LEFT) < 12, + { byDefault, byMirror }, + ); await evaluate("__gm.showTab('shader')"); await sleep(300); await shot('28-shader-scroll-graph');