diff --git a/README.md b/README.md index ee367605..6f0feccf 100644 --- a/README.md +++ b/README.md @@ -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, 1695 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1699 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 c3b05832..ee52094d 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -1,5 +1,5 @@ import { graphEmitter } from '../graph/emitGraph'; -import { edgesInto, type Graph } from '../graph/Graph'; +import { edgesInto, type Graph, type GraphNode } from '../graph/Graph'; import { nodeDef } from '../graph/NodeRegistry'; import { colorOf } from '../model/Properties'; import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema'; @@ -16,9 +16,11 @@ import { SHADER_OUTPUT, SHADER_SAMPLE, SHADER_TIME, + SHADER_VERTEX_OUTPUT, type ShaderNodeDef, SHEET_EDGE, STRAIGHT_FN, + subdivisionsOf, TEXEL_ALPHA, TEXEL_AT, TEXEL_COLOR, @@ -33,10 +35,25 @@ export interface ShaderUniform { default: number; } +/** + * What a Vertex output node adds to the wearer's vertex stage (#79). It reads the corner's + * `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. */ + head: string; + /** Statements inside `main`, ending in `vec2 offset`: the corner's move in pixels of the picture. */ + body: string; + /** Cells per axis of the wearer's grid; 1 is the plain quad. */ + subdivisions: number; +} + export interface CompiledShader { fragment: string; + /** Null without a Vertex output node: the wearer then draws on the quad every sprite shares. */ + vertex: ShaderVertex | null; uniforms: ShaderUniform[]; - /** A Time node feeds the Output: the fragment takes `uTime`, and a sprite wearing it redraws every tick. */ + /** A Time node feeds an output: the program takes `uTime`, and a sprite wearing it redraws every tick. */ time: boolean; /** * The texture variant the sprite's mesh draws with – the Output's Edge where the graph samples @@ -59,6 +76,7 @@ export interface ShaderBuild { } export const PADDING_CLAMP_HINT = 'Padding with Clamp smears the border'; +export const perCornerProblem = (what: string) => `Not available per corner: ${what}`; /** * A shader graph to the fragment of the sprite's own draw: every node reachable from the Output @@ -67,38 +85,9 @@ export const PADDING_CLAMP_HINT = 'Padding with Clamp smears the border'; */ export function compileShader(graph: Graph): ShaderBuild { const lines: string[] = []; - let count = 0; - - const walk = graphEmitter(graph, { - isDef: isShaderNodeDef, - noun: 'shader', - emit: (def, inputs, node) => { - const outputs: Record = {}; - for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) { - const local = `n${count++}_${key}`; - lines.push(`${expr.type} ${local} = ${expr.code};`); - outputs[key] = { code: local, type: expr.type }; - } - return outputs; - }, - // With nothing to type and nothing the node says, an input is the texel: its colour for a - // colour port, its alpha for a number. - unfed: (node, port, def) => { - const typed = node.params[port.key] ?? port.default; - if (port.field?.kind === 'color') - return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' }; - if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' }; - return def?.unfed?.[port.key] ?? (port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR); - }, - conform: (node, port, expr) => { - if (port.type === 'number' && expr.type === 'vec3') { - walk.problem(node, `"${port.key}" needs a number, not a color`); - return null; - } - if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' }; - return expr; - }, - }); + /** What the vertex walk met that only a pixel has; the Vertex output node reports the first. */ + const perPixel: string[] = []; + const walk = walker(graph, lines, 'n', null); const { problems } = walk; walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) => @@ -119,12 +108,40 @@ export function compileShader(graph: Graph): ShaderBuild { 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) + const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default })); + + // The per-corner part is a walk of its own from the Vertex output: a node feeding both outputs is + // emitted once for each stage. + const corner = graph.nodes.find((n) => n.type === SHADER_VERTEX_OUTPUT); + const cornerLines: string[] = []; + const cornerWalk = walker(graph, cornerLines, 'c', perPixel); + let vertex: ShaderVertex | null = null; + if (corner) { + const cornerDef = nodeDef(SHADER_VERTEX_OUTPUT)!; + const x = cornerWalk.input(corner, cornerDef.inputs[0]!); + const y = cornerWalk.input(corner, cornerDef.inputs[1]!); + if (perPixel.length > 0) cornerWalk.problem(corner, perCornerProblem(perPixel[0]!)); + for (const [id, text] of Object.entries(cornerWalk.problems)) if (!(id in problems)) problems[id] = text; + const reachesTime = graph.nodes.some((n) => n.type === SHADER_TIME && cornerWalk.reached(n)); + if (x && y) + vertex = { + // A uniform both stages declare has to have one precision; the fragment's default is mediump. + head: [ + ...(reachesTime ? [`uniform mediump float ${TIME_UNIFORM};`] : []), + ...uniforms.map((u) => `uniform mediump float ${uniformName(u.name)};`), + DIVIDE_FN, + ].join('\n'), + body: [...cornerLines, `vec2 offset = vec2(${x.code}, ${y.code});`].join('\n'), + subdivisions: subdivisionsOf(corner.params), + }; + } + + const failed = firstProblem(graph, problems); + if (failed !== null || !color || !alpha || (corner && !vertex)) 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)); + const time = graph.nodes.some((n) => n.type === SHADER_TIME && (walk.reached(n) || cornerWalk.reached(n))); + const fragmentTime = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n)); // The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the // texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader. // The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for @@ -135,7 +152,7 @@ export function compileShader(graph: Graph): ShaderBuild { 'in vec4 vColor;', 'out vec4 finalColor;', 'uniform sampler2D uTexture;', - ...(time ? [`uniform float ${TIME_UNIFORM};`] : []), + ...(fragmentTime ? [`uniform float ${TIME_UNIFORM};`] : []), ...uniforms.map((u) => `uniform float ${uniformName(u.name)};`), STRAIGHT_FN, DIVIDE_FN, @@ -161,5 +178,53 @@ export function compileShader(graph: Graph): ShaderBuild { (edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0), ); const edge = (shifted || padding > 0) && mode !== 'transparent' ? mode : SHEET_EDGE; - return { compiled: { fragment, uniforms, time, edge, padding }, problems, hints, reason: '' }; + return { compiled: { fragment, vertex, uniforms, time, edge, padding }, problems, hints, reason: '' }; +} + +function firstProblem(graph: Graph, problems: Record): string | null { + const first = graph.nodes.find((n) => n.id in problems); + return first ? problems[first.id]! : null; +} + +/** + * One walk of the graph into `lines`, a local per node output. `perPixel` is the list a per-corner + * walk notes in what it met that only a pixel has; the fragment's walk hands in null. + */ +function walker(graph: Graph, lines: string[], prefix: string, perPixel: string[] | null) { + let count = 0; + const walk = graphEmitter(graph, { + isDef: isShaderNodeDef, + noun: 'shader', + emit: (def, inputs, node) => { + if (def.perPixel) perPixel?.push(def.label); + const outputs: Record = {}; + for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) { + const local = `${prefix}${count++}_${key}`; + lines.push(`${expr.type} ${local} = ${expr.code};`); + outputs[key] = { code: local, type: expr.type }; + } + return outputs; + }, + // With nothing to type and nothing the node says, an input is the texel: its colour for a + // colour port, its alpha for a number. + unfed: (node, port, def) => { + const typed = node.params[port.key] ?? port.default; + if (port.field?.kind === 'color') + return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' }; + if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' }; + const said = def?.unfed?.[port.key]; + if (said) return said; + perPixel?.push('the picture'); + return port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR; + }, + conform: (node: GraphNode, port, expr) => { + if (port.type === 'number' && expr.type === 'vec3') { + walk.problem(node, `"${port.key}" needs a number, not a color`); + return null; + } + if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' }; + return expr; + }, + }); + return walk; } diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index 4c9d48f4..ae3af8d1 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -24,6 +24,8 @@ export interface ShaderNodeDef extends NodeDef { emit(inputs: Record, params: PropertyBag): Record; /** What an input with nothing to type on reads while unconnected; without it, the texel. */ unfed?: Record; + /** Reads the picture or is a colour: there is none per corner, so it cannot feed the Vertex output (#79). */ + perPixel?: boolean; } export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef { @@ -33,6 +35,7 @@ export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef { export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor'; export const SHADER_TEXTURE_ALPHA = 'ShaderTextureAlpha'; export const SHADER_OUTPUT = 'ShaderOutput'; +export const SHADER_VERTEX_OUTPUT = 'ShaderVertexOutput'; export const SHADER_COLOR = 'ShaderColor'; export const SHADER_NUMBER = 'ShaderNumber'; export const SHADER_UV = 'ShaderUV'; @@ -87,6 +90,19 @@ export function paddingOf(params: PropertyBag): number { return Number.isFinite(value) ? Math.min(MAX_PADDING, Math.max(0, value)) : 0; } +/** + * How many cells per axis the wearer's quad is cut into (#79) – a setting of the Vertex output node, + * because how fine the grid has to be is the effect's to say. 1 is the plain quad. + */ +export const SUBDIVISIONS_PARAM = 'subdivisions'; +export const DEFAULT_SUBDIVISIONS = 8; +export const MAX_SUBDIVISIONS = 64; + +export function subdivisionsOf(params: PropertyBag): number { + const value = Math.round(Number(params[SUBDIVISIONS_PARAM] ?? DEFAULT_SUBDIVISIONS)); + return Number.isFinite(value) ? Math.min(MAX_SUBDIVISIONS, Math.max(1, value)) : DEFAULT_SUBDIVISIONS; +} + /** * 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. @@ -185,6 +201,7 @@ export const ShaderTextureColor: ShaderNodeDef = { inputs: [], outputs: [vec3Port('color')], params: [], + perPixel: true, emit: () => ({ color: TEXEL_COLOR }), }; @@ -196,6 +213,7 @@ export const ShaderTextureAlpha: ShaderNodeDef = { inputs: [], outputs: [{ key: 'value', label: 'value', type: 'number' }], params: [], + perPixel: true, emit: () => ({ value: TEXEL_ALPHA }), }; @@ -216,6 +234,36 @@ export const ShaderOutput: ShaderNodeDef = { emit: () => ({}), }; +/** + * The second node the compiler starts from (#79): what feeds it runs once per corner of the + * wearer's grid and moves that corner. Without it the wearer keeps the quad every sprite shares. + */ +export const ShaderVertexOutput: ShaderNodeDef = { + type: SHADER_VERTEX_OUTPUT, + label: 'Vertex output', + category: 'shader', + description: + 'Moves the corners of the grid the picture is drawn on: x to the right and y down, in pixels of the picture. The picture bends with them. Subdivisions is how many cells the grid has each way; 1 is the plain rectangle.', + inputs: [ + { key: 'x', label: 'x', type: 'number', inline: false }, + { key: 'y', label: 'y', type: 'number', inline: false }, + ], + outputs: [], + params: [ + { + key: SUBDIVISIONS_PARAM, + label: 'Subdivisions', + kind: 'number', + default: DEFAULT_SUBDIVISIONS, + min: 1, + max: MAX_SUBDIVISIONS, + step: 1, + }, + ], + unfed: { x: { code: '0.0', type: 'float' }, y: { code: '0.0', type: 'float' } }, + emit: () => ({}), +}; + export const ShaderUV: ShaderNodeDef = { type: SHADER_UV, label: 'UV', @@ -240,6 +288,7 @@ export const ShaderSample: ShaderNodeDef = { ], outputs: [vec3Port('color'), numberOut('alpha')], params: [], + perPixel: true, unfed: { u: OWN_U, v: OWN_V }, emit: ({ u, v }) => { const sample = `${TEXEL_AT}(vec2(${u!.code}, ${v!.code})) * vColor`; @@ -258,6 +307,7 @@ export const ShaderTextureSize: ShaderNodeDef = { inputs: [], outputs: [numberOut('width'), numberOut('height')], params: [], + perPixel: true, emit: () => ({ width: { code: 'float(textureSize(uTexture, 0).x)', type: 'float' }, height: { code: 'float(textureSize(uTexture, 0).y)', type: 'float' }, @@ -303,6 +353,7 @@ export const ShaderColor: ShaderNodeDef = { inputs: [], outputs: [vec3Port('color')], params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], + perPixel: true, emit: (_inputs, params) => ({ color: { code: vec3Literal(Number(params.value ?? 0xffffff)), type: 'vec3' }, }), @@ -497,6 +548,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderTextureSize, ShaderTime, ShaderOutput, + ShaderVertexOutput, ShaderProperty, ShaderColor, ShaderNumber, diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index fe8c1e24..0cd6c375 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -29,7 +29,7 @@ import { } from '../../core/properties/particles'; import { nextPropertyName, PROPERTY_NAME_PARAM, SHADER_PROPERTY } from '../../core/properties/shaders'; import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes'; -import { SHADER_NODES, SHADER_OUTPUT } from '../../core/shader/nodes'; +import { SHADER_NODES, SHADER_OUTPUT, SHADER_VERTEX_OUTPUT } from '../../core/shader/nodes'; /** * One row of the palette. `def` is the definition the node would actually have once placed, so the @@ -95,8 +95,9 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr return [{ id: State.type, hint: State.category, def: State, params }]; } if (asset.type === 'shader') { - const hasOutput = asset.graph.nodes.some((n) => n.type === SHADER_OUTPUT); - return SHADER_NODES.filter((def) => def.type !== SHADER_OUTPUT || !hasOutput).map((def) => { + const placed = new Set(asset.graph.nodes.map((n) => n.type)); + const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT]; + return SHADER_NODES.filter((def) => !once.includes(def.type) || !placed.has(def.type)).map((def) => { const params: PropertyBag = def.type === SHADER_PROPERTY ? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) } : {}; return { id: def.type, hint: def.category, def, params }; diff --git a/src/editor/shader/ShaderPreview.ts b/src/editor/shader/ShaderPreview.ts index e6e0074f..f788bd96 100644 --- a/src/editor/shader/ShaderPreview.ts +++ b/src/editor/shader/ShaderPreview.ts @@ -114,10 +114,11 @@ export class ShaderPreview extends Widget { ); this.mesh.position.set(Math.round(this.w / 2), Math.round(this.h / 2)); this.mesh.scale.set(w * zoom, h * zoom); - syncQuadPadding(this.mesh, worn?.padding ?? 0, texture?.width ?? w, texture?.height ?? h); + const counted = { w: texture?.width ?? w, h: texture?.height ?? h }; + syncQuadPadding(this.mesh, worn?.padding ?? 0, counted.w, counted.h, worn?.vertex?.subdivisions ?? 1); this.mesh.texture = texture ?? Texture.WHITE; this.mesh.tint = picture?.tint ?? 0xffffff; - syncShader(this.mesh, { ...this.values, [SHADER_KEY]: this.shader.id }, source); + syncShader(this.mesh, { ...this.values, [SHADER_KEY]: this.shader.id }, source, counted); } override destroy(): void { diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index 1b528f11..120da485 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -330,8 +330,9 @@ export abstract class WorldViewport extends W // 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. 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; + // of a shader that pads it (#69) or moves its corners (#79): both draw past its rectangle. + const worn = wornShader(item.props, shaders); + const unbound = item.type === 'particles' || (worn?.padding ?? 0) > 0 || Boolean(worn?.vertex); const onScreen = item.enabled && (unbound || rectsIntersect(extent, bounds)); let node = this.nodes.get(item.id); if (!node) { diff --git a/src/viewport/quadPadding.ts b/src/viewport/quadPadding.ts index 4989ce41..9fc91137 100644 --- a/src/viewport/quadPadding.ts +++ b/src/viewport/quadPadding.ts @@ -33,6 +33,35 @@ export function paddedQuad(pad: QuadPadding): { }; } +/** + * The same rectangle cut into `cells` by `cells` (#79), so a shader's Vertex output has corners to + * move inside it. Positions and UVs stay one and the same, as on the quad. + */ +export function gridQuad( + pad: QuadPadding | null, + cells: number, +): { positions: Float32Array; uvs: Float32Array; indices: Uint32Array } { + const n = Math.max(1, Math.round(cells)); + const [u, v] = [pad?.u ?? 0, pad?.v ?? 0]; + const corners = new Float32Array((n + 1) * (n + 1) * 2); + for (let row = 0; row <= n; row++) { + for (let column = 0; column <= n; column++) { + const at = (row * (n + 1) + column) * 2; + corners[at] = -u + ((1 + 2 * u) * column) / n; + corners[at + 1] = -v + ((1 + 2 * v) * row) / n; + } + } + const indices = new Uint32Array(n * n * 6); + for (let row = 0; row < n; row++) { + for (let column = 0; column < n; column++) { + const corner = row * (n + 1) + column; + const below = corner + n + 1; + indices.set([corner, corner + 1, below + 1, corner, below + 1, below], (row * n + column) * 6); + } + } + return { positions: corners, uvs: new Float32Array(corners), indices }; +} + export function paddingKey(pad: QuadPadding | null): string { return pad ? `${pad.u},${pad.v}` : ''; } diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index b1f684e4..d71827de 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -11,7 +11,7 @@ import { } from 'pixi.js'; import type { PropertyBag } from '../core/model/Properties'; import { shaderOf } from '../core/properties/shaders'; -import type { CompiledShader } from '../core/shader/compile'; +import type { CompiledShader, ShaderVertex } from '../core/shader/compile'; import { TIME_UNIFORM, uniformName } from '../core/shader/nodes'; import { type QuadPadding, quadPadding } from './quadPadding'; @@ -52,6 +52,35 @@ 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. + */ +function cornerVertex(vertex: ShaderVertex): string { + return ` +in vec2 aPosition; +in vec2 aUV; +out vec2 vTextureCoord; +out vec4 vColor; +uniform mat3 uProjectionMatrix; +uniform mat3 uWorldTransformMatrix; +uniform vec4 uWorldColorAlpha; +uniform mat3 uTransformMatrix; +uniform vec4 uColor; +uniform vec2 uPerPixel; +${vertex.head} +void main() +{ + vTextureCoord = aUV; + ${vertex.body.split('\n').join('\n ')} + mat3 m = uProjectionMatrix * uWorldTransformMatrix * uTransformMatrix; + gl_Position = vec4((m * vec3(aPosition + offset * uPerPixel, 1.0)).xy, 0.0, 1.0); + vColor = uColor * uWorldColorAlpha; +} +`; +} + /** * 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 @@ -125,6 +154,8 @@ abstract class CompiledGraphShader extends Shader { private readonly uniforms: Record | null; private readonly compiled: CompiledShader; readonly fragment: string; + /** The vertex stage this was built with; a particle field's never comes from the graph. */ + private readonly vertex: string; constructor( readonly assetId: string, @@ -138,13 +169,21 @@ abstract class CompiledGraphShader extends Shader { resources: { ...resources, ...(group ? { shaderUniforms: group } : {}) }, }); this.fragment = compiled.fragment; + this.vertex = program.vertex; this.compiled = compiled; this.uniforms = group ? group.uniforms : null; } /** True while this is still the shader `props` ask for, so it can be updated instead of rebuilt. */ wears(id: string, compiled: CompiledShader): boolean { - return this.assetId === id && this.fragment === compiled.fragment; + return ( + this.assetId === id && this.fragment === compiled.fragment && this.vertex === this.vertexFor(compiled) + ); + } + + /** The vertex stage this kind of wearer draws `compiled` with. */ + protected vertexFor(_compiled: CompiledShader): string { + return this.vertex; } read(props: PropertyBag, now: number): void { @@ -159,14 +198,34 @@ abstract class CompiledGraphShader extends Shader { 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; constructor(assetId: string, compiled: CompiledShader) { + const corner = compiled.vertex + ? new UniformGroup({ uPerPixel: { value: new Float32Array(2), type: 'vec2' } }) + : null; super( assetId, compiled, - { vertex: SPRITE_VERTEX, name: `shader-${assetId}` }, - { uTexture: Texture.WHITE.source, uSampler: Texture.WHITE.source.style }, + { vertex: spriteVertexOf(compiled), name: `shader-${assetId}` }, + { + uTexture: Texture.WHITE.source, + uSampler: Texture.WHITE.source.style, + ...(corner ? { corner } : {}), + }, ); + this.perPixel = corner ? corner.uniforms.uPerPixel : null; + } + + protected override vertexFor(compiled: CompiledShader): string { + return spriteVertexOf(compiled); + } + + /** The picture the Vertex output's pixels are counted in; null holds every corner in place. */ + count(picture: { w: number; h: number } | null): void { + if (!this.perPixel) return; + this.perPixel[0] = picture && picture.w > 0 ? 1 / picture.w : 0; + this.perPixel[1] = picture && picture.h > 0 ? 1 / picture.h : 0; } /** Pixi binds a mesh's texture only for its own shader, so a custom one takes it here. */ @@ -177,6 +236,10 @@ class GraphShader extends CompiledGraphShader { } } +function spriteVertexOf(compiled: CompiledShader): string { + return compiled.vertex ? cornerVertex(compiled.vertex) : SPRITE_VERTEX; +} + function wornBy(mesh: Mesh): GraphShader | null { return mesh.shader instanceof GraphShader ? mesh.shader : null; } @@ -191,13 +254,19 @@ export function wornShaderId(mesh: Mesh): string { * rebuilt when either changed, dropped for no shader – and for one that does not compile, which * leaves the mesh on Pixi's batched draw. */ -export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource): void { +export function syncShader( + mesh: Mesh, + props: PropertyBag, + shaders: ShaderSource, + picture: { w: number; h: number } | null = null, +): void { const id = shaderOf(props); const compiled = id ? shaders.compiled(id) : null; const worn = wornBy(mesh); if (worn && compiled && worn.wears(id, compiled)) { worn.read(props, shaders.now); worn.wear(mesh.texture); + worn.count(picture); return; } disposeShader(mesh); @@ -205,6 +274,7 @@ export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource const shader = new GraphShader(id, compiled); shader.read(props, shaders.now); shader.wear(mesh.texture); + shader.count(picture); mesh.shader = shader; } // Pixi caches whether a mesh batches (MeshPipe's meshData) and re-checks it only in diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 8ad652f9..18063c90 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -17,7 +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 { gridQuad, paddedQuad, paddingKey, quadPadding } from './quadPadding'; import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders'; import type { TextureLibrary } from './TextureLibrary'; @@ -78,16 +78,13 @@ export function syncSpriteMesh( 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, - worn?.padding ?? 0, - texture ? { w: texture.width, h: texture.height } : size, - ); + const slice = texture ? (item.texture?.slice ?? null) : null; + const picture = texture ? { w: texture.width, h: texture.height } : size; + syncGeometry(mesh, slice, size, worn?.padding ?? 0, picture, worn?.vertex?.subdivisions ?? 1); mesh.texture = texture ?? Texture.WHITE; mesh.tint = Number(item.props.color ?? 0xffffff); - syncShader(mesh, item.props, shaders); + // A sliced sprite keeps its nine parts where they are: its corners are not the grid's (#79). + syncShader(mesh, item.props, shaders, slice ? null : picture); } /** @@ -107,27 +104,33 @@ function syncGeometry( size: { w: number; h: number }, padding: number, picture: { w: number; h: number }, + subdivisions = 1, ): void { const pad = slice ? null : quadPadding(padding, picture.w, picture.h); - const key = slice ? `${sliceKey(slice, size.w, size.h)}|+${padding}` : paddingKey(pad); + const cells = slice ? 1 : subdivisions; + const key = slice + ? `${sliceKey(slice, size.w, size.h)}|+${padding}` + : paddingKey(pad) + (cells > 1 ? `#${cells}` : ''); if ((geometryKeys.get(mesh) ?? '') === key) return; const previous = mesh.geometry; mesh.geometry = slice ? new MeshGeometry(sliceGeometry(slice, size.w, size.h, padding)) - : pad - ? new MeshGeometry(paddedQuad(pad)) - : UNIT_QUAD; + : cells > 1 + ? new MeshGeometry(gridQuad(pad, cells)) + : pad + ? new MeshGeometry(paddedQuad(pad)) + : UNIT_QUAD; if (previous !== UNIT_QUAD) previous.destroy(); 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. + * The room a shader's Padding asks for around a plain quad, and the grid its Vertex output asks + * for: 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 }); +export function syncQuadPadding(mesh: Mesh, padding: number, w: number, h: number, subdivisions = 1): void { + syncGeometry(mesh, null, { w, h }, padding, { w, h }, subdivisions); } /** diff --git a/src/viewport/textPicture.ts b/src/viewport/textPicture.ts index 254ae93d..34f84871 100644 --- a/src/viewport/textPicture.ts +++ b/src/viewport/textPicture.ts @@ -112,10 +112,10 @@ export class TextPicture { 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); + syncQuadPadding(this.mesh, worn?.padding ?? 0, this.w, this.h, worn?.vertex?.subdivisions ?? 1); this.mesh.pivot.set(textAnchor(item.props), 0.5); this.mesh.texture = texture; - syncShader(this.mesh, item.props, shaders); + syncShader(this.mesh, item.props, shaders, { w: this.w, h: this.h }); // Only now: until the shader took the new picture, the old one was still bound to it. previous?.destroy(true); } diff --git a/tests/core/shader-vertex.test.ts b/tests/core/shader-vertex.test.ts new file mode 100644 index 00000000..982b2160 --- /dev/null +++ b/tests/core/shader-vertex.test.ts @@ -0,0 +1,191 @@ +/** + * A shader's Vertex output (#79): what feeds it runs once per corner of the wearer's grid and moves + * that corner, in pixels of the picture. Without the node nothing of a shader changes. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; +import { findAssetOf } from '../../src/core/model/Project'; +import { SHADER_PROPERTY } from '../../src/core/properties/shaders'; +import { compileShader, perCornerProblem } from '../../src/core/shader/compile'; +import { + DEFAULT_SUBDIVISIONS, + MAX_SUBDIVISIONS, + SHADER_COLOR, + SHADER_NUMBER, + SHADER_OUTPUT, + SHADER_SAMPLE, + SHADER_TEXTURE_ALPHA, + SHADER_TEXTURE_COLOR, + SHADER_TEXTURE_SIZE, + SHADER_TIME, + SHADER_UV, + SHADER_VERTEX_OUTPUT, + subdivisionsOf, +} from '../../src/core/shader/nodes'; +import { paletteEntries } from '../../src/editor/graph/paletteEntries'; +import { gridQuad, quadPadding } from '../../src/viewport/quadPadding'; +import { makeProject } from '../helpers'; + +function shader(vertexParams: Record | null = {}) { + const f = makeProject(); + const create = new CreateAssetCommand('shader', 'Wave'); + f.stack.execute(create); + const id = create.asset.id; + const asset = () => findAssetOf(f.project, id, 'shader')!; + const add = (type: string, params = {}) => { + const command = new AddNodeCommand(id, type, 0, 0, params); + f.stack.execute(command); + return command.node.id; + }; + const link = (from: string, fromPort: string, to: string, toPort: string) => + f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort })); + const out = add(SHADER_OUTPUT); + const corner = vertexParams ? add(SHADER_VERTEX_OUTPUT, vertexParams) : ''; + return { f, asset, add, link, out, corner, build: () => compileShader(asset().graph) }; +} + +describe('Vertex output (#79)', () => { + it('leaves a shader without the node exactly as it was', () => { + const plain = shader(null).build(); + expect(plain.compiled!.vertex).toBeNull(); + // The fragment does not know whether there is a per-corner part. + expect(shader().build().compiled!.fragment).toBe(plain.compiled!.fragment); + }); + + it('holds every corner in place while nothing feeds it', () => { + const { compiled } = shader().build(); + expect(compiled!.vertex!.body).toBe('vec2 offset = vec2(0.0, 0.0);'); + expect(compiled!.vertex!.subdivisions).toBe(DEFAULT_SUBDIVISIONS); + }); + + it('moves a corner by what feeds x and y, from the corner’s own UV', () => { + const s = shader(); + const uv = s.add(SHADER_UV); + const wave = s.add('ShaderSin'); + const scaled = s.add('ShaderMultiply', { b: 4 }); + s.link(uv, 'v', wave, 'value'); + s.link(wave, 'result', scaled, 'a'); + s.link(scaled, 'result', s.corner, 'x'); + const { vertex } = s.build().compiled!; + expect(vertex!.body.split('\n')).toEqual([ + 'float c0_u = vTextureCoord.x;', + 'float c1_v = vTextureCoord.y;', + 'float c2_result = sin(c1_v);', + 'float c3_result = (c2_result * 4.0);', + 'vec2 offset = vec2(c3_result, 0.0);', + ]); + }); + + it('emits a node feeding both outputs once for each stage', () => { + const s = shader(); + const amount = s.add(SHADER_PROPERTY, { name: 'amount', default: 1, min: 0, max: 8 }); + s.link(amount, 'value', s.corner, 'y'); + s.link(amount, 'value', s.out, 'alpha'); + const { compiled } = s.build(); + expect(compiled!.uniforms).toEqual([{ name: 'amount', default: 1 }]); + expect(compiled!.fragment).toContain('float n0_value = u_amount;'); + expect(compiled!.vertex!.body).toContain('float c0_value = u_amount;'); + // One precision for a uniform both stages declare: the fragment's default. + expect(compiled!.vertex!.head).toContain('uniform mediump float u_amount;'); + }); + + it('redraws every tick for a Time node only the corners read', () => { + const s = shader(); + const time = s.add(SHADER_TIME); + s.link(time, 'value', s.corner, 'x'); + const { compiled } = s.build(); + expect(compiled!.time).toBe(true); + expect(compiled!.vertex!.head).toContain('uniform mediump float uTime;'); + expect(compiled!.fragment).not.toContain('uTime'); + // And the other way round the vertex stage does not declare what it never reads. + const other = shader(); + const clock = other.add(SHADER_TIME); + other.link(clock, 'value', other.out, 'alpha'); + expect(other.build().compiled!.vertex!.head).not.toContain('uTime'); + expect(other.build().compiled!.fragment).toContain('uniform float uTime;'); + }); + + 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'); + const build = s.build(); + expect(build.compiled).toBeNull(); + expect(build.problems).toEqual({ [s.corner]: perCornerProblem(label) }); + expect(build.reason).toBe(perCornerProblem(label)); + }); + + it('says so too for a colour arriving through a calculation', () => { + for (const type of [SHADER_TEXTURE_COLOR, SHADER_COLOR]) { + const s = shader(); + const luminance = s.add('ShaderLuminance'); + s.link(s.add(type), 'color', luminance, 'color'); + s.link(luminance, 'value', s.corner, 'y'); + expect(s.build().compiled).toBeNull(); + expect(s.build().problems[s.corner]).toMatch(/^Not available per corner: /); + } + // An input that would read the picture while unconnected is the same thing. + const unfed = shader(); + unfed.link(unfed.add('ShaderLuminance'), 'value', unfed.corner, 'y'); + expect(unfed.build().problems).toEqual({ [unfed.corner]: perCornerProblem('the picture') }); + }); + + it('still lets the same nodes feed the Output', () => { + const s = shader(); + s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', s.out, 'alpha'); + s.link(s.add(SHADER_NUMBER, { value: 2 }), 'value', s.corner, 'x'); + const build = s.build(); + expect(build.problems).toEqual({}); + expect(build.compiled!.vertex!.body).toContain('vec2 offset = vec2(c0_value, 0.0);'); + }); + + it('reads Subdivisions as whole cells between 1 and the limit', () => { + expect(subdivisionsOf({})).toBe(DEFAULT_SUBDIVISIONS); + expect(subdivisionsOf({ subdivisions: 1 })).toBe(1); + expect(subdivisionsOf({ subdivisions: 0 })).toBe(1); + expect(subdivisionsOf({ subdivisions: -3 })).toBe(1); + expect(subdivisionsOf({ subdivisions: 2.6 })).toBe(3); + expect(subdivisionsOf({ subdivisions: MAX_SUBDIVISIONS + 9 })).toBe(MAX_SUBDIVISIONS); + expect(subdivisionsOf({ subdivisions: 'fine' })).toBe(DEFAULT_SUBDIVISIONS); + expect(shader({ subdivisions: 32 }).build().compiled!.vertex!.subdivisions).toBe(32); + }); + + it('is offered once, like the Output', () => { + const without = shader(null); + const offered = (s: typeof without) => + paletteEntries(s.f.project, s.asset()).filter((e) => e.def.type === SHADER_VERTEX_OUTPUT).length; + expect(offered(without)).toBe(1); + expect(offered(shader())).toBe(0); + }); +}); + +describe('gridQuad', () => { + it('is the unit quad cut into cells, corners row by row', () => { + const grid = gridQuad(null, 2); + expect([...grid.positions]).toEqual([0, 0, 0.5, 0, 1, 0, 0, 0.5, 0.5, 0.5, 1, 0.5, 0, 1, 0.5, 1, 1, 1]); + expect([...grid.uvs]).toEqual([...grid.positions]); + expect([...grid.indices.slice(0, 6)]).toEqual([0, 1, 4, 0, 4, 3]); + expect(grid.indices.length).toBe(2 * 2 * 6); + }); + + it('has as many corners as the cells need, and every index is one of them', () => { + for (const cells of [1, 8, 64]) { + const grid = gridQuad(null, cells); + expect(grid.positions.length).toBe((cells + 1) ** 2 * 2); + expect(Math.max(...grid.indices)).toBe((cells + 1) ** 2 - 1); + } + }); + + it('covers the padded rectangle, the picture still at 0..1', () => { + const grid = gridQuad(quadPadding(4, 16, 32), 4); + const xs = [...grid.positions].filter((_, i) => i % 2 === 0); + const ys = [...grid.positions].filter((_, i) => i % 2 === 1); + expect([Math.min(...xs), Math.max(...xs)]).toEqual([-0.25, 1.25]); + expect([Math.min(...ys), Math.max(...ys)]).toEqual([-0.125, 1.125]); + expect([...grid.uvs]).toEqual([...grid.positions]); + }); +}); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 091072c6..8c9d236b 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -854,6 +854,77 @@ export async function shader(t) { ); await shot('28-shader-padding-scene'); + // 28l-iv. A Vertex output moves the corners of the sprite's grid (#79), in pixels of the picture: + // 16 of this 32-px one is half the sprite. A constant moves the whole picture; a cut on v + // bends it only where the grid has corners, and with one cell there is just a shear. + const cornered = (cornerNodes, cornerEdges, subdivisions) => + graph( + [ + node('texel', 'ShaderTextureColor'), + node('out', 'ShaderOutput', { edge: 'transparent' }, 780, 0), + ...cornerNodes, + node('vout', 'ShaderVertexOutput', { subdivisions }, 780, 260), + ], + [edge('texel', 'color', 'out', 'color'), ...cornerEdges], + ); + await cornered( + [node('by', 'ShaderNumber', { value: 16 }, 260, 260)], + [edge('by', 'value', 'vout', 'x')], + 8, + ); + const moved = { + left: await pixel(-0.3, -0.3), + right: await pixel(0.3, -0.3), + past: await pixel(0.8, -0.3), + }; + check( + 'a Vertex output fed 16 moves the picture half a sprite to the right, past its rectangle', + colourDistance(moved.left, LEFT) > 60 && + colourDistance(moved.right, LEFT) < 12 && + colourDistance(moved.past, RIGHT) < 12 && + colourDistance(bare.left, LEFT) < 12, + { moved, bare }, + ); + const lowerHalf = async (subdivisions) => { + await cornered( + [ + node('uv', 'ShaderUV', {}, 0, 260), + node('cut', 'ShaderStep', { edge: 0.5 }, 260, 260), + node('far', 'ShaderMultiply', { b: 16 }, 520, 260), + ], + [ + edge('uv', 'v', 'cut', 'value'), + edge('cut', 'result', 'far', 'a'), + edge('far', 'result', 'vout', 'x'), + ], + subdivisions, + ); + return { upper: await pixel(0.6, -0.2), lower: await pixel(0.8, 0.35) }; + }; + const bent = await lowerHalf(8); + const sheared = await lowerHalf(1); + await shot('28-shader-vertex-scene'); + check( + 'with 8 subdivisions only the lower half moves; with 1 there are four corners, and the upper half leans along', + colourDistance(bent.lower, RIGHT) < 12 && + colourDistance(bent.upper, RIGHT) > 60 && + colourDistance(sheared.upper, RIGHT) < 12, + { bent, sheared }, + ); + await cornered( + [node('opacity', 'ShaderTextureAlpha', {}, 260, 260)], + [edge('opacity', 'value', 'vout', 'x')], + 8, + ); + const perCorner = await json("__gm.shader.nodeProblem('vout')"); + const perCornerReadout = await info(); + check( + 'what reads the picture cannot feed the Vertex output: the node says so, and the shader does not compile', + perCorner === 'Not available per corner: Texture alpha' && + JSON.stringify(perCornerReadout).includes(perCorner), + { perCorner, perCornerReadout }, + ); + // 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"); diff --git a/tools/e2e/30-font.mjs b/tools/e2e/30-font.mjs index 7c427be5..d409a1d8 100644 --- a/tools/e2e/30-font.mjs +++ b/tools/e2e/30-font.mjs @@ -321,10 +321,25 @@ export async function font(t) { Math.abs(textRoomy.last - textTight.last - reach) <= 2, JSON.stringify({ textTight, textRoomy, reach }), ); + // 30i-iii. A shader's Vertex output moves a text's corners as it does a sprite's (#79), in the + // text's own units: six of them to the right, and the filled quad stands six further on. + await filled(0); 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'); + graph.nodes.push(${JSON.stringify(node('ft-red-by', 'ShaderNumber', 0, 320, { value: 6 }))}); + graph.nodes.push(${JSON.stringify(node('ft-red-vout', 'ShaderVertexOutput', 260, 320, { subdivisions: 4 }))}); + graph.edges.push(${JSON.stringify(edge('ft-red-e3', 'ft-red-by', 'value', 'ft-red-vout', 'x'))});`); + const textMoved = await redSpan(); + check( + 'a Vertex output fed 6 draws the text six units to the right of where it stands', + Math.abs(textMoved.first - textTight.first - reach) <= 2 && + Math.abs(textMoved.last - textTight.last - reach) <= 2, + JSON.stringify({ textTight, textMoved, reach }), + ); + await mutate(` + const graph = ${shaderGraph}; + graph.nodes = graph.nodes.filter((n) => !['ft-red-full', 'ft-red-by', 'ft-red-vout'].includes(n.id)); + graph.edges = graph.edges.filter((e) => !['ft-red-e2', 'ft-red-e3'].includes(e.id)); 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: diff --git a/tools/perf-shaders.mjs b/tools/perf-shaders.mjs index 5a3e660b..4a181d5a 100644 --- a/tools/perf-shaders.mjs +++ b/tools/perf-shaders.mjs @@ -10,6 +10,8 @@ * * Usage: node tools/seed-e2e.mjs && node tools/perf-shaders.mjs * GM_PERF_COUNTS=... sprite counts, GM_PERF_SIZE=... edge length of one sprite + * GM_PERF_SUBDIVISIONS=... gives the shader a Vertex output with that grid (#79); nothing feeds + * it, so the pixels stay the same and only the corners are more * * It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it. * @@ -28,6 +30,7 @@ const { projectId, baseUrl } = cliArgs('perf-shaders.mjs', PROJECTS.fixture); const COUNTS = (process.env.GM_PERF_COUNTS ?? '50,100,200,400,800').split(',').map(Number); const SIZE = Number(process.env.GM_PERF_SIZE ?? 48); +const SUBDIVISIONS = Number(process.env.GM_PERF_SUBDIVISIONS ?? 0); const WARMUP_MS = 1500; const SAMPLE_MS = 4000; @@ -49,6 +52,14 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async props: { width: ${SIZE}, height: ${SIZE}, color: 0x4080ff, shader: '' } }); `); + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === 'adjust-colors').graph; + graph.nodes = graph.nodes.filter((n) => n.id !== 'perf-vout'); + if (${SUBDIVISIONS} > 0) + graph.nodes.push({ id: 'perf-vout', type: 'ShaderVertexOutput', x: 0, y: 600, + params: { subdivisions: ${SUBDIVISIONS} } }); + `); + /** Camera-sized grid of `n` instances, all inside the view so none of them is skipped. */ const buildScene = async (n) => { await mutate(` @@ -145,7 +156,8 @@ await runChecks({ projectId, baseUrl, port: 9347, shotPrefix: 'gm-perf' }, async await stop(); console.log( - `\nsprite ${SIZE}x${SIZE}, ${SAMPLE_MS} ms per point, neutral values, ` + + `\nsprite ${SIZE}x${SIZE}, ${SUBDIVISIONS > 0 ? `grid of ${SUBDIVISIONS}` : 'plain quad'}, ` + + `${SAMPLE_MS} ms per point, neutral values, ` + `60 Hz cap (cannot be lifted in headless)`, ); console.log('| sprites | no shader | with shader | per sprite | shaders | filters |');