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 <noreply@anthropic.com>
This commit is contained in:
1 parent
4f4c149211
commit
bf27e42e8a
13 files changed
+303
-57
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, 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
|
||||
|
||||
@@ -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<R extends FlatDef>(
|
||||
graph: Graph,
|
||||
of: Declaration & { rowOf(name: string, node: GraphNode): R },
|
||||
): R[] {
|
||||
const rows: R[] = [];
|
||||
const seen = new Set<string>();
|
||||
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'> = {
|
||||
|
||||
@@ -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<ShaderParamDef>(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}`;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<ShaderAsset, GraphCanvas> {
|
||||
private readonly subjectLabel = new Label('', { style: 'small' });
|
||||
private readonly params = new VStack({ gap: Theme.gap, padding: Theme.padding });
|
||||
private readonly sliders = new Map<string, { slider: Slider; value: Label }>();
|
||||
/** What the sliders are set to – the preview's values, never anyone's rows. */
|
||||
private readonly colors = new Map<string, ColorField>();
|
||||
/** What the sliders and colour fields are set to – the preview's values, never anyone's rows. */
|
||||
private values: Record<string, number> = {};
|
||||
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<ShaderAsset, GraphCanvas> {
|
||||
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<ShaderAsset, GraphCanvas> {
|
||||
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<ShaderAsset, GraphCanvas> {
|
||||
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<ShaderAsset, GraphCanvas> {
|
||||
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. */
|
||||
|
||||
@@ -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<string, number> {
|
||||
const wears = shaderOf(props) === shaderId;
|
||||
const values: Record<string, number> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
+20
-9
@@ -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<Record<string, UniformFloat>> | 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<Record<string, UniformValue>> | 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<f32>' }
|
||||
: { 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<f32>' };
|
||||
|
||||
function readUniforms(
|
||||
into: Record<string, number>,
|
||||
into: Record<string, number | Float32Array>,
|
||||
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<string, number> | null;
|
||||
private readonly uniforms: Record<string, number | Float32Array> | null;
|
||||
private readonly compiled: CompiledShader;
|
||||
readonly fragment: string;
|
||||
/** The vertex stage this was built with; a particle field's never comes from the graph. */
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user