From 8118b8042bb33887a84f900c356b36d04719489c Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 11 Oct 2026 18:35:20 +0200 Subject: [PATCH] Let Texture size feed the Vertex output to scale about the middle (#409) A scale about the picture's middle moves each corner by its distance to the middle times (scale - 1). In pixels that distance needs the picture's size, and Texture size was locked at the Vertex output as a per-pixel node. So the size had to be typed in by hand and was only right for pictures of exactly that size. - At a corner Texture size reads the uniform uPictureSize. It holds the size the Vertex output counts its pixels in, the same number the node gives in the fragment. The offset is divided by that size again, so a scale comes out right for every picture and sprite size. - A node can say what it is at a corner through emitPerCorner. The walker takes it there and emit everywhere else. - Fed a Pass's picture, Texture size stays locked at the Vertex output. A Pass has no picture at a corner. - 9-slice, field and camera shaders do not move corners. Their size is 0 there and nothing changes for them. Tests: - Unit shader-vertex: the node compiles at a corner to uPictureSize and the Output still reads textureSize. shader-passes: a Pass's picture into Texture size at the Vertex output is refused. - Against the old perPixel flag the new unit test fails. - e2e 28l-iv: width * 0.5 at x moves the 32 px picture as far as the constant 16 did, same three colours. With the size forced to 0 on the GPU the check fails and the picture stands still. - The case "Texture size is refused at a corner" left the it.each that lists refused nodes, so the test count stays the same. - e2e 28 on melektaus-atx: ALL PASSED, 101 checks. - Gate on melektaus-atx: CHECK EXIT 0, 297 files, 4562 tests. - tsc, eslint, Prettier clean. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/shader/compile.ts | 9 +++++++-- src/core/shader/nodes.ts | 12 ++++++++++-- src/viewport/shaders.ts | 17 +++++++++++++---- tests/core/shader-passes.test.ts | 8 ++++++++ tests/core/shader-vertex.test.ts | 24 +++++++++++++++++++++++- tools/e2e/28-shader.mjs | 20 ++++++++++++++++++++ 7 files changed, 82 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7a5101ff..2ca1bb66 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,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, 2008 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2009 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 diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 7a9e0c19..6fa651f5 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -64,7 +64,10 @@ const glslOf = (u: ShaderUniform) => (u.kind === 'color' ? 'vec3' : 'float'); * `vTextureCoord` and the same uniforms the fragment does. */ export interface ShaderVertex { - /** Declarations ahead of `main`: the uniforms, at the fragment's precision, and the helpers. */ + /** + * Declarations ahead of `main`: the uniforms, at the fragment's precision, and the helpers. The + * picture's size, `CORNER_SIZE_UNIFORM`, is the wearer's to declare. + */ head: string; /** Statements inside `main`, ending in `vec2 offset`: the corner's move in pixels of the picture. */ body: string; @@ -384,8 +387,10 @@ function walker( prepared = `${prefix}${count++}_prepared`; lines.push(`vec4 ${prepared} = ${def.prepare(inputs, node.params)};`); } + const emitted = + perPixel && def.emitPerCorner ? def.emitPerCorner(inputs) : def.emit(inputs, node.params, prepared); const outputs: Record = {}; - for (const [key, expr] of Object.entries(def.emit(inputs, node.params, prepared))) { + for (const [key, expr] of Object.entries(emitted)) { const local = `${prefix}${count++}_${key}`; lines.push(`${expr.type} ${local} = ${expr.code};`); outputs[key] = { code: local, type: expr.type }; diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index ddb871b6..f96d25ca 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -33,6 +33,8 @@ export interface ShaderNodeDef extends NodeDef { unfed?: Record; /** Reads the picture or is a colour: there is none per corner, so it cannot feed the Vertex output (#79). */ perPixel?: boolean; + /** What the node is at a corner where that differs from `emit`, the picture's size for one (#409). */ + emitPerCorner?: (inputs: Record) => Record; /** A vec4 all outputs read, worked out once into a local `emit` gets as `prepared` (#300). */ prepare?: (inputs: Record, params: PropertyBag) => string; } @@ -87,6 +89,8 @@ const sampleOf = (picture: Expr, uv: string) => `${TEXEL_AT}(${picture.code}, ${ export const PIXEL_SCALE_UNIFORM = 'uPixelScale'; /** `picture`'s size in the pixels a node counts. */ const countedSize = (picture: Expr) => `(vec2(textureSize(${picture.code}, 0)) * ${PIXEL_SCALE_UNIFORM})`; +/** The picture a Vertex output counts its pixels in, in the vertex stage (#409); zero where no corner moves. */ +export const CORNER_SIZE_UNIFORM = 'uPictureSize'; /** The input a node reads a picture from; unconnected, the wearer's own. */ const pictureIn: PortDef = { key: 'picture', label: 'picture', type: 'picture', inline: false }; @@ -504,16 +508,20 @@ export const ShaderTextureSize: ShaderNodeDef = { label: 'Texture size', category: 'shader', description: - "The sprite's picture in pixels, for effects that need to know where its pixels are. Fed a Pass's picture, that one's, Padding included. In a field shader a pixel is a unit of the frame, whatever the zoom.", + "The sprite's picture in pixels, for effects that need to know where its pixels are. Fed a Pass's picture, that one's, Padding included. In a field shader a pixel is a unit of the frame, whatever the zoom. It can feed the Vertex output too, e.g. to scale the picture about its middle.", inputs: [pictureIn], outputs: [numberOut('width'), numberOut('height')], params: [], - perPixel: true, unfed: { picture: ORIGINAL }, emit: ({ picture }) => ({ width: { code: `${countedSize(picture!)}.x`, type: 'float' }, height: { code: `${countedSize(picture!)}.y`, type: 'float' }, }), + // A corner only ever sees the wearer's own picture: a Pass has none per corner. + emitPerCorner: () => ({ + width: { code: `${CORNER_SIZE_UNIFORM}.x`, type: 'float' }, + height: { code: `${CORNER_SIZE_UNIFORM}.y`, type: 'float' }, + }), }; export const ShaderTime: ShaderNodeDef = { diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index 3a203077..8b011c13 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -12,7 +12,7 @@ import { import { colorOf, type PropertyBag } from '../core/model/Properties'; import { shaderOf } from '../core/properties/shaders'; import type { CompiledShader, ShaderVertex } from '../core/shader/compile'; -import { PIXEL_SCALE_UNIFORM, TIME_UNIFORM, uniformName } from '../core/shader/nodes'; +import { CORNER_SIZE_UNIFORM, PIXEL_SCALE_UNIFORM, TIME_UNIFORM, uniformName } from '../core/shader/nodes'; import { type QuadPadding, quadPadding } from './quadPadding'; import { PassPictures } from './shaderPasses'; @@ -56,7 +56,8 @@ void main() /** * The same stage with a graph's Vertex output in it (#79): the corner's UV is known before the * graph runs, and what it says in pixels of the picture moves the corner in the quad's own 0..1. - * `uPerPixel` is one such pixel there – zero on a wearer whose corners are not a grid. + * `uPerPixel` is one such pixel there – zero on a wearer whose corners are not a grid – and the + * picture's size is what a Texture size reads at the corner (#409). */ function cornerVertex(vertex: ShaderVertex): string { return ` @@ -70,6 +71,7 @@ uniform vec4 uWorldColorAlpha; uniform mat3 uTransformMatrix; uniform vec4 uColor; uniform vec2 uPerPixel; +uniform vec2 ${CORNER_SIZE_UNIFORM}; ${vertex.head} void main() { @@ -263,10 +265,14 @@ class GraphShader extends CompiledGraphShader { /** What `Mesh.shader` asks of a shader; kept in step with the mesh's own by `wear`. */ texture: Texture = Texture.WHITE; private readonly perPixel: Float32Array | null; + private readonly size: Float32Array | null; constructor(assetId: string, compiled: CompiledShader) { const corner = compiled.vertex - ? new UniformGroup({ uPerPixel: { value: new Float32Array(2), type: 'vec2' } }) + ? new UniformGroup({ + uPerPixel: { value: new Float32Array(2), type: 'vec2' }, + [CORNER_SIZE_UNIFORM]: { value: new Float32Array(2), type: 'vec2' }, + }) : null; super( assetId, @@ -279,6 +285,7 @@ class GraphShader extends CompiledGraphShader { }, ); this.perPixel = corner ? corner.uniforms.uPerPixel : null; + this.size = corner ? corner.uniforms[CORNER_SIZE_UNIFORM] : null; } protected override vertexFor(compiled: CompiledShader): string { @@ -287,10 +294,12 @@ class GraphShader extends CompiledGraphShader { /** The picture the Vertex output's pixels are counted in; null or a fixed one holds every corner in place. */ count(picture: WornPicture | null): void { - if (!this.perPixel) return; + if (!this.perPixel || !this.size) return; const counted = picture && !picture.fixed ? picture : null; this.perPixel[0] = counted && counted.w > 0 ? 1 / counted.w : 0; this.perPixel[1] = counted && counted.h > 0 ? 1 / counted.h : 0; + this.size[0] = counted?.w ?? 0; + this.size[1] = counted?.h ?? 0; } /** Pixi binds a mesh's texture only for its own shader, so a custom one takes it here. */ diff --git a/tests/core/shader-passes.test.ts b/tests/core/shader-passes.test.ts index 309734fe..fd5a56c5 100644 --- a/tests/core/shader-passes.test.ts +++ b/tests/core/shader-passes.test.ts @@ -185,6 +185,14 @@ describe('Pass (#301)', () => { s.link(pass, 'picture', alpha, 'picture'); s.link(alpha, 'value', corner, 'x'); expect(s.build().problems[corner]).toBe(perCornerProblem('Pass')); + // Not even its size, though a Texture size reads the wearer's own there (#409). + const sized = shader(); + const sizedPass = sized.add(SHADER_PASS); + const size = sized.add(SHADER_TEXTURE_SIZE); + const sizedCorner = sized.add(SHADER_VERTEX_OUTPUT); + sized.link(sizedPass, 'picture', size, 'picture'); + sized.link(size, 'width', sizedCorner, 'x'); + expect(sized.build().problems[sizedCorner]).toBe(perCornerProblem('Pass')); }); it('sizes its picture as the wearer’s with the margin, at its resolution, within limits', () => { diff --git a/tests/core/shader-vertex.test.ts b/tests/core/shader-vertex.test.ts index cd5f6717..09d1b20f 100644 --- a/tests/core/shader-vertex.test.ts +++ b/tests/core/shader-vertex.test.ts @@ -109,7 +109,6 @@ describe('Vertex output (#79)', () => { it.each([ [SHADER_TEXTURE_ALPHA, 'value', 'Texture alpha'], [SHADER_SAMPLE, 'alpha', 'Sample texture'], - [SHADER_TEXTURE_SIZE, 'width', 'Texture size'], ])('says at the node that %s is not there per corner', (type, port, label) => { const s = shader(); s.link(s.add(type), port, s.corner, 'x'); @@ -134,6 +133,29 @@ describe('Vertex output (#79)', () => { expect(unfed.build().problems).toEqual({ [unfed.corner]: perCornerProblem('the picture') }); }); + it('reads the picture’s size at a corner, the one its pixels are counted in (#409)', () => { + // A scale of 1.2 about the middle: x = (u - 0.5) * width * 0.2. + const s = shader(); + const uv = s.add(SHADER_UV); + const size = s.add(SHADER_TEXTURE_SIZE); + const centred = s.add('ShaderSubtract', { b: 0.5 }); + const wide = s.add('ShaderMultiply'); + const grown = s.add('ShaderMultiply', { b: 0.2 }); + s.link(uv, 'u', centred, 'a'); + s.link(centred, 'result', wide, 'a'); + s.link(size, 'width', wide, 'b'); + s.link(wide, 'result', grown, 'a'); + s.link(grown, 'result', s.corner, 'x'); + s.link(size, 'height', s.out, 'alpha'); + const build = s.build(); + expect(build.problems).toEqual({}); + const { vertex, fragment } = build.compiled!; + expect(vertex!.body).toMatch(/float c\d+_width = uPictureSize\.x;/); + expect(vertex!.body).not.toContain('textureSize'); + // The Output still reads the texture itself. + expect(fragment).toMatch(/float n\d+_height = \(vec2\(textureSize\(uTexture, 0\)\) \* uPixelScale\)\.y;/); + }); + it('still lets the same nodes feed the Output', () => { const s = shader(); s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', s.out, 'alpha'); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 652994b4..3984b020 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -893,6 +893,26 @@ export async function shader(t) { colourDistance(bare.left, LEFT) < 12, { moved, bare }, ); + // The picture's size reaches the corner (#409): half its width is the 16 from before. + await cornered( + [node('size', 'ShaderTextureSize', {}, 0, 260), node('half', 'ShaderMultiply', { b: 0.5 }, 260, 260)], + [edge('size', 'width', 'half', 'a'), edge('half', 'result', 'vout', 'x')], + 8, + ); + const sized = { + left: await pixel(-0.3, -0.3), + right: await pixel(0.3, -0.3), + past: await pixel(0.8, -0.3), + problem: await json("__gm.shader.nodeProblem('vout')"), + }; + check( + 'half of a Texture size at the Vertex output moves the picture as far as 16 does', + colourDistance(sized.left, LEFT) > 60 && + colourDistance(sized.right, LEFT) < 12 && + colourDistance(sized.past, RIGHT) < 12 && + !sized.problem, + { sized }, + ); const lowerHalf = async (subdivisions) => { await cornered( [