Let a shader move the corners of the picture it wears (#79)
A shader could only say what colour a pixel has. Where the picture is drawn was fixed to the four corners of its rectangle, so nothing could wave, bend or wobble. - The new node Vertex output takes x and y, in pixels of the picture, and moves every corner of the wearer's grid by them. The UVs stay with the corners, so the picture bends along. - Subdivisions on that node says how fine the grid is: cells per axis, 1 to 64, 8 by default. 1 is the plain quad. A shader without the node keeps the quad every sprite shares. - A graph holds one Vertex output at most. UV, Time, Property, Number and the math nodes may feed it, and one node may feed both outputs. - Texture color, Texture alpha, Sample texture, Texture size and Color cannot feed it. The node says "Not available per corner" and the shader does not compile. - Sprites, texts and the preview in the shader tab wear the grid. A nine-slice sprite and particles draw as if the node were not there. - Picking, the selection frame, the size and colliders stay with the rectangle. A wearer with a Vertex output is never culled, as with Padding, and with Padding the grid covers the room around the picture too. - tools/perf-shaders.mjs takes GM_PERF_SUBDIVISIONS to measure a grid against the plain quad. Measured on stein2 (Intel HD 630), frames per second, one run per row: at 800 sprites 58 with a shader on the quad, 46 with 8 cells, 41 with 32. Up to 400 sprites a grid hardly shows. Every sprite holds its own grid. Tests: - Gate on melektaus-atx: 220 files, 3416 tests, CHECK EXIT 0. - 15 new unit tests for the compiler and the grid. - Four new e2e checks in modules 28 and 30: a fixed offset moves the picture, 8 cells bend only the lower half where 1 cell shears it all, Texture alpha is refused at the node, a text moves. - e2e 28,30 on melektaus-atx: 110 checks, all pass. - The e2e total of 1699 is counted (1695 + 4), the full suite did not run. - No counter-probe, no screenshot. Not looked at: the preview, Time in the browser, flipped sprites, Padding together with the grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
963f8a152a
commit
4716726b6b
14 files changed
+586
-75
No files matched your search
@@ -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
|
||||
|
||||
+105
-40
@@ -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<ShaderNodeDef, Expr>(graph, {
|
||||
isDef: isShaderNodeDef,
|
||||
noun: 'shader',
|
||||
emit: (def, inputs, node) => {
|
||||
const outputs: Record<string, Expr> = {};
|
||||
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, string>): 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<ShaderNodeDef, Expr>(graph, {
|
||||
isDef: isShaderNodeDef,
|
||||
noun: 'shader',
|
||||
emit: (def, inputs, node) => {
|
||||
if (def.perPixel) perPixel?.push(def.label);
|
||||
const outputs: Record<string, Expr> = {};
|
||||
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;
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export interface ShaderNodeDef extends NodeDef {
|
||||
emit(inputs: Record<string, Expr>, params: PropertyBag): Record<string, Expr>;
|
||||
/** What an input with nothing to type on reads while unconnected; without it, the texel. */
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -330,8 +330,9 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> 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) {
|
||||
|
||||
@@ -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}` : '';
|
||||
}
|
||||
+75
-5
@@ -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<string, number> | 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<f32>' } })
|
||||
: 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
|
||||
|
||||
+20
-17
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | 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]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
+17
-2
@@ -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:
|
||||
|
||||
+13
-1
@@ -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 |');
|
||||
|
||||
Reference in new issue
Block a user