Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
game-maker / suite (push) Failing after 3m59s

A shader is an asset carrying a node graph (Texture color, Output, Property,
Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL
fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite
wears a shader through its Shader picker, and the rows below it are the
graph's Property nodes. Property schema, resolution and deviation take the
project because a sprite's rows now live in another asset. The built-in
registry of the prototype is gone; "Adjust colors" ships as a graph in the
fixture and the three example games.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-12 22:41:14 +02:00
1 parent 6141d07d4f
commit 42cafa7543
62 files changed
+1682 -144

No files matched your search

+42 -7
View File
@@ -19,7 +19,10 @@ import {
type ValueOf,
type VariableKind,
} from './kinds';
import { findAssetOf, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { flatListOf, listItemOf } from './listItems';
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
/**
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
@@ -124,6 +127,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
// The fill while there is no picture, and a tint multiplier once there is one: white = no tint.
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
// The shader's own parameters follow this row – see `schemaForAsset`. Empty is no shader at all.
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
],
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
texture: [
@@ -157,6 +162,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
// The states are the asset's graph (see StateMachineAsset), not a property.
statemachine: [],
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
shader: [],
collider: [
{
key: 'shape',
@@ -204,15 +211,43 @@ export function schemaFor(type: AssetType): PropertySchema {
return FULL_SCHEMAS[type];
}
/** The type schema, for behaviors followed by their declared variables. */
export function schemaForAsset(asset: Asset): PropertySchema {
return asset.type === 'behavior'
? [...FULL_SCHEMAS.behavior, ...asset.variables]
: FULL_SCHEMAS[asset.type];
/**
* The type schema, for behaviors followed by their declared variables, for sprites by the
* parameters of the shader asset their bag names – which is why the project is needed.
*/
export function schemaForAsset(project: Project, asset: Asset): PropertySchema {
return schemaForBag(project, asset, asset.props);
}
export function propDef(asset: Asset, key: string): PropertyDef | undefined {
return schemaForAsset(asset).find((d) => d.key === key);
/** A placed object's rows: a sprite overriding its shader has that shader's parameters. */
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
const shader = object.overrides[SHADER_KEY];
return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
}
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
return FULL_SCHEMAS[asset.type];
}
/** The rows a shader asset adds to the sprites wearing it; a dangling id adds none. */
export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] {
const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined;
return shader ? shaderParamDefs(shader.graph) : [];
}
/** The parameters of every shader in the project, one row per name – what a sprite *may* have. */
export function allShaderParamRows(project: Project): PropertyDef[] {
const rows = new Map<string, PropertyDef>();
for (const asset of project.assets)
if (asset.type === 'shader')
for (const row of shaderParamDefs(asset.graph)) if (!rows.has(row.key)) rows.set(row.key, row);
return [...rows.values()];
}
export function propDef(project: Project, asset: Asset, key: string): PropertyDef | undefined {
return schemaForAsset(project, asset).find((d) => d.key === key);
}
/** A fresh bag of the type's default values (callers may mutate it). */
+8 -7
View File
@@ -3,20 +3,20 @@ import { ancestorIdsOf, type ObjectTree } from '../model/hierarchy';
import { instanceTargetOf, isEmptyEdit, resolveObjects } from '../model/instance';
import { findAsset, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { type PropertySchema, schemaFor, schemaForAsset } from './PropertySchema';
import { type PropertySchema, schemaFor, schemaForAsset, schemaForObject } from './PropertySchema';
import { deviatesFromAsset, isModified } from './resolve';
/**
* The rows the asset inspector marks as modified. A behavior's variables are left out: their
* default is declared on the variable itself, so the asset value cannot differ from it.
*/
export function markedAssetSchema(asset: Asset): PropertySchema {
return asset.type === 'behavior' ? schemaFor('behavior') : schemaForAsset(asset);
export function markedAssetSchema(project: Project, asset: Asset): PropertySchema {
return asset.type === 'behavior' ? schemaFor('behavior') : schemaForAsset(project, asset);
}
/** Whether any asset property the inspector would mark gold differs from its default. */
export function assetDeviates(asset: Asset): boolean {
return markedAssetSchema(asset).some((def) => isModified(asset, def.key));
export function assetDeviates(project: Project, asset: Asset): boolean {
return markedAssetSchema(project, asset).some((def) => isModified(project, asset, def.key));
}
/**
@@ -33,8 +33,9 @@ export function objectDeviates(project: Project, holder: ObjectTree, object: Sce
}
if (object.editorOffset.x !== 0 || object.editorOffset.y !== 0) return true;
const asset = findAsset(project, object.assetId);
return (
asset !== undefined && schemaForAsset(asset).some((def) => deviatesFromAsset(object, asset, def.key))
if (!asset) return false;
return schemaForObject(project, asset, object).some((def) =>
deviatesFromAsset(project, object, asset, def.key),
);
}
+1 -1
View File
@@ -16,6 +16,6 @@ export function runsInEditor(props: PropertyBag): boolean {
export function hasEditorRun(project: Project, holder: ObjectTree): boolean {
return resolveObjects(project, holder).some((object) => {
const asset = findAsset(project, object.assetId);
return asset?.type === 'behavior' && runsInEditor(resolveSceneObjectProps(asset, object));
return asset?.type === 'behavior' && runsInEditor(resolveSceneObjectProps(project, asset, object));
});
}
+30 -18
View File
@@ -1,48 +1,60 @@
import type { Asset } from '../model/Asset';
import { type PropertyBag, type PropertyValue, sameValue } from '../model/Properties';
import type { Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { propDef, schemaForAsset } from './PropertySchema';
import { propDef, schemaForAsset, schemaForObject } from './PropertySchema';
/**
* Schema defaults filled with the asset's own values. Keys unknown to the schema are ignored, so
* stale data never leaks into the editor.
*/
export function resolveAssetProps(asset: Asset): PropertyBag {
const bag: PropertyBag = {};
for (const def of schemaForAsset(asset)) {
const own = asset.props[def.key];
bag[def.key] = own !== undefined ? own : def.default;
}
return bag;
export function resolveAssetProps(project: Project, asset: Asset): PropertyBag {
return valuesOver(schemaForAsset(project, asset), asset.props);
}
/** The only place where overrides are merged onto asset values. */
export function resolveSceneObjectProps(asset: Asset, object: SceneObject): PropertyBag {
const bag = resolveAssetProps(asset);
for (const def of schemaForAsset(asset)) {
/**
* The only place where overrides are merged onto asset values. The rows are the object's: a
* sprite overriding its shader has that shader's parameters, not the asset's.
*/
export function resolveSceneObjectProps(project: Project, asset: Asset, object: SceneObject): PropertyBag {
const schema = schemaForObject(project, asset, object);
const bag = valuesOver(schema, asset.props);
for (const def of schema) {
const override = object.overrides[def.key];
if (override !== undefined) bag[def.key] = override;
}
return bag;
}
function valuesOver(
schema: readonly { key: string; default: PropertyValue }[],
values: PropertyBag,
): PropertyBag {
const bag: PropertyBag = {};
for (const def of schema) {
const own = values[def.key];
bag[def.key] = own !== undefined ? own : def.default;
}
return bag;
}
/** Whether the asset's own value differs from the schema default – the asset-level override. */
export function isModified(asset: Asset, key: string): boolean {
const def = propDef(asset, key);
export function isModified(project: Project, asset: Asset, key: string): boolean {
const def = propDef(project, asset, key);
if (!def) return false;
const own = asset.props[key];
return own !== undefined && !sameValue(own, def.default);
}
/** The value the object would have without its override for `key`. */
export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined {
return resolveAssetProps(asset)[key];
export function inheritedValue(project: Project, asset: Asset, key: string): PropertyValue | undefined {
return resolveAssetProps(project, asset)[key];
}
/** An override that happens to carry the asset's own value does not count as a deviation. */
export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string): boolean {
export function deviatesFromAsset(project: Project, object: SceneObject, asset: Asset, key: string): boolean {
const own = object.overrides[key];
return own !== undefined && !sameValue(own, inheritedValue(asset, key));
return own !== undefined && !sameValue(own, inheritedValue(project, asset, key));
}
/**
+82
View File
@@ -0,0 +1,82 @@
import type { Graph, GraphNode } from '../graph/Graph';
import { type PropertyBag, textOf } from '../model/Properties';
import { type FlatDef, isValidFieldName } from './PropertySchema';
/** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */
export const SHADER_KEY = 'shader';
/**
* The node of a shader graph that declares a parameter: its `name` is a sprite property and a
* GLSL uniform, `default`/`min`/`max` are the row the inspector shows.
*/
export const SHADER_PROPERTY = 'ShaderProperty';
export const PROPERTY_NAME_PARAM = 'name';
export const PROPERTY_DEFAULT_PARAM = 'default';
export const PROPERTY_MIN_PARAM = 'min';
export const PROPERTY_MAX_PARAM = 'max';
export function shaderOf(bag: PropertyBag): string {
return textOf(bag[SHADER_KEY]);
}
export function isPropertyNode(node: GraphNode): boolean {
return node.type === SHADER_PROPERTY;
}
export function propertyName(node: GraphNode): string {
return textOf(node.params[PROPERTY_NAME_PARAM] ?? '');
}
/** A property's name is a bag key and a uniform, so it has to be an identifier. */
export function isValidPropertyName(name: string): boolean {
return isValidFieldName(name);
}
/**
* The properties a shader graph declares, in node order: one number row per `Property` node with
* a valid name. A second node with the same name declares nothing new – both read one uniform.
*/
export function shaderParamDefs(graph: Graph): FlatDef<'number'>[] {
const rows: FlatDef<'number'>[] = [];
const seen = new Set<string>();
for (const node of graph.nodes) {
if (!isPropertyNode(node)) continue;
const name = propertyName(node);
if (!isValidPropertyName(name) || seen.has(name)) continue;
seen.add(name);
rows.push(propertyRow(name, node.params));
}
return rows;
}
function propertyRow(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'> = {
key: name,
label: name,
kind: 'number',
default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0),
min,
step: niceStep(max > min ? max - min : 1),
};
// A max at or below the min would pin the value; treated as "no max" instead.
return max > min ? { ...row, max } : row;
}
function numberParam(params: PropertyBag, key: string, fallback: number): number {
const n = Number(params[key]);
return Number.isFinite(n) ? n : fallback;
}
/** A twentieth of the range's power of ten: 0.05 for 0-4, 0.05 for 0-1, 5 for 0-100. */
function niceStep(range: number): number {
return 10 ** Math.floor(Math.log10(range)) / 20;
}
/** What a property placed from the palette is called: `value`, then `value2`, `value3` … */
export function nextPropertyName(graph: Graph): 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}`;
}