From bf27e42e8a4e9a46c3cea1b8cfdb29b3aeb0d9f2 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 11:43:54 +0200 Subject: [PATCH] Let a shader declare a color the sprite sets (#298) A shader could only ask its sprites for numbers. A tint, an outline colour or a glow colour had to be built from three Property nodes. - New node Color property next to Property: Name and Default (a colour, white unless picked), output `color`. It becomes a colour row below the Shader dropdown and a vec3 uniform in the program. - Number and colour properties share one set of names. A clash is the existing "Another property is named" problem. - Like Color it cannot feed the Vertex output. - Placed from the palette it is called tint, then tint2. - The preview in the Shader tab shows a colour field for it. Like the sliders it starts at the sprite's value and never writes into the project. - `declaredRows` takes the row builder, so the particle graph keeps its number rows unchanged. Tests: - Gate: 222 files, 3452 tests (3445 + 7) - e2e 28: 89 checks (28l-v: default colour drawn, then the colour the sprite sets) - e2e total 1704, counted (1703 + 1) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/properties/declaredRows.ts | 21 ++-- src/core/properties/shaders.ts | 32 ++++-- src/core/shader/compile.ts | 16 ++- src/core/shader/nodes.ts | 30 +++++- src/editor/graph/paletteEntries.ts | 13 ++- src/editor/shader/ShaderEditor.ts | 53 +++++++--- src/editor/shader/previewPicture.ts | 12 ++- src/viewport/shaders.ts | 29 ++++-- tests/core/shader-color-property.test.ts | 121 +++++++++++++++++++++++ tests/core/shader-vertex.test.ts | 2 +- tests/core/shaders.test.ts | 6 +- tools/e2e/28-shader.mjs | 23 +++++ 13 files changed, 303 insertions(+), 57 deletions(-) create mode 100644 tests/core/shader-color-property.test.ts diff --git a/README.md b/README.md index 8dfa4d70..0d52a263 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, 1703 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1704 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/properties/declaredRows.ts b/src/core/properties/declaredRows.ts index 29adb8c7..e39c5584 100644 --- a/src/core/properties/declaredRows.ts +++ b/src/core/properties/declaredRows.ts @@ -4,7 +4,7 @@ import { flatDef, type FlatDef, type PropertyDef } from './PropertySchema'; /** The params of a `Property` node in a shader or particle graph: its name and the row it declares. */ export const PROPERTY_NAME_PARAM = 'name'; -const PROPERTY_DEFAULT_PARAM = 'default'; +export const PROPERTY_DEFAULT_PARAM = 'default'; const PROPERTY_MIN_PARAM = 'min'; const PROPERTY_MAX_PARAM = 'max'; @@ -25,23 +25,30 @@ export interface Declaration { } /** - * The number rows a graph declares, in node order: one per declaring node with a valid name. A - * second node of the same name declares nothing new – both read the one value. + * The rows a graph declares, in node order: one per declaring node with a valid name. A second node + * of the same name declares nothing new – both read the one value. */ -export function declaredNumberRows(graph: Graph, of: Declaration): FlatDef<'number'>[] { - const rows: FlatDef<'number'>[] = []; +export function declaredRows( + graph: Graph, + of: Declaration & { rowOf(name: string, node: GraphNode): R }, +): R[] { + const rows: R[] = []; const seen = new Set(); for (const node of graph.nodes) { if (!of.isDeclaration(node)) continue; const name = of.nameOf(node); if (!of.isValidName(name) || seen.has(name)) continue; seen.add(name); - rows.push(numberRow(name, node.params)); + rows.push(of.rowOf(name, node)); } return rows; } -function numberRow(name: string, params: PropertyBag): FlatDef<'number'> { +export function declaredNumberRows(graph: Graph, of: Declaration): FlatDef<'number'>[] { + return declaredRows(graph, { ...of, rowOf: (name, node) => numberRow(name, node.params) }); +} + +export function numberRow(name: string, params: PropertyBag): FlatDef<'number'> { const min = numberParam(params, PROPERTY_MIN_PARAM, 0); const max = numberParam(params, PROPERTY_MAX_PARAM, 1); const row: FlatDef<'number'> = { diff --git a/src/core/properties/shaders.ts b/src/core/properties/shaders.ts index 256b7bee..ed2988de 100644 --- a/src/core/properties/shaders.ts +++ b/src/core/properties/shaders.ts @@ -1,7 +1,7 @@ import type { Graph, GraphNode } from '../graph/Graph'; -import { type PropertyBag, textOf } from '../model/Properties'; -import { declaredNumberRows, PROPERTY_NAME_PARAM } from './declaredRows'; -import { type FlatDef, isSpriteProp, isValidFieldName } from './PropertySchema'; +import { colorOf, type PropertyBag, textOf } from '../model/Properties'; +import { declaredRows, numberRow, PROPERTY_DEFAULT_PARAM, PROPERTY_NAME_PARAM } from './declaredRows'; +import { type FlatDef, flatDef, isSpriteProp, isValidFieldName } from './PropertySchema'; export { PROPERTY_NAME_PARAM } from './declaredRows'; @@ -13,13 +13,19 @@ export const SHADER_KEY = 'shader'; * GLSL uniform, `default`/`min`/`max` are the row the inspector shows. */ export const SHADER_PROPERTY = 'ShaderProperty'; +/** The same for a colour (#298): a colour row on the sprite and a vec3 uniform. */ +export const SHADER_COLOR_PROPERTY = 'ShaderColorProperty'; +export const DEFAULT_PROPERTY_COLOR = 0xffffff; + +/** A row a shader graph adds to its wearers. */ +export type ShaderParamDef = FlatDef<'number'> | FlatDef<'color'>; export function shaderOf(bag: PropertyBag): string { return textOf(bag[SHADER_KEY]); } export function isPropertyNode(node: GraphNode): boolean { - return node.type === SHADER_PROPERTY; + return node.type === SHADER_PROPERTY || node.type === SHADER_COLOR_PROPERTY; } export function propertyName(node: GraphNode): string { @@ -36,17 +42,25 @@ function isValidPropertyName(name: string): boolean { } /** The properties a shader graph declares: one row on the sprite and one uniform each. */ -export function shaderParamDefs(graph: Graph): FlatDef<'number'>[] { - return declaredNumberRows(graph, { +export function shaderParamDefs(graph: Graph): ShaderParamDef[] { + return declaredRows(graph, { isDeclaration: isPropertyNode, nameOf: propertyName, isValidName: isValidPropertyName, + rowOf: (name, node) => + node.type === SHADER_COLOR_PROPERTY + ? flatDef(name, 'color', { value: propertyColor(node.params) }) + : numberRow(name, node.params), }); } +export function propertyColor(params: PropertyBag): number { + return colorOf(params[PROPERTY_DEFAULT_PARAM] ?? DEFAULT_PROPERTY_COLOR, DEFAULT_PROPERTY_COLOR); +} + /** What a property placed from the palette is called: `value`, then `value2`, `value3` … */ -export function nextPropertyName(graph: Graph): string { +export function nextPropertyName(graph: Graph, base = 'value'): string { const taken = new Set(graph.nodes.filter(isPropertyNode).map(propertyName)); - if (!taken.has('value')) return 'value'; - for (let n = 2; ; n++) if (!taken.has(`value${n}`)) return `value${n}`; + if (!taken.has(base)) return base; + for (let n = 2; ; n++) if (!taken.has(`${base}${n}`)) return `${base}${n}`; } diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index ee52094d..cb213fd8 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -32,9 +32,13 @@ import { export interface ShaderUniform { name: string; + /** A colour is a vec3 uniform and its default a packed 0xRRGGBB (#298). */ + kind: 'number' | 'color'; default: number; } +const glslOf = (u: ShaderUniform) => (u.kind === 'color' ? 'vec3' : 'float'); + /** * 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. @@ -80,7 +84,7 @@ export const perCornerProblem = (what: string) => `Not available per corner: ${w /** * A shader graph to the fragment of the sprite's own draw: every node reachable from the Output - * becomes one local per output, in dependency order, and every Property a float uniform. The texel + * becomes one local per output, in dependency order, and every Property a uniform. The texel * arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back. */ export function compileShader(graph: Graph): ShaderBuild { @@ -108,7 +112,11 @@ export function compileShader(graph: Graph): ShaderBuild { const color = walk.input(output, outputDef.inputs[0]!); const alpha = walk.input(output, outputDef.inputs[1]!); - const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default })); + const uniforms = shaderParamDefs(graph).map((row) => ({ + name: row.key, + kind: row.kind, + 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. @@ -128,7 +136,7 @@ export function compileShader(graph: Graph): ShaderBuild { // 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)};`), + ...uniforms.map((u) => `uniform mediump ${glslOf(u)} ${uniformName(u.name)};`), DIVIDE_FN, ].join('\n'), body: [...cornerLines, `vec2 offset = vec2(${x.code}, ${y.code});`].join('\n'), @@ -153,7 +161,7 @@ export function compileShader(graph: Graph): ShaderBuild { 'out vec4 finalColor;', 'uniform sampler2D uTexture;', ...(fragmentTime ? [`uniform float ${TIME_UNIFORM};`] : []), - ...uniforms.map((u) => `uniform float ${uniformName(u.name)};`), + ...uniforms.map((u) => `uniform ${glslOf(u)} ${uniformName(u.name)};`), STRAIGHT_FN, DIVIDE_FN, texelAtFn(mode), diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index ae3af8d1..d55f7df9 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -2,9 +2,15 @@ import type { PortType } from '../graph/Graph'; import type { NodeDef, PortDef } from '../graph/NodeRegistry'; import type { SharedPortGroup } from '../graph/portTypes'; import { type PropertyBag, textOf } from '../model/Properties'; -import { propertyNodeParams } from '../properties/declaredRows'; +import { PROPERTY_DEFAULT_PARAM, propertyNodeParams } from '../properties/declaredRows'; import { flatDef } from '../properties/PropertySchema'; -import { PROPERTY_NAME_PARAM, propertyName, SHADER_PROPERTY } from '../properties/shaders'; +import { + DEFAULT_PROPERTY_COLOR, + PROPERTY_NAME_PARAM, + propertyName, + SHADER_COLOR_PROPERTY, + SHADER_PROPERTY, +} from '../properties/shaders'; export type GlslType = 'float' | 'vec3'; @@ -341,6 +347,25 @@ export const ShaderProperty: ShaderNodeDef = { }), }; +/** The same for a colour (#298); like `Color`, there is none per corner. */ +export const ShaderColorProperty: ShaderNodeDef = { + type: SHADER_COLOR_PROPERTY, + label: 'Color property', + category: 'shader', + description: 'A color the sprite sets: a color row below the Shader dropdown, and a color here.', + inputs: [], + outputs: [vec3Port('color')], + params: [ + flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }), + flatDef(PROPERTY_DEFAULT_PARAM, 'color', { label: 'Default', value: DEFAULT_PROPERTY_COLOR }), + ], + perPixel: true, + derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderColorProperty.label }), + emit: (_inputs, params) => ({ + color: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'vec3' }, + }), +}; + export function uniformName(property: string): string { return `u_${property}`; } @@ -550,6 +575,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderOutput, ShaderVertexOutput, ShaderProperty, + ShaderColorProperty, ShaderColor, ShaderNumber, ShaderAdd, diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index 0cd6c375..3e10d4be 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -27,7 +27,12 @@ import { PROPERTY_NAME_PARAM as PARTICLE_NAME_PARAM, propertyNameOf, } from '../../core/properties/particles'; -import { nextPropertyName, PROPERTY_NAME_PARAM, SHADER_PROPERTY } from '../../core/properties/shaders'; +import { + nextPropertyName, + PROPERTY_NAME_PARAM, + SHADER_COLOR_PROPERTY, + SHADER_PROPERTY, +} from '../../core/properties/shaders'; import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes'; import { SHADER_NODES, SHADER_OUTPUT, SHADER_VERTEX_OUTPUT } from '../../core/shader/nodes'; @@ -99,7 +104,11 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr 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) } : {}; + def.type === SHADER_PROPERTY + ? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) } + : def.type === SHADER_COLOR_PROPERTY + ? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph, 'tint') } + : {}; return { id: def.type, hint: def.category, def, params }; }); } diff --git a/src/editor/shader/ShaderEditor.ts b/src/editor/shader/ShaderEditor.ts index b2d80b3e..b68e18ff 100644 --- a/src/editor/shader/ShaderEditor.ts +++ b/src/editor/shader/ShaderEditor.ts @@ -1,8 +1,9 @@ import type { Asset, ShaderAsset } from '../../core/model/Asset'; import type { FlatDef } from '../../core/properties/PropertySchema'; -import { shaderParamDefs } from '../../core/properties/shaders'; +import { type ShaderParamDef, shaderParamDefs } from '../../core/properties/shaders'; import { Button, + ColorField, formatNumber, HStack, Label, @@ -56,7 +57,8 @@ export class ShaderEditor extends AssetTabEditor { private readonly subjectLabel = new Label('', { style: 'small' }); private readonly params = new VStack({ gap: Theme.gap, padding: Theme.padding }); private readonly sliders = new Map(); - /** What the sliders are set to – the preview's values, never anyone's rows. */ + private readonly colors = new Map(); + /** What the sliders and colour fields are set to – the preview's values, never anyone's rows. */ private values: Record = {}; private subject: PictureSubject | null = null; /** Rebuild the sliders only when the declared properties change, not on every edit of the graph. */ @@ -139,6 +141,11 @@ export class ShaderEditor extends AssetTabEditor { return this.sliders.get(key)?.slider; } + /** The colour field of one colour property, for the check scripts. */ + colorFieldOf(key: string): ColorField | undefined { + return this.colors.get(key); + } + override selectedId(): string { return this.state.selectedShaderId ?? ''; } @@ -178,12 +185,15 @@ export class ShaderEditor extends AssetTabEditor { this.syncControls(); } - private syncParams(defs: readonly FlatDef<'number'>[]): void { - const key = JSON.stringify(defs.map((d) => [d.key, d.min, d.max, d.default])); + private syncParams(defs: readonly ShaderParamDef[]): void { + const key = JSON.stringify( + defs.map((d) => (d.kind === 'color' ? [d.key, d.kind] : [d.key, d.min, d.max, d.default])), + ); if (key !== this.paramsKey) { this.paramsKey = key; for (const row of this.params.removeChildren()) row.destroy({ children: true }); this.sliders.clear(); + this.colors.clear(); for (const def of defs) this.params.add(this.paramRow(def)); // Neither the column nor, with as many rows as before, the stack of rows changes its size, // and a resize that changes nothing lays nothing out: both lay out anew, outside in. @@ -191,14 +201,36 @@ export class ShaderEditor extends AssetTabEditor { this.params.relayout(); } for (const def of defs) { - const row = this.sliders.get(def.key); const value = this.values[def.key] ?? def.default; + this.colors.get(def.key)?.setValue(value); + const row = this.sliders.get(def.key); row?.slider.setValue(value); row?.value.setText(formatNumber(value)); } } - private paramRow(def: FlatDef<'number'>): Widget { + private paramRow(def: ShaderParamDef): Widget { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.controlHeight; + const name = new Label(def.label, { style: 'small' }); + name.minWidth = PARAM_NAME_WIDTH; + row.add(name); + if (def.kind === 'color') { + const show = (color: number) => { + this.values[def.key] = color; + this.preview.invalidate(); + }; + const field = new ColorField(this.values[def.key] ?? def.default, { onInput: show, onChange: show }); + this.colors.set(def.key, field); + row.add(field, 1); + return row; + } + row.add(this.slider(def), 1); + row.add(this.sliders.get(def.key)!.value); + return row; + } + + private slider(def: FlatDef<'number'>): Slider { const { min, max } = sliderRange(def); const slider = new Slider(this.values[def.key] ?? def.default, min, max, 80); const value = new Label('', { style: 'small', align: 'right' }); @@ -210,14 +242,7 @@ export class ShaderEditor extends AssetTabEditor { this.preview.invalidate(); }; this.sliders.set(def.key, { slider, value }); - const row = new HStack({ gap: Theme.gap, align: 'center' }); - row.minHeight = Theme.controlHeight; - const name = new Label(def.label, { style: 'small' }); - name.minWidth = PARAM_NAME_WIDTH; - row.add(name); - row.add(slider, 1); - row.add(value); - return row; + return slider; } /** Stands the preview at a second, or lets it run again with `null` – what Hold and Run do. */ diff --git a/src/editor/shader/previewPicture.ts b/src/editor/shader/previewPicture.ts index 39a1d1db..fb0ebe43 100644 --- a/src/editor/shader/previewPicture.ts +++ b/src/editor/shader/previewPicture.ts @@ -1,12 +1,12 @@ import { findObject } from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; -import type { PropertyBag } from '../../core/model/Properties'; +import { colorOf, type PropertyBag } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; import { objectName } from '../../core/model/SceneObject'; import { findFrame, resolvePicture, resolveTexture, type TextureRef } from '../../core/model/texture'; import type { FlatDef } from '../../core/properties/PropertySchema'; import { resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve'; -import { shaderOf } from '../../core/properties/shaders'; +import { type ShaderParamDef, shaderOf } from '../../core/properties/shaders'; import type { Selection } from '../state'; /** Something the Shader tab's preview can show: a sprite, placed or not, a texture or one of its frames (#168). */ @@ -85,15 +85,17 @@ function texturePicture(name: string, ref: TextureRef): PreviewPicture { /** Where a slider starts: the subject's own value when it wears this shader, the declared default otherwise. */ export function startValues( - defs: readonly FlatDef<'number'>[], + defs: readonly ShaderParamDef[], props: PropertyBag, shaderId: string, ): Record { const wears = shaderOf(props) === shaderId; const values: Record = {}; for (const def of defs) { - const own = Number(props[def.key]); - values[def.key] = wears && Number.isFinite(own) ? own : def.default; + const own = props[def.key]; + if (def.kind === 'color') + values[def.key] = wears && own !== undefined ? colorOf(own, def.default) : def.default; + else values[def.key] = wears && Number.isFinite(Number(own)) ? Number(own) : def.default; } return values; } diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index d71827de..72a1e6e4 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -9,7 +9,7 @@ import { TextureStyle, UniformGroup, } from 'pixi.js'; -import type { PropertyBag } from '../core/model/Properties'; +import { colorOf, type PropertyBag } from '../core/model/Properties'; import { shaderOf } from '../core/properties/shaders'; import type { CompiledShader, ShaderVertex } from '../core/shader/compile'; import { TIME_UNIFORM, uniformName } from '../core/shader/nodes'; @@ -119,27 +119,38 @@ void main(void) } `; -/** One float uniform per graph parameter, plus the clock where a Time node reads one. */ -function uniformGroupOf(compiled: CompiledShader): UniformGroup> | null { - const entries: [string, UniformFloat][] = compiled.uniforms.map((u) => [ +/** One uniform per graph parameter, plus the clock where a Time node reads one. */ +function uniformGroupOf(compiled: CompiledShader): UniformGroup> | null { + const entries: [string, UniformValue][] = compiled.uniforms.map((u) => [ uniformName(u.name), - { value: u.default, type: 'f32' }, + u.kind === 'color' + ? { value: new Float32Array(3), type: 'vec3' } + : { value: u.default, type: 'f32' }, ]); if (compiled.time) entries.push([TIME_UNIFORM, { value: 0, type: 'f32' }]); return entries.length > 0 ? new UniformGroup(Object.fromEntries(entries)) : null; } -type UniformFloat = { value: number; type: 'f32' }; +type UniformValue = { value: number; type: 'f32' } | { value: Float32Array; type: 'vec3' }; function readUniforms( - into: Record, + into: Record, compiled: CompiledShader, props: PropertyBag, now: number, ): void { for (const u of compiled.uniforms) { + const name = uniformName(u.name); + const slot = into[name]; + if (slot instanceof Float32Array) { + const color = colorOf(props[u.name] ?? u.default, u.default); + slot[0] = ((color >> 16) & 0xff) / 255; + slot[1] = ((color >> 8) & 0xff) / 255; + slot[2] = (color & 0xff) / 255; + continue; + } const n = Number(props[u.name]); - into[uniformName(u.name)] = Number.isFinite(n) ? n : u.default; + into[name] = Number.isFinite(n) ? n : u.default; } if (compiled.time) into[TIME_UNIFORM] = now; } @@ -151,7 +162,7 @@ function readUniforms( * constructor declared, so a subclass names them all up front. */ abstract class CompiledGraphShader extends Shader { - private readonly uniforms: Record | null; + 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. */ diff --git a/tests/core/shader-color-property.test.ts b/tests/core/shader-color-property.test.ts new file mode 100644 index 00000000..bb8f061a --- /dev/null +++ b/tests/core/shader-color-property.test.ts @@ -0,0 +1,121 @@ +/** + * A shader's `Color property` (#298): a colour row on every sprite wearing it, and a vec3 uniform + * in the program – the colour counterpart of `Property`. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; +import { findAssetOf } from '../../src/core/model/Project'; +import { schemaForAsset } from '../../src/core/properties/PropertySchema'; +import { + SHADER_COLOR_PROPERTY, + SHADER_KEY, + SHADER_PROPERTY, + shaderParamDefs, +} from '../../src/core/properties/shaders'; +import { compileShader, perCornerProblem } from '../../src/core/shader/compile'; +import { SHADER_OUTPUT, SHADER_VERTEX_OUTPUT } from '../../src/core/shader/nodes'; +import { paletteEntries } from '../../src/editor/graph/paletteEntries'; +import { startValues } from '../../src/editor/shader/previewPicture'; +import { makeSpriteScene, roundTrip } from '../helpers'; + +function shader() { + const f = makeSpriteScene(); + const create = new CreateAssetCommand('shader', 'Recolor'); + 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); + return { f, id, asset, add, link, out, build: () => compileShader(asset().graph) }; +} + +describe('Color property (#298)', () => { + it('declares a colour row, in node order with the number rows', () => { + const s = shader(); + s.add(SHADER_PROPERTY, { name: 'amount', default: 0.5, min: 0, max: 1 }); + s.add(SHADER_COLOR_PROPERTY, { name: 'tint', default: 0x3cc85a }); + s.add(SHADER_COLOR_PROPERTY, { name: 'shade', default: 'not a colour' }); + const rows = shaderParamDefs(s.asset().graph); + expect(rows.map((r) => [r.key, r.kind])).toEqual([ + ['amount', 'number'], + ['tint', 'color'], + ['shade', 'color'], + ]); + expect(rows[1]).toEqual({ key: 'tint', label: 'tint', kind: 'color', default: 0x3cc85a }); + expect(rows[2]!.default).toBe(0xffffff); + }); + + it('compiles to a vec3 uniform the Output can take as its colour', () => { + const s = shader(); + const tint = s.add(SHADER_COLOR_PROPERTY, { name: 'tint', default: 0xff0000 }); + const mixed = s.add('ShaderMultiply'); + s.link(tint, 'color', mixed, 'a'); + s.link(mixed, 'result', s.out, 'color'); + const build = s.build(); + expect(build.problems).toEqual({}); + expect(build.compiled!.uniforms).toEqual([{ name: 'tint', kind: 'color', default: 0xff0000 }]); + expect(build.compiled!.fragment).toContain('uniform vec3 u_tint;'); + expect(build.compiled!.fragment).toContain('vec3 n0_color = u_tint;'); + expect(build.compiled!.fragment).toContain('vec3 n1_result = (n0_color * vec3(1.0));'); + }); + + it('shares its names with the number properties', () => { + const s = shader(); + s.add(SHADER_PROPERTY, { name: 'tint', default: 1 }); + const second = s.add(SHADER_COLOR_PROPERTY, { name: 'tint' }); + expect(s.build().problems[second]).toBe('Another property is named "tint"'); + const rename = s.add(SHADER_COLOR_PROPERTY, { name: 'color' }); + expect(s.build().problems[rename]).toBe('"color" is a sprite property'); + }); + + it('is a colour, so it cannot feed the Vertex output', () => { + const s = shader(); + const corner = s.add(SHADER_VERTEX_OUTPUT); + const tint = s.add(SHADER_COLOR_PROPERTY, { name: 'tint' }); + const bright = s.add('ShaderLuminance'); + s.link(tint, 'color', bright, 'color'); + s.link(bright, 'value', corner, 'x'); + const build = s.build(); + expect(build.compiled).toBeNull(); + expect(build.problems[corner]).toBe(perCornerProblem('Color property')); + }); + + it('is placed from the palette as tint, then tint2', () => { + const s = shader(); + const entry = () => + paletteEntries(s.f.project, s.asset()).find((e) => e.def.type === SHADER_COLOR_PROPERTY)!; + expect(entry().params).toEqual({ name: 'tint' }); + s.add(SHADER_COLOR_PROPERTY, entry().params); + expect(entry().params).toEqual({ name: 'tint2' }); + }); + + it('gives a sprite wearing it a colour row that survives a save', () => { + const s = shader(); + s.add(SHADER_COLOR_PROPERTY, { name: 'tint', default: 0x112233 }); + s.f.stack.execute(new SetAssetPropertyCommand(s.f.spriteId, SHADER_KEY, s.id)); + const sprite = () => findAssetOf(s.f.project, s.f.spriteId, 'sprite')!; + expect(schemaForAsset(s.f.project, sprite()).find((d) => d.key === 'tint')).toMatchObject({ + kind: 'color', + default: 0x112233, + }); + s.f.stack.execute(new SetAssetPropertyCommand(s.f.spriteId, 'tint', 0xe63c3c)); + const loaded = findAssetOf(roundTrip(s.f.project), s.f.spriteId, 'sprite')!; + expect(loaded.props.tint).toBe(0xe63c3c); + }); + + it('starts the preview at the sprite’s colour when it wears the shader, else at the default', () => { + const s = shader(); + s.add(SHADER_COLOR_PROPERTY, { name: 'tint', default: 0x112233 }); + const defs = shaderParamDefs(s.asset().graph); + expect(startValues(defs, { [SHADER_KEY]: s.id, tint: '#e63c3c' }, s.id)).toEqual({ tint: 0xe63c3c }); + expect(startValues(defs, { [SHADER_KEY]: 'other', tint: 0xe63c3c }, s.id)).toEqual({ tint: 0x112233 }); + expect(startValues(defs, { [SHADER_KEY]: s.id }, s.id)).toEqual({ tint: 0x112233 }); + }); +}); diff --git a/tests/core/shader-vertex.test.ts b/tests/core/shader-vertex.test.ts index 982b2160..cd5f6717 100644 --- a/tests/core/shader-vertex.test.ts +++ b/tests/core/shader-vertex.test.ts @@ -83,7 +83,7 @@ describe('Vertex output (#79)', () => { 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!.uniforms).toEqual([{ name: 'amount', kind: 'number', 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. diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index f92bd26a..35909399 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -215,9 +215,9 @@ describe('compileShader', () => { expect(build.reason).toBe(''); const compiled = build.compiled!; expect(compiled.uniforms).toEqual([ - { name: 'brightness', default: 1 }, - { name: 'contrast', default: 1 }, - { name: 'saturation', default: 1 }, + { name: 'brightness', kind: 'number', default: 1 }, + { name: 'contrast', kind: 'number', default: 1 }, + { name: 'saturation', kind: 'number', default: 1 }, ]); const { fragment } = compiled; for (const name of ['brightness', 'contrast', 'saturation']) diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 8c9d236b..466aad58 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -925,6 +925,29 @@ export async function shader(t) { { perCorner, perCornerReadout }, ); + // 28l-v. A Color property is a colour row on the sprite and a vec3 uniform (#298): unset, the + // sprite shows the declared default; set on the sprite, that colour. + await graph( + [ + node('tint', 'ShaderColorProperty', { name: 'tint', default: 0x3cc85a }), + node('out', 'ShaderOutput', {}, 780, 0), + ], + [edge('tint', 'color', 'out', 'color')], + ); + const tinted = async () => ({ a: await pixel(-0.3, -0.3), b: await pixel(0.3, 0.3) }); + const unsetTint = await tinted(); + await assetProps({ tint: 0xe63c3c }); + const setTint = await tinted(); + check( + 'a Color property draws its default, and the colour the sprite sets once it sets one', + colourDistance(unsetTint.a, '#3cc85a') < 12 && + colourDistance(unsetTint.b, '#3cc85a') < 12 && + colourDistance(setTint.a, '#e63c3c') < 12 && + colourDistance(setTint.b, '#e63c3c') < 12, + { unsetTint, setTint }, + ); + await mutate(`delete __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.tint`); + // 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");