Let Texture size feed the Vertex output to scale about the middle (#409)
game-maker / gate (push) Successful in 4m16s
game-maker / suite (push) Successful in 17m10s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 18:35:20 +02:00
1 parent b9fcf48097
commit 8118b8042b
7 files changed
+82 -10

No files matched your search

+1 -1
View File
@@ -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
+7 -2
View File
@@ -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<string, Expr> = {};
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 };
+10 -2
View File
@@ -33,6 +33,8 @@ export interface ShaderNodeDef extends NodeDef {
unfed?: Record<string, Expr>;
/** 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<string, Expr>) => Record<string, Expr>;
/** A vec4 all outputs read, worked out once into a local `emit` gets as `prepared` (#300). */
prepare?: (inputs: Record<string, Expr>, 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 = {
+13 -4
View File
@@ -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<f32>' } })
? new UniformGroup({
uPerPixel: { value: new Float32Array(2), type: 'vec2<f32>' },
[CORNER_SIZE_UNIFORM]: { value: new Float32Array(2), type: 'vec2<f32>' },
})
: 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. */
+8
View File
@@ -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', () => {
+23 -1
View File
@@ -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');
+20
View File
@@ -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(
[