From 42cafa75439e83622b2144206fca3fe62ce36db3 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 12 Sep 2026 22:41:14 +0200 Subject: [PATCH] Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62) 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 --- README.md | 2 +- src/core/commands/applyCommands.ts | 4 +- src/core/commands/assetCommands.ts | 12 +- src/core/commands/colliderCommands.ts | 2 +- src/core/commands/require.ts | 13 +- src/core/commands/sceneObjectCommands.ts | 5 +- src/core/graph/Graph.ts | 8 +- src/core/graph/NodeRegistry.ts | 11 +- src/core/graph/nodes/index.ts | 5 +- src/core/model/Asset.ts | 16 +- src/core/model/AssetTypes.ts | 21 +- src/core/model/graphLookup.ts | 2 +- src/core/model/usages.ts | 2 +- src/core/properties/PropertySchema.ts | 49 +++- src/core/properties/deviation.ts | 15 +- src/core/properties/editorRun.ts | 2 +- src/core/properties/resolve.ts | 48 +-- src/core/properties/shaders.ts | 82 ++++++ src/core/shader/compile.ts | 143 +++++++++ src/core/shader/nodes.ts | 217 ++++++++++++++ src/core/store/serialize.ts | 41 ++- src/editor/AssetTabEditor.ts | 5 + src/editor/Editor.ts | 16 +- src/editor/debugHook.ts | 13 + src/editor/graph/GraphCanvas.ts | 8 +- src/editor/graph/NodeView.ts | 4 + src/editor/graph/paletteEntries.ts | 17 +- src/editor/inspector/aspectAction.ts | 11 +- src/editor/inspector/assetSection.ts | 20 +- src/editor/inspector/objectQueries.ts | 4 +- src/editor/inspector/objectSection.ts | 21 +- src/editor/inspector/portSection.ts | 8 +- src/editor/list/slot.ts | 13 +- src/editor/panels/AssetPanel.ts | 2 +- src/editor/panels/HierarchyPanel.ts | 2 +- src/editor/shader/ShaderEditor.ts | 44 +++ src/editor/state.ts | 7 +- src/editor/viewport/WorldViewport.ts | 6 +- src/runtime/World.ts | 4 +- src/ui/Theme.ts | 2 + src/viewport/ShaderLibrary.ts | 39 +++ src/viewport/items.ts | 2 +- src/viewport/shaders.ts | 81 ++++++ src/viewport/symbols.ts | 3 + tests/core/commands.test.ts | 1 + tests/core/deviation.test.ts | 12 +- tests/core/enabled.test.ts | 2 +- tests/core/graph.test.ts | 2 + tests/core/list-value.test.ts | 4 +- tests/core/resolve.test.ts | 31 +- tests/core/serialize.test.ts | 1 + tests/core/shaders.test.ts | 354 +++++++++++++++++++++++ tests/core/texture-commands.test.ts | 1 + tests/editor/editor-run.test.ts | 4 +- tests/editor/struct-field-edit.test.ts | 12 +- tools/e2e/28-shader.mjs | 302 +++++++++++++++++++ tools/e2e/index.mjs | 2 + tools/lib/shaders.mjs | 45 +++ tools/seed-actor.mjs | 3 + tools/seed-e2e.mjs | 3 + tools/seed-jumprun.mjs | 10 +- tools/seed-tetris.mjs | 5 +- 62 files changed, 1682 insertions(+), 144 deletions(-) create mode 100644 src/core/properties/shaders.ts create mode 100644 src/core/shader/compile.ts create mode 100644 src/core/shader/nodes.ts create mode 100644 src/editor/shader/ShaderEditor.ts create mode 100644 src/viewport/ShaderLibrary.ts create mode 100644 src/viewport/shaders.ts create mode 100644 tests/core/shaders.test.ts create mode 100644 tools/e2e/28-shader.mjs create mode 100644 tools/lib/shaders.mjs diff --git a/README.md b/README.md index fb9005be..b367ad86 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,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, 671 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 700 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/commands/applyCommands.ts b/src/core/commands/applyCommands.ts index a8aa8c8f..b16ac886 100644 --- a/src/core/commands/applyCommands.ts +++ b/src/core/commands/applyCommands.ts @@ -42,8 +42,8 @@ export class ApplyOverrideToAssetCommand extends ObjectEditCommand { protected override apply(ref: ObjectRef, project: Project): void { const asset = requireAsset(project, ref.object.assetId); - requirePropDef(asset, this.key); - const value = resolveSceneObjectProps(asset, ref.object)[this.key]; + requirePropDef(project, asset, this.key, ref.object); + const value = resolveSceneObjectProps(project, asset, ref.object)[this.key]; this.asset = asset; this.previousAssetValue = asset.props[this.key]; asset.props[this.key] = value!; diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index cb5d531e..c7143356 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -36,6 +36,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'animation', frames: [] }; case 'statemachine': return { ...base, type: 'statemachine', graph: createEmptyGraph() }; + case 'shader': + return { ...base, type: 'shader', graph: createEmptyGraph() }; } } @@ -57,7 +59,7 @@ export class CreateAssetCommand implements Command { execute(project: Project): void { for (const [key, value] of Object.entries(this.props)) - this.asset.props[key] = coerceValue(requirePropDef(this.asset, key), value); + this.asset.props[key] = coerceValue(requirePropDef(project, this.asset, key), value); project.assets.push(this.asset); this.previousActiveScene = project.activeSceneId; if (this.asset.type === 'scene' && !project.activeSceneId) project.activeSceneId = this.asset.id; @@ -195,8 +197,8 @@ export class SetAssetPropertyCommand extends AssetPropertyCommand { super(assetId, key, `Set ${key}`); } - protected override computeNext(asset: Asset): PropertyValue { - return coerceValue(requirePropDef(asset, this.key), this.value); + protected override computeNext(asset: Asset, project: Project): PropertyValue { + return coerceValue(requirePropDef(project, asset, this.key), this.value); } } @@ -205,8 +207,8 @@ export class ResetAssetPropertyCommand extends AssetPropertyCommand { super(assetId, key, `Reset ${key}`); } - protected override computeNext(asset: Asset): PropertyValue { - return requirePropDef(asset, this.key).default; + protected override computeNext(asset: Asset, project: Project): PropertyValue { + return requirePropDef(project, asset, this.key).default; } } diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index ec342d1a..624d36fc 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -37,7 +37,7 @@ export class AddColliderCommand implements Command { this.assetId, ); if (targetAsset.type === 'sprite') { - const props = resolveSceneObjectProps(targetAsset, target); + const props = resolveSceneObjectProps(project, targetAsset, target); asset.props.width = Number(props.width); asset.props.height = Number(props.height); } diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index ddffad77..d249dcc0 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -13,7 +13,7 @@ import { type Project, } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; -import { propDef, type PropertyDef } from '../properties/PropertySchema'; +import { type PropertyDef, schemaForAsset, schemaForObject } from '../properties/PropertySchema'; import { CommandError } from './Command'; export function requireAsset(project: Project, assetId: string): Asset { @@ -91,8 +91,15 @@ export function requireNodeDef(type: string): NodeDef { return def; } -export function requirePropDef(asset: Asset, key: string): PropertyDef { - const def = propDef(asset, key); +/** The row `key` names on the asset – or, given the placed object, on that object (its shader's rows). */ +export function requirePropDef( + project: Project, + asset: Asset, + key: string, + object?: SceneObject, +): PropertyDef { + const schema = object ? schemaForObject(project, asset, object) : schemaForAsset(project, asset); + const def = schema.find((d) => d.key === key); if (!def) throw new CommandError(`Property "${key}" is not defined for ${asset.type}`); return def; } diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index 3568549c..e949fee2 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -189,7 +189,10 @@ export class SetOverrideCommand extends OverrideCommand { } protected override nextValue(object: SceneObject, project: Project): PropertyValue { - return coerceValue(requirePropDef(requireAsset(project, object.assetId), this.key), this.value); + return coerceValue( + requirePropDef(project, requireAsset(project, object.assetId), this.key, object), + this.value, + ); } } diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 92f41c38..529e7113 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -3,8 +3,12 @@ import { type DeclaredPortKind, KIND } from '../properties/kinds'; export type { DeclaredPortKind } from '../properties/kinds'; -/** `exec` carries control flow, the others values; `any` accepts every value type. */ -export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'any'; +/** + * `exec` carries control flow, the others values; `any` accepts every value type. `vec3` is a + * colour inside a shader graph, where the values are GLSL and no behavior node ever sees one. + */ +export type PortType = + 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'vec3' | 'any'; export interface PortRef { node: string; diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 9a8bc61d..a62bb3b8 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -16,8 +16,11 @@ export interface PortDef { field?: PropertyDef; } -/** `state` is the one category no behavior graph offers: its nodes are the states of a state machine. */ -export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action' | 'state'; +/** + * `state` and `shader` are the categories no behavior graph offers: their nodes are the states of + * a state machine and the GLSL of a shader asset. + */ +export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader'; /** * How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body, @@ -134,13 +137,15 @@ export function hasExecInput(def: NodeDef): boolean { /** * Data inputs that may carry an inline value while unconnected. An object is a live handle and a - * struct is assembled from its parts, so neither has a value that could be typed on one line. + * struct is assembled from its parts, so neither has a value that could be typed on one line; a + * shader colour has one only where the node gives it a colour field. */ export function inlineInputs(def: NodeDef): PortDef[] { return def.inputs.filter(isInlinePort); } function isInlinePort(port: PortDef): boolean { + if (port.type === 'vec3') return port.field !== undefined; return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct'; } diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index f55750ad..289be162 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -88,6 +88,7 @@ import { RunBehavior } from './flow/RunBehavior'; import { Sequence } from './flow/Sequence'; import { SetState } from './flow/SetState'; import { StateBranch } from './flow/StateBranch'; +import { SHADER_NODES } from '../../shader/nodes'; import { State } from '../stateMachine'; import { Variables } from './variable/Variables'; @@ -187,5 +188,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ ]; for (const def of BUILT_IN_NODES) registerNode(def); -// Registered so a machine's graph loads and draws, but not built in: no behavior graph offers it. +// Registered so a machine's or a shader's graph loads and draws, but not built in: no behavior +// graph offers them. registerNode(State); +for (const def of SHADER_NODES) registerNode(def); diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 27089004..c5aacb6e 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -91,6 +91,15 @@ export interface StateMachineAsset extends AssetBase { graph: Graph; } +/** + * The graph is GLSL in nodes: `Property` nodes declare the sprite rows, the `Output` node ends + * the fragment (see `core/shader/compile.ts`). A sprite wears it through its `shader` property. + */ +export interface ShaderAsset extends AssetBase { + type: 'shader'; + graph: Graph; +} + export type Asset = | SpriteAsset | CameraAsset @@ -101,13 +110,14 @@ export type Asset = | ContainerAsset | TextureAsset | AnimationAsset - | StateMachineAsset; + | StateMachineAsset + | ShaderAsset; /** The assets edited as a node graph – what the graph canvas and the graph commands work on. */ -export type GraphAsset = BehaviorAsset | StateMachineAsset; +export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset; export function isGraphAsset(asset: Asset): asset is GraphAsset { - return asset.type === 'behavior' || asset.type === 'statemachine'; + return asset.type === 'behavior' || asset.type === 'statemachine' || asset.type === 'shader'; } export function isSceneAsset(asset: Asset): asset is SceneAsset { diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 253cf3b1..600e7aa1 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -9,6 +9,7 @@ export const ASSET_TYPES = [ 'texture', 'animation', 'statemachine', + 'shader', ] as const; export type AssetType = (typeof ASSET_TYPES)[number]; @@ -24,16 +25,28 @@ export const ASSET_TYPE_LABELS: Record = { texture: 'Texture', animation: 'Animation', statemachine: 'State machine', + shader: 'Shader', }; /** - * Textures and animations are not placeable: they are pictures a sprite wears through `texture`. - * A state machine is not either: a behavior refers to it by id. + * Textures and animations are not placeable: they are pictures a sprite wears through `texture`, + * as a shader is worn through `shader`. A state machine is not either: a behavior refers to it by id. */ -export type PlaceableAssetType = Exclude; +export type PlaceableAssetType = Exclude< + AssetType, + 'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' +>; + +const NOT_PLACEABLE: ReadonlySet = new Set([ + 'scene', + 'texture', + 'animation', + 'statemachine', + 'shader', +]); export function isPlaceableType(type: AssetType): type is PlaceableAssetType { - return type !== 'scene' && type !== 'texture' && type !== 'animation' && type !== 'statemachine'; + return !NOT_PLACEABLE.has(type); } export function isAssetType(value: unknown): value is AssetType { diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index 43539ffd..48e852de 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -12,7 +12,7 @@ export function graphLookup(project: Project): GraphLookup { behavior: (assetId) => { const asset = findAssetOf(project, assetId, 'behavior'); if (!asset) return null; - return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) }; + return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(project, asset) }; }, machine: (assetId) => { const asset = findAssetOf(project, assetId, 'statemachine'); diff --git a/src/core/model/usages.ts b/src/core/model/usages.ts index 9ce0ec1e..f7ff23ef 100644 --- a/src/core/model/usages.ts +++ b/src/core/model/usages.ts @@ -48,7 +48,7 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri const refKeys = new Map(); const matches = (value: unknown): boolean => typeof value === 'string' && wanted.has(value); for (const asset of project.assets) { - const keys = schemaForAsset(asset) + const keys = schemaForAsset(project, asset) .filter((def) => isKindWith('reference', def.kind)) .map((def) => def.key); refKeys.set(asset.id, keys); diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 158cadc8..68019b0d 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { // 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 = { 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(); + 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). */ diff --git a/src/core/properties/deviation.ts b/src/core/properties/deviation.ts index 2abb2794..f6619933 100644 --- a/src/core/properties/deviation.ts +++ b/src/core/properties/deviation.ts @@ -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), ); } diff --git a/src/core/properties/editorRun.ts b/src/core/properties/editorRun.ts index 9cb37c9e..cd7792c8 100644 --- a/src/core/properties/editorRun.ts +++ b/src/core/properties/editorRun.ts @@ -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)); }); } diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts index 87a758aa..9b325d93 100644 --- a/src/core/properties/resolve.ts +++ b/src/core/properties/resolve.ts @@ -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)); } /** diff --git a/src/core/properties/shaders.ts b/src/core/properties/shaders.ts new file mode 100644 index 00000000..2b573fb0 --- /dev/null +++ b/src/core/properties/shaders.ts @@ -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(); + 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}`; +} diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts new file mode 100644 index 00000000..3df0c405 --- /dev/null +++ b/src/core/shader/compile.ts @@ -0,0 +1,143 @@ +import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph'; +import { nodeDef, type PortDef } from '../graph/NodeRegistry'; +import { colorOf } from '../model/Properties'; +import { isPropertyNode, isValidPropertyName, propertyName, shaderParamDefs } from '../properties/shaders'; +import { + asVec3, + type Expr, + floatLiteral, + isShaderNodeDef, + SHADER_OUTPUT, + TEXEL_COLOR, + uniformName, + vec3Literal, +} from './nodes'; + +export interface ShaderUniform { + name: string; + default: number; +} + +export interface CompiledShader { + fragment: string; + uniforms: ShaderUniform[]; +} + +export interface ShaderBuild { + /** Null while the graph has a problem – the sprite then draws without a filter. */ + compiled: CompiledShader | null; + /** Per node id, what keeps the graph from compiling. */ + problems: Record; + /** One line for the tab: empty once compiled. */ + reason: string; +} + +/** + * A shader graph to a Pixi filter fragment: every node reachable from the Output becomes one + * local per output, in dependency order, and every Property a float uniform. Pixi hands a filter + * premultiplied pixels, so the prelude unpremultiplies and the epilogue multiplies back. + */ +export function compileShader(graph: Graph): ShaderBuild { + const problems: Record = {}; + const problem = (node: GraphNode, text: string) => { + if (!(node.id in problems)) problems[node.id] = text; + }; + + const names = new Set(); + for (const node of graph.nodes) { + if (!isPropertyNode(node)) continue; + const name = propertyName(node); + if (!isValidPropertyName(name)) problem(node, 'Name must be one word: letters, digits, _'); + else if (names.has(name)) problem(node, `Another property is named "${name}"`); + names.add(name); + } + + const lines: string[] = []; + const emitted = new Map>(); + const visiting = new Set(); + let count = 0; + + const emitNode = (node: GraphNode): Record | null => { + const done = emitted.get(node.id); + if (done) return done; + if (visiting.has(node.id)) { + problem(node, 'Feeds itself'); + return null; + } + const def = nodeDef(node.type); + if (!def || !isShaderNodeDef(def)) { + problem(node, 'Not a shader node'); + return null; + } + visiting.add(node.id); + const inputs: Record = {}; + let complete = true; + for (const port of def.inputs) { + const expr = input(node, port); + if (expr) inputs[port.key] = expr; + else complete = false; + } + visiting.delete(node.id); + if (!complete) return null; + const outputs: Record = {}; + for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) { + const local = `n${count++}_${key}`; + lines.push(`${expr.type} ${local} = ${expr.code};`); + outputs[key] = { code: local, type: expr.type }; + } + emitted.set(node.id, outputs); + return outputs; + }; + + /** What feeds an input: the edge's source, the typed-in value, or – with nothing to type – the texel. */ + const input = (node: GraphNode, port: PortDef): Expr | null => { + const edge = edgesInto(graph, node.id, port.key)[0]; + if (edge) { + const source = findNode(graph, edge.from.node); + const outputs = source && emitNode(source); + const expr = outputs?.[edge.from.port]; + if (!expr) return null; + return conform(node, port, expr); + } + 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 TEXEL_COLOR; + }; + + const conform = (node: GraphNode, port: PortDef, expr: Expr): Expr | null => { + if (port.type === 'number' && expr.type === 'vec3') { + problem(node, `"${port.key}" needs a number, not a colour`); + return null; + } + if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' }; + return expr; + }; + + const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT); + if (!output) return { compiled: null, problems, reason: 'No Output node' }; + const outputDef = nodeDef(SHADER_OUTPUT)!; + const color = input(output, outputDef.inputs[0]!); + + const first = graph.nodes.find((n) => n.id in problems); + if (first || !color) + return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' }; + + const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default })); + const fragment = [ + 'in vec2 vTextureCoord;', + 'out vec4 finalColor;', + 'uniform sampler2D uTexture;', + ...uniforms.map((u) => `uniform float ${uniformName(u.name)};`), + 'void main()', + '{', + ' vec4 texel = texture(uTexture, vTextureCoord);', + ` vec3 ${TEXEL_COLOR.code} = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;`, + ...lines.map((line) => ` ${line}`), + ` finalColor = vec4(clamp(${color.code}, 0.0, 1.0) * texel.a, texel.a);`, + '}', + '', + ].join('\n'); + return { compiled: { fragment, uniforms }, problems, reason: '' }; +} diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts new file mode 100644 index 00000000..14c74b4f --- /dev/null +++ b/src/core/shader/nodes.ts @@ -0,0 +1,217 @@ +import type { NodeDef, PortDef } from '../graph/NodeRegistry'; +import { type PropertyBag, textOf } from '../model/Properties'; +import { flatDef } from '../properties/PropertySchema'; +import { + PROPERTY_DEFAULT_PARAM, + PROPERTY_MAX_PARAM, + PROPERTY_MIN_PARAM, + PROPERTY_NAME_PARAM, + propertyName, + SHADER_PROPERTY, +} from '../properties/shaders'; + +export type GlslType = 'float' | 'vec3'; + +/** A GLSL expression and what it evaluates to. */ +export interface Expr { + code: string; + type: GlslType; +} + +/** + * A node of a shader graph. It never runs: `emit` writes the GLSL of its outputs from the GLSL of + * its inputs. An input typed `number` arrives as a float, one typed `vec3` as a vec3 (a float is + * broadcast), one typed `any` as whatever feeds it. + */ +export interface ShaderNodeDef extends NodeDef { + category: 'shader'; + emit(inputs: Record, params: PropertyBag): Record; +} + +export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef { + return def.category === 'shader' && 'emit' in def; +} + +export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor'; +export const SHADER_OUTPUT = 'ShaderOutput'; +export const SHADER_COLOR = 'ShaderColor'; + +/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */ +export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' }; + +const NUMBER_STEP = 0.05; + +const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' }); + +/** A number input with an inline value, so a constant needs no node of its own. */ +const floatIn = (key: string, value: number): PortDef => ({ + key, + label: key, + type: 'number', + default: value, + field: { key, label: key, kind: 'number', default: value, step: NUMBER_STEP }, +}); + +/** An input taking a number or a colour; typed in, it is a number. */ +const anyIn = (key: string, value: number): PortDef => ({ ...floatIn(key, value), type: 'any' }); + +const anyOut = (key: string): PortDef => ({ key, label: key, type: 'any' }); + +export const asVec3 = (e: Expr): string => (e.type === 'vec3' ? e.code : `vec3(${e.code})`); + +/** Two operands lifted to one type: a colour on either side makes the result a colour. */ +function lifted(a: Expr, b: Expr): { a: string; b: string; type: GlslType } { + if (a.type === 'vec3' || b.type === 'vec3') return { a: asVec3(a), b: asVec3(b), type: 'vec3' }; + return { a: a.code, b: b.code, type: 'float' }; +} + +function arithmetic( + type: string, + label: string, + op: string, + description: string, + bDefault: number, +): ShaderNodeDef { + return { + type, + label, + category: 'shader', + description, + inputs: [anyIn('a', bDefault === 1 ? 1 : 0), anyIn('b', bDefault)], + outputs: [anyOut('result')], + params: [], + emit: ({ a, b }) => { + const l = lifted(a!, b!); + return { result: { code: `(${l.a} ${op} ${l.b})`, type: l.type } }; + }, + }; +} + +export const ShaderTextureColor: ShaderNodeDef = { + type: SHADER_TEXTURE_COLOR, + label: 'Texture color', + category: 'shader', + description: "The sprite's own pixel, before the shader: its texture tinted by its color.", + inputs: [], + outputs: [vec3Port('color')], + params: [], + emit: () => ({ color: TEXEL_COLOR }), +}; + +/** The one node the compiler starts from; an unconnected color leaves the pixel as it is. */ +export const ShaderOutput: ShaderNodeDef = { + type: SHADER_OUTPUT, + label: 'Output', + category: 'shader', + description: 'The colour the sprite is drawn with. Its alpha stays the texture’s.', + inputs: [vec3Port('color')], + outputs: [], + params: [], + emit: () => ({}), +}; + +/** `name` is the sprite property and the uniform; the numbers are the row the inspector shows. */ +export const ShaderProperty: ShaderNodeDef = { + type: SHADER_PROPERTY, + label: 'Property', + category: 'shader', + description: 'A number the sprite sets: a property row below the Shader dropdown, and a value here.', + inputs: [], + outputs: [{ key: 'value', label: 'value', type: 'number' }], + params: [ + flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }), + { key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP }, + { key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP }, + { key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP }, + ], + derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderProperty.label }), + emit: (_inputs, params) => ({ + value: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'float' }, + }), +}; + +export function uniformName(property: string): string { + return `u_${property}`; +} + +export const ShaderColor: ShaderNodeDef = { + type: SHADER_COLOR, + label: 'Color', + category: 'shader', + description: 'A constant colour.', + inputs: [], + outputs: [vec3Port('color')], + params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], + emit: (_inputs, params) => ({ + color: { code: vec3Literal(Number(params.value ?? 0xffffff)), type: 'vec3' }, + }), +}; + +export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colours.', 0); +export const ShaderSubtract = arithmetic( + 'ShaderSubtract', + 'Subtract', + '-', + 'a - b, per channel for colours.', + 0, +); +export const ShaderMultiply = arithmetic( + 'ShaderMultiply', + 'Multiply', + '*', + 'a × b, per channel for colours.', + 1, +); +export const ShaderDivide = arithmetic('ShaderDivide', 'Divide', '/', 'a / b, per channel for colours.', 1); + +export const ShaderMix: ShaderNodeDef = { + type: 'ShaderMix', + label: 'Mix', + category: 'shader', + description: 'Blends from a to b: t = 0 is a, t = 1 is b, beyond that it extrapolates.', + inputs: [anyIn('a', 0), anyIn('b', 1), floatIn('t', 0.5)], + outputs: [anyOut('result')], + params: [], + emit: ({ a, b, t }) => { + const l = lifted(a!, b!); + return { result: { code: `mix(${l.a}, ${l.b}, ${t!.code})`, type: l.type } }; + }, +}; + +export const ShaderLuminance: ShaderNodeDef = { + type: 'ShaderLuminance', + label: 'Luminance', + category: 'shader', + description: 'How bright a colour is (Rec. 709 weights), as one number.', + inputs: [vec3Port('color')], + outputs: [{ key: 'value', label: 'value', type: 'number' }], + params: [], + emit: ({ color }) => ({ + value: { code: `dot(${color!.code}, vec3(0.2126, 0.7152, 0.0722))`, type: 'float' }, + }), +}; + +/** Every node a shader graph offers. Registered by `graph/nodes`, offered by no behavior palette. */ +export const SHADER_NODES: readonly ShaderNodeDef[] = [ + ShaderTextureColor, + ShaderOutput, + ShaderProperty, + ShaderColor, + ShaderAdd, + ShaderSubtract, + ShaderMultiply, + ShaderDivide, + ShaderMix, + ShaderLuminance, +]; + +/** A GLSL float literal: always with a decimal point, so `1` does not become an int. */ +export function floatLiteral(n: number): string { + if (!Number.isFinite(n)) return '0.0'; + return Number.isInteger(n) ? n.toFixed(1) : String(n); +} + +export function vec3Literal(packed: number): string { + const channel = (shift: number) => ((packed >> shift) & 0xff) / 255; + return `vec3(${channel(16).toFixed(4)}, ${channel(8).toFixed(4)}, ${channel(0).toFixed(4)})`; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 117b31b9..f1c1b392 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -8,9 +8,11 @@ import { type GraphNode, isValidPortName, fixedPortKeys, + type NodeDef, nodeDef, STATE, } from '../graph'; +import { isShaderNodeDef } from '../shader/nodes'; import type { Asset, TextureFrame } from '../model/Asset'; import { isAssetType } from '../model/AssetTypes'; import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance'; @@ -45,6 +47,7 @@ import { type StructFieldDef, variableDef, } from '../properties/PropertySchema'; +import { SHADER_KEY, shaderParamDefs } from '../properties/shaders'; /** * Turns untrusted JSON (server file, import) into a well-formed Project: unknown keys are dropped, @@ -57,16 +60,33 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { // Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2. const schemas = new Map(); const variables = new Map(); + const rawProps = new Map>(); + const shaderGraphs = new Map(); + const shaderRows = new Map(); for (const a of rawAssets) { if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; + rawProps.set(a.id, asRecord(a.props)); if (a.type === 'behavior') { const vars = normalizeVariables(a.variables); variables.set(a.id, vars); schemas.set(a.id, [...COMMON_PROPS, ...vars]); } else { + if (a.type === 'shader') { + const graph = normalizeGraph(a.graph, isShaderNodeDef); + shaderGraphs.set(a.id, graph); + shaderRows.set(a.id, shaderParamDefs(graph)); + } schemas.set(a.id, schemaFor(a.type)); } } + /** A sprite's rows end with the parameters of the shader its bag names – an override's, else the asset's. */ + const schemaOf = (assetId: string, bag: Record): PropertySchema | undefined => { + const schema = schemas.get(assetId); + if (!schema || schema !== schemaFor('sprite')) return schema; + const own = bag[SHADER_KEY]; + const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY]; + return [...schema, ...(shaderRows.get(typeof shader === 'string' ? shader : '') ?? [])]; + }; // Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture` // is handed the ids already spoken for. @@ -74,13 +94,13 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const takenIds = new Set(schemas.keys()); for (const a of rawAssets) { const id = typeof a.id === 'string' ? a.id : ''; - const schema = schemas.get(id); + const schema = schemaOf(id, asRecord(a.props)); if (!schema || !isAssetType(a.type)) continue; const type = a.type; const name = typeof a.name === 'string' ? a.name : type; const props = normalizeBag(a.props, schema, false); if (type === 'scene' || type === 'container') { - assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) }); + assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemaOf) }); } else if (type === 'behavior') { assets.push({ id, @@ -91,7 +111,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { graph: normalizeGraph(a.graph), }); } else if (type === 'statemachine') { - assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, STATE) }); + assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, (def) => def.type === STATE) }); + } else if (type === 'shader') { + assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() }); } else if (type === 'texture') { assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) }); } else if (type === 'animation') { @@ -130,12 +152,15 @@ export function serializeProject(project: Project): unknown { return JSON.parse(JSON.stringify(project)); } -function normalizeObjects(raw: unknown, schemas: Map): SceneObject[] { +function normalizeObjects( + raw: unknown, + schemaOf: (assetId: string, overrides: Record) => PropertySchema | undefined, +): SceneObject[] { const objects: SceneObject[] = []; for (const item of Array.isArray(raw) ? raw : []) { const o = asRecord(item); if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue; - const schema = schemas.get(o.assetId); + const schema = schemaOf(o.assetId, asRecord(o.overrides)); if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene const p = asRecord(o.position); const offset = asRecord(o.editorOffset); @@ -245,8 +270,8 @@ function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolea return ports; } -/** `only` restricts the node types kept: a state machine's graph holds states and nothing else. */ -function normalizeGraph(raw: unknown, only?: string): Graph { +/** `keep` restricts the node types kept: a state machine's graph holds states, a shader's shader nodes, and nothing else. */ +function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph { const g = asRecord(raw); const graph = createEmptyGraph(); const nodeIds = new Set(); @@ -255,7 +280,7 @@ function normalizeGraph(raw: unknown, only?: string): Graph { const id = typeof n.id === 'string' ? n.id : ''; const type = typeof n.type === 'string' ? n.type : ''; const def = nodeDef(type); - if (!id || nodeIds.has(id) || !def || (only !== undefined && type !== only)) continue; + if (!id || nodeIds.has(id) || !def || (keep !== undefined && !keep(def))) continue; nodeIds.add(id); // Params carry both settings and inline input values, so there is no schema to coerce against. const params: PropertyBag = {}; diff --git a/src/editor/AssetTabEditor.ts b/src/editor/AssetTabEditor.ts index 87926fe1..48744234 100644 --- a/src/editor/AssetTabEditor.ts +++ b/src/editor/AssetTabEditor.ts @@ -85,6 +85,11 @@ export abstract class AssetTabEditor< return this.canvas.currentAssetId; } + /** The right-hand readout as shown – the check scripts read this. */ + get shownInfo(): string { + return this.info.text; + } + /** The selection's id for this kind of asset, `''` when the selection is about something else. */ protected abstract selectedId(): string; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 2cb0be6d..c9c1511b 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -18,6 +18,7 @@ import { HierarchyPanel } from './panels/HierarchyPanel'; import { Toolbar } from './panels/Toolbar'; import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession'; import { ProjectSession } from './ProjectSession'; +import { ShaderEditor } from './shader/ShaderEditor'; import { StateMachineEditor } from './statemachine/StateMachineEditor'; import { installShortcuts } from './shortcuts'; import type { EditorState } from './state'; @@ -25,13 +26,14 @@ import { TextureEditor } from './texture/TextureEditor'; import { installTextureImport } from './textureImport'; export type CentreTab = - 'scene' | 'graph' | 'statemachine' | 'game' | 'texture' | 'animation' | 'container' | 'list'; + 'scene' | 'graph' | 'statemachine' | 'shader' | 'game' | 'texture' | 'animation' | 'container' | 'list'; const TAB_ORDER = [ 'scene', 'game', 'graph', 'statemachine', + 'shader', 'texture', 'animation', 'container', @@ -43,6 +45,7 @@ const TAB_LABELS: Record = { game: 'Game', graph: 'Behavior', statemachine: 'State machine', + shader: 'Shader', texture: 'Texture', animation: 'Animation', container: 'Container', @@ -69,6 +72,7 @@ export class Editor implements PlayHost { readonly gameView: GameViewport; readonly graphEditor: GraphEditor; readonly stateMachineEditor: StateMachineEditor; + readonly shaderEditor: ShaderEditor; readonly textureEditor: TextureEditor; readonly animationEditor: AnimationEditor; readonly containerEditor: ContainerEditor; @@ -109,6 +113,7 @@ export class Editor implements PlayHost { this.gameView = new GameViewport(state); this.graphEditor = new GraphEditor(state); this.stateMachineEditor = new StateMachineEditor(state); + this.shaderEditor = new ShaderEditor(state); this.textureEditor = new TextureEditor(state); this.animationEditor = new AnimationEditor(state); this.containerEditor = new ContainerEditor(state); @@ -154,6 +159,7 @@ export class Editor implements PlayHost { game: this.gameView, graph: this.graphEditor, statemachine: this.stateMachineEditor, + shader: this.shaderEditor, texture: this.textureEditor, animation: this.animationEditor, container: this.containerEditor, @@ -164,6 +170,7 @@ export class Editor implements PlayHost { game: this.tabButton('game'), graph: this.tabButton('graph'), statemachine: this.tabButton('statemachine'), + shader: this.tabButton('shader'), texture: this.tabButton('texture'), animation: this.tabButton('animation'), container: this.tabButton('container'), @@ -216,13 +223,16 @@ export class Editor implements PlayHost { app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); // A node, a frame and a template object exist only in their own tab, so selecting one // switches to it; selecting the behavior, texture or container asset itself does not. A state - // machine asset does switch (user's choice): its tab is the only place it shows anything. + // machine or shader asset does switch (user's choice): its tab is the only place it shows anything. state.on('selection', () => { const s = state.selection; const machine = state.selectedStateMachineId !== null; - if (s?.kind === 'node' && !machine && this.tab !== 'graph') this.showTab('graph'); + const shader = state.selectedShaderId !== null; + if (s?.kind === 'node' && !machine && !shader && this.tab !== 'graph') this.showTab('graph'); if ((s?.kind === 'node' || s?.kind === 'asset') && machine && this.tab !== 'statemachine') this.showTab('statemachine'); + if ((s?.kind === 'node' || s?.kind === 'asset') && shader && this.tab !== 'shader') + this.showTab('shader'); if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture'); if ( s?.kind === 'object' && diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 0e58466b..94140ca3 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -269,6 +269,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E center: () => centreOf(editor.stateMachineEditor.canvas), snapping: () => editor.stateMachineEditor.canvas.snapEnabled, }, + shader: { + current: () => editor.shaderEditor.currentAssetId, + pickerAt: () => centreOf(editor.shaderEditor.picker), + info: () => editor.shaderEditor.shownInfo, + nodeAt: (nodeId: string) => editor.shaderEditor.canvas.nodeGlobal(nodeId), + nodeLabel: (nodeId: string) => editor.shaderEditor.canvas.nodeLabel(nodeId), + nodeRect: (nodeId: string) => editor.shaderEditor.canvas.nodeRect(nodeId), + nodeProblem: (nodeId: string) => editor.shaderEditor.canvas.nodeProblem(nodeId), + canvasRect: () => editor.shaderEditor.canvas.canvasRect(), + emptySpot: () => editor.shaderEditor.canvas.emptySpotGlobal(), + center: () => centreOf(editor.shaderEditor.canvas), + snapping: () => editor.shaderEditor.canvas.snapEnabled, + }, /** The console panel: its lines as kept, its drawn height, and the divider above it. */ console: { lines: () => editor.console.debugInfo().lines, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 4e664ba8..2aed7ee4 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -28,6 +28,7 @@ import { } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; +import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { findAsset, findGraphAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { beadsInFlight, launchBead } from './beads'; @@ -218,6 +219,8 @@ export class GraphCanvas extends Widget { } const seen = new Set(); const lookup = graphLookup(this.state.project); + // A shader node's problem is the compiler's, not a call's. + const shaderProblems = asset.type === 'shader' ? shaderLibrary.buildFor(asset).problems : null; for (const node of asset.graph.nodes) { const def = resolveDef(node, asset.graph, lookup); if (!def) continue; @@ -230,7 +233,10 @@ export class GraphCanvas extends Widget { this.views.set(node.id, view); this.nodeLayer.addChild(view); } - view.sync(node, asset.graph, this.state.project, def, callProblem(node, lookup, asset.id)); + const problem = shaderProblems + ? (shaderProblems[node.id] ?? null) + : callProblem(node, lookup, asset.id); + view.sync(node, asset.graph, this.state.project, def, problem); } for (const [id, view] of this.views) { if (!seen.has(id)) { diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 394e0062..e3195571 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -51,6 +51,8 @@ export function categoryColor(category: NodeCategory): number { return c.categoryAction; case 'state': return c.categoryState; + case 'shader': + return c.categoryShader; } } @@ -71,6 +73,8 @@ export function portColor(type: PortType): number { return c.portList; case 'struct': return c.portStruct; + case 'vec3': + return c.portVec3; case 'any': return c.portAny; } diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index a21efd05..3acdf234 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -15,6 +15,8 @@ import type { GraphAsset } from '../../core/model/Asset'; import { graphLookup } from '../../core/model/graphLookup'; import type { PropertyBag } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; +import { nextPropertyName, PROPERTY_NAME_PARAM, SHADER_PROPERTY } from '../../core/properties/shaders'; +import { SHADER_NODES, SHADER_OUTPUT } from '../../core/shader/nodes'; /** * One row of the palette. `def` is the definition the node would actually have once placed, so the @@ -29,17 +31,26 @@ export interface PaletteEntry { /** * The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each - * new one named by its number. In a behavior, every other behavior appears under its own name - * instead of behind a generic "Run behavior", and the two terminals drop out once the graph has them. + * new one named by its number; a shader holds shader nodes, its one Output dropping out once + * placed. In a behavior, every other behavior appears under its own name instead of behind a + * generic "Run behavior", and the two terminals drop out once the graph has them. */ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] { if (asset.type === 'statemachine') { const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) }; 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 params: PropertyBag = + def.type === SHADER_PROPERTY ? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) } : {}; + return { id: def.type, hint: def.category, def, params }; + }); + } const entries: PaletteEntry[] = []; for (const def of allNodeDefs()) { - if (def.type === RUN_BEHAVIOR || def.category === 'state') continue; + if (def.type === RUN_BEHAVIOR || def.category === 'state' || def.category === 'shader') continue; if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue; entries.push({ id: def.type, hint: def.category, def, params: {} }); } diff --git a/src/editor/inspector/aspectAction.ts b/src/editor/inspector/aspectAction.ts index 997e1bbd..87b12d22 100644 --- a/src/editor/inspector/aspectAction.ts +++ b/src/editor/inspector/aspectAction.ts @@ -2,6 +2,7 @@ import type { Command } from '../../core/commands/Command'; import { oneStep } from '../../core/commands/CompoundCommand'; import type { Asset } from '../../core/model/Asset'; import type { PropertyBag } from '../../core/model/Properties'; +import type { Project } from '../../core/model/Project'; import { fitAxisToAspect, matchesAspect, @@ -27,8 +28,8 @@ export function sizeCommand(values: SizeValues, make: (axis: SizeAxis, value: nu } /** The pixel size as the schema would store it – null where a min/max would change a side. */ -function storablePixelSize(asset: Asset, size: PixelSize): SizeValues | null { - const schema = schemaForAsset(asset); +function storablePixelSize(project: Project, asset: Asset, size: PixelSize): SizeValues | null { + const schema = schemaForAsset(project, asset); const values: SizeValues = {}; for (const axis of ['width', 'height'] as const) { const def = schema.find((d) => d.key === axis); @@ -56,7 +57,7 @@ export function aspectActions( ): FieldActionGroup[] { if (def.kind !== 'number' || (def.key !== 'width' && def.key !== 'height')) return []; // A size without a picture (collider, camera) has no proportions to fit to. - if (!schemaForAsset(asset).some((d) => d.kind === 'texture')) return []; + if (!schemaForAsset(host.state.project, asset).some((d) => d.kind === 'texture')) return []; const axis: SizeAxis = def.key; return [ { @@ -73,7 +74,9 @@ export function aspectActions( if (next !== null && next === fitted) { actions.push({ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit({ [axis]: next }) }); } - const pixels = matchesPixelSize(size, bag) ? null : storablePixelSize(asset, size); + const pixels = matchesPixelSize(size, bag) + ? null + : storablePixelSize(host.state.project, asset, size); if (pixels) { actions.push({ label: `Set to ${size.w} × ${size.h} px`, diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 3098be24..865b1fa7 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -35,7 +35,7 @@ const newField = (key: string, kind: StructFieldKind): StructFieldDef => ({ key, */ export function assetSignature(project: Project, assetId: string): string { const asset = findAsset(project, assetId); - return asset ? schemaForAsset(asset).map(defSignature).join(',') : 'missing'; + return asset ? schemaForAsset(project, asset).map(defSignature).join(',') : 'missing'; } export function defSignature(def: PropertyDef): string { @@ -56,7 +56,7 @@ export function buildAssetSection(host: InspectorHost, assetId: string): void { }, }); - buildProps(host, asset, markedAssetSchema(asset)); + buildProps(host, asset, markedAssetSchema(host.state.project, asset)); if (asset.type === 'behavior') buildVariables(host, asset); } @@ -65,16 +65,18 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole const asset = findAsset(host.state.project, assetId); if (!asset) return false; host.nameField?.setValue(asset.name); - const values = resolveAssetProps(asset); + const project = host.state.project; + const values = resolveAssetProps(project, asset); for (const [key, row] of host.fields) { row.setValue(values[key] ?? 0); - row.setModified(isModified(asset, key)); + row.setModified(isModified(project, asset, key)); } return true; } function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void { - const values = resolveAssetProps(asset); + const project = () => host.state.project; + const values = resolveAssetProps(project(), asset); for (const def of schema) { const row = createFieldRow( host, @@ -88,7 +90,7 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property def, () => { const target = findAsset(host.state.project, asset.id); - return target ? resolveAssetProps(target) : null; + return target ? resolveAssetProps(project(), target) : null; }, (values) => host.state.execute( @@ -97,13 +99,13 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property ), reset: () => { const target = findAsset(host.state.project, asset.id); - return actionIf(target && isModified(target, def.key), 'Reset to default', () => + return actionIf(target && isModified(project(), target, def.key), 'Reset to default', () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)), ); }, }, ); - row.setModified(isModified(asset, def.key)); + row.setModified(isModified(project(), asset, def.key)); host.fields.set(def.key, row); host.body.add(row.row); } @@ -136,7 +138,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void { host.body.add(header); if (asset.variables.length === 0) addNote(host, 'No variables yet.'); - const values = resolveAssetProps(asset); + const values = resolveAssetProps(host.state.project, asset); const run = (command: Command): void => void host.state.execute(command); for (const def of asset.variables) { const edit: VariableEditing = { diff --git a/src/editor/inspector/objectQueries.ts b/src/editor/inspector/objectQueries.ts index f9bf3058..5be7fc07 100644 --- a/src/editor/inspector/objectQueries.ts +++ b/src/editor/inspector/objectQueries.ts @@ -42,7 +42,7 @@ export function currentRef(state: State, holderId: string, objectId: string): Ob export function currentProps(state: State, holderId: string, objectId: string): PropertyBag | null { const object = currentRef(state, holderId, objectId)?.object; const asset = object && findAsset(state.project, object.assetId); - return object && asset ? resolveSceneObjectProps(asset, object) : null; + return object && asset ? resolveSceneObjectProps(state.project, asset, object) : null; } /** @@ -61,7 +61,7 @@ export function deviations( const asset = object && findAsset(state.project, object.assetId); if (!view || !object || !asset) return { template: false, asset: false, applyToAsset: false }; const target = targetOf(state.project, holderId, objectId); - const fromAsset = deviatesFromAsset(object, asset, key); + const fromAsset = deviatesFromAsset(state.project, object, asset, key); return { template: Boolean(target) && deviatesFromTemplate(object, target!.base, key), asset: !target && fromAsset, diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 44e7d6f6..2d31327c 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -30,7 +30,7 @@ import { isCommonProp, type PropertyDef, schemaFor, - schemaForAsset, + schemaForObject, } from '../../core/properties/PropertySchema'; import { deviatesFromAsset, @@ -69,7 +69,8 @@ export function objectSignature( : isLiveInstance(project, holder, object) ? 'instance' : ''; - return `${object.parentId}|${kind}|` + schemaForAsset(asset).map(defSignature).join(','); + const rows = schemaForObject(project, asset, object); + return `${object.parentId}|${kind}|` + rows.map(defSignature).join(','); } /** Several objects: what they are, and the actions that apply to all of them at once. */ @@ -125,7 +126,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object if (target) assetLink(host, 'Part of', target.asset); // The master switch, right where it is looked for: above everything type-specific. - const resolved = resolveSceneObjectProps(asset, object); + const resolved = resolveSceneObjectProps(host.state.project, asset, object); for (const def of COMMON_PROPS) propertyRow(host, holderId, objectId, asset, def, resolved); // A behavior's own switches sit with it rather than in the section below: that one lists what // the behavior *declares*, and `Run in editor` is not something it declared. @@ -178,11 +179,11 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object } // Asset properties (or behavior variables) with overrides; the switches are shown above. - const values = resolveSceneObjectProps(asset, object); + const values = resolveSceneObjectProps(host.state.project, asset, object); const own = asset.type === 'behavior' ? asset.variables - : schemaForAsset(asset).filter((def) => !isCommonProp(def.key)); + : schemaForObject(host.state.project, asset, object).filter((def) => !isCommonProp(def.key)); if (own.length > 0 || asset.type === 'behavior') addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES'); if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.'); @@ -248,8 +249,9 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje host.nameField?.setValue(object.name); for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]); for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]); - const values = resolveSceneObjectProps(asset, object); - for (const def of schemaForAsset(asset)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default); + const values = resolveSceneObjectProps(project, asset, object); + for (const def of schemaForObject(project, asset, object)) + host.fields.get(def.key)?.setValue(values[def.key] ?? def.default); markObjectRows(host, object, asset, targetOf(project, holderId, objectId)); return true; } @@ -343,10 +345,11 @@ function markObjectRows( ?.setModified(Boolean(target) && object.position[axis] !== target!.base.position[axis]); for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0); - for (const def of schemaForAsset(asset)) { + const project = host.state.project; + for (const def of schemaForObject(project, asset, object)) { const deviates = target ? deviatesFromTemplate(object, target.base, def.key) - : deviatesFromAsset(object, asset, def.key); + : deviatesFromAsset(project, object, asset, def.key); host.fields.get(def.key)?.setModified(deviates); } } diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index fb1df1a4..73c8e45a 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -26,7 +26,7 @@ import { import { findAssetOf } from '../../core/model/Project'; import { textOf } from '../../core/model/Properties'; import { DECLARED_PORT_KINDS, kindMark, VARIABLE_KINDS } from '../../core/properties/kinds'; -import { type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; +import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema'; import { Checkbox, Dropdown, @@ -76,9 +76,11 @@ export function buildPropertyPortSection( } if (target !== 'behavior') { const owner = `a ${targetLabel(target)}`; + // A sprite's rows go on with the parameters of the shader it wears – any shader, the node cannot know which. + const shaderRows = target === 'sprite' ? allShaderParamRows(host.state.project) : []; buildPickedPortSection(host, assetId, node, { noun: 'Property', - choices: targetProperties(target), + choices: [...targetProperties(target), ...shaderRows], owner, }); return; @@ -88,7 +90,7 @@ export function buildPropertyPortSection( const behavior = id ? findAssetOf(host.state.project, id, 'behavior') : undefined; buildPickedPortSection(host, assetId, node, { noun: 'Property', - choices: behavior ? schemaForAsset(behavior) : [], + choices: behavior ? schemaForAsset(host.state.project, behavior) : [], owner: behavior ? `"${behavior.name}"` : 'a behavior', empty: id ? 'That behavior is gone.' : 'Pick a behavior first.', }); diff --git a/src/editor/list/slot.ts b/src/editor/list/slot.ts index 548bfc13..d70464ef 100644 --- a/src/editor/list/slot.ts +++ b/src/editor/list/slot.ts @@ -12,7 +12,12 @@ import { isList, type ListValue, type PropertyValue, type StructValue } from '.. import { findAsset } from '../../core/model/Project'; import { FIELD_KINDS, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds'; import { fieldLike, type ListItem, itemLike, structItemOf } from '../../core/properties/listItems'; -import { type PropertyDef, schemaForAsset, structOf } from '../../core/properties/PropertySchema'; +import { + type PropertyDef, + schemaForAsset, + schemaForObject, + structOf, +} from '../../core/properties/PropertySchema'; import { resolveAssetProps } from '../../core/properties/resolve'; import type { FieldHandlers } from '../inspector/fieldFactory'; import { currentProps, currentRef } from '../inspector/objectQueries'; @@ -149,14 +154,14 @@ function access( function assetOwner(state: EditorState, slot: Extract): Owner | null { const asset = findAsset(state.project, slot.assetId); - const def = asset && schemaForAsset(asset).find((d) => d.key === slot.key); + const def = asset && schemaForAsset(state.project, asset).find((d) => d.key === slot.key); if (!asset || !def) return null; return { def, title: `${asset.name} · ${def.label}`, current: () => { const now = findAsset(state.project, slot.assetId); - return now && resolveAssetProps(now)[slot.key]; + return now && resolveAssetProps(state.project, now)[slot.key]; }, spec: assetValue(state, slot.assetId, slot.key), }; @@ -168,7 +173,7 @@ function objectOwner( ): Owner | null { const ref = currentRef(state, slot.holderId, slot.objectId); const owner = ref && findAsset(state.project, ref.object.assetId); - const def = owner && schemaForAsset(owner).find((d) => d.key === slot.key); + const def = owner && schemaForObject(state.project, owner, ref.object).find((d) => d.key === slot.key); if (!ref || !def) return null; return { def, diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 1858ea37..792c2429 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -71,7 +71,7 @@ export class AssetPanel extends Panel { label: asset.name, hint: asset.type === 'scene' && asset.id === active ? 'scene ●' : asset.type, swatch: hasColorSwatch(asset.type) ? Number(asset.props.color ?? 0) : undefined, - overridden: assetDeviates(asset), + overridden: assetDeviates(this.state.project, asset), })), ); this.syncSelection(); diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index db5fb854..6fa706bf 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -231,7 +231,7 @@ export class HierarchyPanel extends Panel { this.list.setItems( (holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => { const asset = findAsset(this.state.project, object.assetId); - const props = asset ? resolveSceneObjectProps(asset, object) : {}; + const props = asset ? resolveSceneObjectProps(this.state.project, asset, object) : {}; return { id: object.id, label: object.name || asset?.name || '?', diff --git a/src/editor/shader/ShaderEditor.ts b/src/editor/shader/ShaderEditor.ts new file mode 100644 index 00000000..b5c17c0c --- /dev/null +++ b/src/editor/shader/ShaderEditor.ts @@ -0,0 +1,44 @@ +import type { Asset, ShaderAsset } from '../../core/model/Asset'; +import { shaderParamDefs } from '../../core/properties/shaders'; +import { shaderLibrary } from '../../viewport/ShaderLibrary'; +import { AssetTabEditor } from '../AssetTabEditor'; +import { GraphCanvas } from '../graph/GraphCanvas'; +import { SnapToggle } from '../graph/SnapToggle'; +import type { EditorState } from '../state'; + +/** Room kept for "N properties", or for the reason the graph does not compile. */ +const INFO_WIDTH = 220; + +/** + * The nodes of one shader asset, on the graph canvas the Behavior tab uses. The graph is compiled + * on every change (see `ShaderLibrary`); the readout says whether it did. + */ +export class ShaderEditor extends AssetTabEditor { + private readonly snap = new SnapToggle(() => this.canvas); + + constructor(state: EditorState) { + super(state, { noun: 'Shader', infoWidth: INFO_WIDTH }); + this.mount(new GraphCanvas(state, 'shader'), this.snap.widgets); + // Play swaps in the runtime copy of the project, and back. + state.on('mode', () => this.refresh()); + } + + protected override selectedId(): string { + return this.state.selectedShaderId ?? ''; + } + + protected override isMine(asset: Asset): asset is ShaderAsset { + return asset.type === 'shader'; + } + + protected override infoText(asset: ShaderAsset): string { + const build = shaderLibrary.buildFor(asset); + if (!build.compiled) return build.reason; + const count = shaderParamDefs(asset.graph).length; + return `${count} ${count === 1 ? 'property' : 'properties'}`; + } + + protected override syncExtras(asset: ShaderAsset | null): void { + this.snap.setEnabled(Boolean(asset)); + } +} diff --git a/src/editor/state.ts b/src/editor/state.ts index 7f3922b1..5b7acc2c 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -359,7 +359,12 @@ export class EditorState { return this.selectedGraphId('statemachine'); } - private selectedGraphId(type: 'behavior' | 'statemachine'): string | null { + /** The shader the current selection refers to: the asset, or one of its nodes. */ + get selectedShaderId(): string | null { + return this.selectedGraphId('shader'); + } + + private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader'): string | null { const s = this.selection; if (!s) return null; if (s.kind === 'frame') return null; diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index ca9a93d4..ef276364 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -6,6 +6,8 @@ import { rectAround, rectsIntersect } from '../../core/properties/camera'; import type { EditorState } from '../state'; import { type Size, Theme, Widget } from '../../ui'; import type { DrawItem } from '../../viewport/items'; +import { disposeShader } from '../../viewport/shaders'; +import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { drawItem } from '../../viewport/symbols'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { type View, worldBounds, worldToScreen } from '../../viewport/view'; @@ -141,6 +143,7 @@ export abstract class WorldViewport extends Widget { ); this.world.scale.set(view.scale); const bounds = worldBounds(view); + const shaders = shaderLibrary.source(this.state.project); const seen = new Set(); for (const item of items) { @@ -165,13 +168,14 @@ export abstract class WorldViewport extends Widget { if (!onScreen) continue; this.visibleCount++; node.position.set(item.x, item.y); - drawItem(node, item, size, view.scale, textureLibrary); + drawItem(node, item, size, view.scale, textureLibrary, shaders); } this.afterItems(items, view); for (const [id, node] of this.nodes) { if (seen.has(id)) continue; this.texts.get(id)?.destroy(); this.texts.delete(id); + disposeShader(node); node.destroy(); this.nodes.delete(id); this.disposeDecorations(id); diff --git a/src/runtime/World.ts b/src/runtime/World.ts index eb125735..483d047f 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -128,7 +128,7 @@ export class World { entity.x = object.position.x; entity.y = object.position.y; entity.z = object.position.z; - const resolved = resolveSceneObjectProps(asset, object); + const resolved = resolveSceneObjectProps(this.project, asset, object); for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key]; Object.assign(entity.props, resolved); return entity; @@ -187,7 +187,7 @@ export class World { if (!entity || !asset) continue; const p = object.position; const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z; - const base = resolveSceneObjectProps(asset, object); + const base = resolveSceneObjectProps(this.project, asset, object); const drifted = Object.keys(entity.props).filter((k) => k in base && base[k] !== entity.props[k]); if (!moved && drifted.length === 0) continue; // Only now, because a ref for resolved content costs a resolve of its own – and because a diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 5d4cf2e7..4ef48d73 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -55,6 +55,7 @@ export const Theme = { categoryVariable: 0xb8860b, categoryAction: 0x2f6fd6, categoryState: 0x8e5ad6, + categoryShader: 0x1f8f8f, portExec: 0xffffff, portNumber: 0x6fcf97, portBoolean: 0xe57373, @@ -62,6 +63,7 @@ export const Theme = { portObject: 0x56ccf2, portList: 0xf2a75c, portStruct: 0xd4c25a, + portVec3: 0xff9ad5, portAny: 0xbdbdbd, /** Ring around the port a running edge drag would connect to. */ portTarget: 0xffffff, diff --git a/src/viewport/ShaderLibrary.ts b/src/viewport/ShaderLibrary.ts new file mode 100644 index 00000000..fd97c57b --- /dev/null +++ b/src/viewport/ShaderLibrary.ts @@ -0,0 +1,39 @@ +import type { ShaderAsset } from '../core/model/Asset'; +import { findAssetOf, type Project } from '../core/model/Project'; +import { compileShader, type ShaderBuild } from '../core/shader/compile'; +import type { ShaderSource } from './shaders'; + +/** + * Compiled shader graphs, one per asset, recompiled when the graph's content changes. A graph is + * edited in place, so the cache keys on its serialized form rather than on its identity. + */ +export class ShaderLibrary { + private readonly builds = new Map(); + + buildFor(asset: ShaderAsset): ShaderBuild { + const fingerprint = JSON.stringify(asset.graph); + const cached = this.builds.get(asset.id); + if (cached && cached.fingerprint === fingerprint) return cached.build; + const build = compileShader(asset.graph); + this.builds.set(asset.id, { fingerprint, build }); + return build; + } + + /** For one redraw: each asset is looked up and fingerprinted once, however many sprites wear it. */ + source(project: Project): ShaderSource { + const seen = new Map(); + return { + compiled: (assetId) => { + let build = seen.get(assetId); + if (build === undefined) { + const asset = findAssetOf(project, assetId, 'shader'); + build = asset ? this.buildFor(asset) : null; + seen.set(assetId, build); + } + return build?.compiled ?? null; + }, + }; + } +} + +export const shaderLibrary = new ShaderLibrary(); diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 88d367fc..6d6529de 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -49,7 +49,7 @@ export function collectHolderItems( for (const { object } of treeOrder(view)) { const asset = findAsset(project, object.assetId); if (!asset) continue; - const props = resolveSceneObjectProps(asset, object); + const props = resolveSceneObjectProps(project, asset, object); items.push({ id: object.id, type: asset.type, diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts new file mode 100644 index 00000000..8b257d0c --- /dev/null +++ b/src/viewport/shaders.ts @@ -0,0 +1,81 @@ +import { defaultFilterVert, Filter, GlProgram, type Graphics, UniformGroup } from 'pixi.js'; +import type { PropertyBag } from '../core/model/Properties'; +import { shaderOf } from '../core/properties/shaders'; +import type { CompiledShader } from '../core/shader/compile'; +import { uniformName } from '../core/shader/nodes'; + +/** Where a node's shader comes from: the compiled form of the asset the sprite names, if it compiles. */ +export interface ShaderSource { + compiled(assetId: string): CompiledShader | null; +} + +/** + * A shader asset worn by one sprite node. The program is shared (`GlProgram.from` caches by + * source), the uniforms are this sprite's; `read` takes them from the item's bag. + */ +class GraphShader extends Filter { + private readonly uniforms: Record | null; + + constructor( + readonly assetId: string, + readonly fragment: string, + private readonly names: readonly { name: string; default: number }[], + ) { + const group = + names.length > 0 + ? new UniformGroup( + Object.fromEntries( + names.map((u) => [uniformName(u.name), { value: u.default, type: 'f32' as const }]), + ), + ) + : null; + super({ + glProgram: GlProgram.from({ vertex: defaultFilterVert, fragment, name: `shader-${assetId}` }), + resources: group ? { shaderUniforms: group } : {}, + resolution: 'inherit', + antialias: 'inherit', + }); + this.uniforms = group ? group.uniforms : null; + } + + read(props: PropertyBag): void { + if (!this.uniforms) return; + for (const u of this.names) { + const n = Number(props[u.name]); + this.uniforms[uniformName(u.name)] = Number.isFinite(n) ? n : u.default; + } + } +} + +function wornBy(node: Graphics): GraphShader | null { + // Declared `readonly Filter[]`, but the getter yields undefined until a filter was ever set. + const first = (node.filters as readonly Filter[] | undefined)?.[0]; + return first instanceof GraphShader ? first : null; +} + +/** + * Makes the node's filter match `props`: updated in place while asset and fragment are the same, + * rebuilt when either changed, dropped for no shader – and for one that does not compile. + */ +export function syncShader(node: Graphics, props: PropertyBag, shaders: ShaderSource): void { + const id = shaderOf(props); + const compiled = id ? shaders.compiled(id) : null; + const worn = wornBy(node); + if (worn && compiled && worn.assetId === id && worn.fragment === compiled.fragment) { + worn.read(props); + return; + } + disposeShader(node); + if (!compiled) return; + const shader = new GraphShader(id, compiled.fragment, compiled.uniforms); + shader.read(props); + node.filters = [shader]; +} + +/** `Container.destroy` leaves filters alone, so a node going away releases its shader here. */ +export function disposeShader(node: Graphics): void { + const worn = wornBy(node); + if (!worn) return; + node.filters = null; + worn.destroy(); +} diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 1523f4d9..ac710853 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -5,6 +5,7 @@ import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/propertie import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; import { Theme } from '../ui'; import type { DrawItem } from './items'; +import { type ShaderSource, syncShader } from './shaders'; import { frameFillMatrix } from './textureFill'; import type { TextureLibrary } from './TextureLibrary'; @@ -18,6 +19,7 @@ export function drawItem( size: { w: number; h: number }, scale: number, textures: TextureLibrary, + shaders: ShaderSource, ): void { node.clear(); node.hitArea = null; @@ -28,6 +30,7 @@ export function drawItem( // once the picture is there the colour tints it. const texture = item.texture ? textures.get(item.texture) : null; node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(spriteFill(texture, color, item.texture)); + syncShader(node, item.props, shaders); return; } case 'camera': diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts index 2b499b9e..a91476c8 100644 --- a/tests/core/commands.test.ts +++ b/tests/core/commands.test.ts @@ -28,6 +28,7 @@ describe('asset commands', () => { height: 100, color: 0xffffff, texture: '', + shader: '', }); }); diff --git a/tests/core/deviation.test.ts b/tests/core/deviation.test.ts index 0147dad6..2511eda0 100644 --- a/tests/core/deviation.test.ts +++ b/tests/core/deviation.test.ts @@ -67,15 +67,15 @@ describe('asset deviation', () => { it('marks an asset only once a property really differs from its default', () => { const f = makeAsset('sprite', 'Box'); - expect(assetDeviates(f.asset())).toBe(false); + expect(assetDeviates(f.project, f.asset())).toBe(false); // Written, but the default value – the inspector shows no gold, so neither does the list. f.stack.execute(new SetAssetPropertyCommand(f.assetId, 'width', 100)); expect(f.asset().props.width).toBe(100); - expect(assetDeviates(f.asset())).toBe(false); + expect(assetDeviates(f.project, f.asset())).toBe(false); f.stack.execute(new SetAssetPropertyCommand(f.assetId, 'width', 250)); - expect(assetDeviates(f.asset())).toBe(true); + expect(assetDeviates(f.project, f.asset())).toBe(true); f.stack.execute(new ResetAssetPropertyCommand(f.assetId, 'width')); - expect(assetDeviates(f.asset())).toBe(false); + expect(assetDeviates(f.project, f.asset())).toBe(false); }); it('leaves a behavior unmarked for its variables, which the inspector does not mark either', () => { @@ -83,8 +83,8 @@ describe('asset deviation', () => { f.stack.execute(new AddVariableCommand(f.assetId, 'speed', 'number')); f.stack.execute(new SetAssetPropertyCommand(f.assetId, 'speed', 42)); expect(f.asset().props.speed).toBe(42); - expect(assetDeviates(f.asset())).toBe(false); + expect(assetDeviates(f.project, f.asset())).toBe(false); f.stack.execute(new SetAssetPropertyCommand(f.assetId, 'enabled', false)); - expect(assetDeviates(f.asset())).toBe(true); + expect(assetDeviates(f.project, f.asset())).toBe(true); }); }); diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts index 74468b31..9c4c484f 100644 --- a/tests/core/enabled.test.ts +++ b/tests/core/enabled.test.ts @@ -44,7 +44,7 @@ describe('the enabled switch', () => { const f = makeSpriteScene(); const { project, stack, sceneId, spriteId, object } = f; const box = place(f, spriteId, { x: 0, y: 0 }); - expect(resolveSceneObjectProps(findAsset(project, spriteId)!, object(box)).enabled).toBe(true); + expect(resolveSceneObjectProps(project, findAsset(project, spriteId)!, object(box)).enabled).toBe(true); // Switching an instance off is an ordinary override – and survives a round trip. stack.execute(new SetOverrideCommand(sceneId, box, 'enabled', false)); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 9bd13d09..75b1b479 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -74,6 +74,8 @@ describe('node registry', () => { expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length); // An event is seeded by the runtime, a state is a name in a machine: neither runs. if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0); + // A shader node is compiled to GLSL, never run. + else if (def.category === 'shader') expect('emit' in def).toBe(true); else expect(def.run).toBeTypeOf('function'); expect(keys.every((k) => k.length > 0)).toBe(true); } diff --git a/tests/core/list-value.test.ts b/tests/core/list-value.test.ts index c537be67..1a0e06d8 100644 --- a/tests/core/list-value.test.ts +++ b/tests/core/list-value.test.ts @@ -72,10 +72,10 @@ describe('a list as a value', () => { f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 2])); const placed = f.object(object); - expect(deviatesFromAsset(placed, asset, 'row')).toBe(false); + expect(deviatesFromAsset(f.project, placed, asset, 'row')).toBe(false); f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 3])); - expect(deviatesFromAsset(placed, asset, 'row')).toBe(true); + expect(deviatesFromAsset(f.project, placed, asset, 'row')).toBe(true); }); it('keeps a loaded item as the kind it is, kind by kind', () => { diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts index d8294864..99ee7985 100644 --- a/tests/core/resolve.test.ts +++ b/tests/core/resolve.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildAsset } from '../../src/core/commands/assetCommands'; +import { createEmptyProject } from '../../src/core/model/Project'; import { createSceneObject } from '../../src/core/model/SceneObject'; import { coerceValue, defaultsFor, schemaFor } from '../../src/core/properties/PropertySchema'; import { @@ -28,6 +29,7 @@ describe('PropertySchema', () => { height: 100, color: 0xffffff, texture: '', + shader: '', }); }); @@ -43,15 +45,17 @@ describe('PropertySchema', () => { }); describe('resolve', () => { + const project = createEmptyProject('p', 'P'); it('fills asset props with defaults and ignores unknown keys', () => { const sprite = buildAsset('sprite', 'Box'); sprite.props = { width: 50, bogus: 1 }; - expect(resolveAssetProps(sprite)).toEqual({ + expect(resolveAssetProps(project, sprite)).toEqual({ enabled: true, width: 50, height: 100, color: 0xffffff, texture: '', + shader: '', }); }); @@ -59,44 +63,45 @@ describe('resolve', () => { const sprite = buildAsset('sprite', 'Box'); sprite.props.color = 0x00ff00; const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 }); - expect(resolveSceneObjectProps(sprite, object)).toEqual({ + expect(resolveSceneObjectProps(project, sprite, object)).toEqual({ enabled: true, width: 100, height: 100, color: 0x00ff00, texture: '', + shader: '', }); object.overrides.color = 0x0000ff; - expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff); - expect(inheritedValue(sprite, 'color')).toBe(0x00ff00); + expect(resolveSceneObjectProps(project, sprite, object).color).toBe(0x0000ff); + expect(inheritedValue(project, sprite, 'color')).toBe(0x00ff00); }); it('detects asset values that differ from the schema default', () => { const sprite = buildAsset('sprite', 'Box'); - expect(isModified(sprite, 'width')).toBe(false); // explicit default counts as unmodified + expect(isModified(project, sprite, 'width')).toBe(false); // explicit default counts as unmodified sprite.props.width = 250; - expect(isModified(sprite, 'width')).toBe(true); + expect(isModified(project, sprite, 'width')).toBe(true); delete sprite.props.height; // missing → resolved from default → unmodified - expect(isModified(sprite, 'height')).toBe(false); - expect(isModified(sprite, 'bogus')).toBe(false); + expect(isModified(project, sprite, 'height')).toBe(false); + expect(isModified(project, sprite, 'bogus')).toBe(false); }); it('ignores an override that carries the asset value – nothing to reset or apply', () => { const sprite = buildAsset('sprite', 'Box'); sprite.props.width = 250; const object = createSceneObject('o1', sprite.id, { x: 0, y: 0 }); - expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); // no override at all + expect(deviatesFromAsset(project, object, sprite, 'width')).toBe(false); // no override at all object.overrides.width = 250; // same value as the asset → not a deviation - expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); + expect(deviatesFromAsset(project, object, sprite, 'width')).toBe(false); object.overrides.width = 300; - expect(deviatesFromAsset(object, sprite, 'width')).toBe(true); + expect(deviatesFromAsset(project, object, sprite, 'width')).toBe(true); // The asset moving to the object's value settles the difference again. sprite.props.width = 300; - expect(deviatesFromAsset(object, sprite, 'width')).toBe(false); + expect(deviatesFromAsset(project, object, sprite, 'width')).toBe(false); }); it('compares an instance against its template as overrides, not as values', () => { @@ -120,6 +125,6 @@ describe('resolve', () => { const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 }); expect(object.position).toEqual({ x: 10, y: 20, z: 0 }); expect('position' in sprite).toBe(false); - expect(resolveSceneObjectProps(sprite, object)).not.toHaveProperty('x'); + expect(resolveSceneObjectProps(project, sprite, object)).not.toHaveProperty('x'); }); }); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index d249b0c0..e5718e8b 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -65,6 +65,7 @@ describe('serialize', () => { height: 100, color: 0xffffff, texture: '', + shader: '', }); }); }); diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts new file mode 100644 index 00000000..6e73dcdf --- /dev/null +++ b/tests/core/shaders.test.ts @@ -0,0 +1,354 @@ +/** + * A shader is an asset whose graph is GLSL in nodes: `Property` nodes declare the rows a sprite + * wearing it shows, the compiler turns everything reachable from `Output` into a fragment. The + * one graph every test builds is the "Adjust colors" recipe (brightness, contrast, saturation). + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import type { Graph } from '../../src/core/graph'; +import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; +import type { ShaderAsset } from '../../src/core/model/Asset'; +import { isPlaceableType } from '../../src/core/model/AssetTypes'; +import { findAssetOf } from '../../src/core/model/Project'; +import { assetDeviates, objectDeviates } from '../../src/core/properties/deviation'; +import { schemaFor, schemaForAsset, schemaForObject } from '../../src/core/properties/PropertySchema'; +import { resolveSceneObjectProps } from '../../src/core/properties/resolve'; +import { + nextPropertyName, + SHADER_KEY, + SHADER_PROPERTY, + shaderParamDefs, +} from '../../src/core/properties/shaders'; +import { compileShader } from '../../src/core/shader/compile'; +import { SHADER_NODES, SHADER_OUTPUT, SHADER_TEXTURE_COLOR } from '../../src/core/shader/nodes'; +import { deserializeProject } from '../../src/core/store/serialize'; +import { paletteEntries } from '../../src/editor/graph/paletteEntries'; +import { makeProject, makeSpriteScene, place, type ProjectFixture, roundTrip } from '../helpers'; + +/** Command-driven editing of a shader asset's graph, the way the tab does it. */ +function shaderEditor(f: ProjectFixture, name = 'Adjust colors') { + const create = new CreateAssetCommand('shader', name); + 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 })); + return { id, asset, add, link }; +} + +const property = (name: string, value = 1, min = 0, max = 4) => ({ name, default: value, min, max }); + +/** + * rgb *= brightness; rgb = (rgb - 0.5) * contrast + 0.5; rgb = mix(luma, rgb, saturation) – + * eleven nodes, the same maths the built-in prototype had. + */ +function buildAdjustColors(s: ReturnType) { + const texel = s.add(SHADER_TEXTURE_COLOR); + const brightness = s.add(SHADER_PROPERTY, property('brightness')); + const contrast = s.add(SHADER_PROPERTY, property('contrast')); + const saturation = s.add(SHADER_PROPERTY, property('saturation')); + const bright = s.add('ShaderMultiply'); + const shifted = s.add('ShaderSubtract', { b: 0.5 }); + const scaled = s.add('ShaderMultiply'); + const centred = s.add('ShaderAdd', { b: 0.5 }); + const luma = s.add('ShaderLuminance'); + const mixed = s.add('ShaderMix'); + const output = s.add(SHADER_OUTPUT); + s.link(texel, 'color', bright, 'a'); + s.link(brightness, 'value', bright, 'b'); + s.link(bright, 'result', shifted, 'a'); + s.link(shifted, 'result', scaled, 'a'); + s.link(contrast, 'value', scaled, 'b'); + s.link(scaled, 'result', centred, 'a'); + s.link(centred, 'result', luma, 'color'); + s.link(luma, 'value', mixed, 'a'); + s.link(centred, 'result', mixed, 'b'); + s.link(saturation, 'value', mixed, 't'); + s.link(mixed, 'result', output, 'color'); + return { texel, brightness, contrast, saturation, bright, shifted, scaled, centred, luma, mixed, output }; +} + +describe('a shader asset', () => { + it('is created with an empty graph, is not placeable, and is a graph asset for the commands', () => { + const f = makeProject(); + const s = shaderEditor(f); + expect(isPlaceableType('shader')).toBe(false); + expect(s.asset().graph).toEqual({ nodes: [], edges: [] }); + expect(schemaFor('shader')).toEqual([]); + expect(compileShader(s.asset().graph).reason).toBe('No Output node'); + }); + + it('offers shader nodes and nothing else, the Output only once, properties numbered', () => { + const f = makeProject(); + const s = shaderEditor(f); + const offered = () => paletteEntries(f.project, s.asset()).map((e) => e.def.type); + expect(offered()).toEqual(SHADER_NODES.map((d) => d.type)); + expect(offered()).not.toContain('OnStart'); + s.add(SHADER_OUTPUT); + expect(offered()).not.toContain(SHADER_OUTPUT); + const entry = () => paletteEntries(f.project, s.asset()).find((e) => e.def.type === SHADER_PROPERTY)!; + expect(entry().params).toEqual({ name: 'value' }); + s.add(SHADER_PROPERTY, entry().params); + expect(nextPropertyName(s.asset().graph)).toBe('value2'); + expect(entry().params).toEqual({ name: 'value2' }); + }); + + it('keeps its nodes out of every behavior palette and out of the built-in set', () => { + const f = makeProject(); + const behavior = new CreateAssetCommand('behavior', 'B'); + f.stack.execute(behavior); + const asset = findAssetOf(f.project, behavior.asset.id, 'behavior')!; + const offered = paletteEntries(f.project, asset).map((e) => e.def.type); + for (const def of SHADER_NODES) expect(offered).not.toContain(def.type); + expect(BUILT_IN_NODES.some((d) => d.category === 'shader')).toBe(false); + }); +}); + +describe('shaderParamDefs', () => { + it('turns every well-named Property node into a number row, in node order', () => { + const f = makeProject(); + const s = shaderEditor(f); + s.add(SHADER_PROPERTY, property('brightness')); + s.add(SHADER_PROPERTY, { name: 'amount', default: 0.25, min: 0, max: 1 }); + s.add(SHADER_PROPERTY, { name: 'not a name', default: 1 }); + s.add(SHADER_PROPERTY, { name: 'brightness', default: 7 }); + const rows = shaderParamDefs(s.asset().graph); + expect(rows.map((r) => r.key)).toEqual(['brightness', 'amount']); + expect(rows[0]).toEqual({ + key: 'brightness', + label: 'brightness', + kind: 'number', + default: 1, + min: 0, + max: 4, + step: 0.05, + }); + expect(rows[1]).toMatchObject({ default: 0.25, min: 0, max: 1, step: 0.05 }); + }); + + it('drops a max at or below the min instead of pinning the value', () => { + const f = makeProject(); + const s = shaderEditor(f); + s.add(SHADER_PROPERTY, { name: 'wide', default: 50, min: 10, max: 10 }); + const [row] = shaderParamDefs(s.asset().graph); + expect(row).toMatchObject({ min: 10, default: 50 }); + expect(row).not.toHaveProperty('max'); + }); +}); + +describe('compileShader', () => { + it('compiles the Adjust colors graph to a fragment with one uniform per property', () => { + const f = makeProject(); + const s = shaderEditor(f); + buildAdjustColors(s); + const build = compileShader(s.asset().graph); + expect(build.problems).toEqual({}); + expect(build.reason).toBe(''); + const compiled = build.compiled!; + expect(compiled.uniforms).toEqual([ + { name: 'brightness', default: 1 }, + { name: 'contrast', default: 1 }, + { name: 'saturation', default: 1 }, + ]); + const { fragment } = compiled; + for (const name of ['brightness', 'contrast', 'saturation']) + expect(fragment).toContain(`uniform float u_${name};`); + expect(fragment).toContain('vec3 rgb = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;'); + expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_color \* vec3\(n\d+_value\)\);/); + expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_result - vec3\(0\.5\)\);/); + expect(fragment).toMatch(/float n\d+_value = dot\(n\d+_result, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/); + expect(fragment).toMatch(/vec3 n\d+_result = mix\(vec3\(n\d+_value\), n\d+_result, n\d+_value\);/); + expect(fragment).toMatch(/finalColor = vec4\(clamp\(n\d+_result, 0\.0, 1\.0\) \* texel\.a, texel\.a\);/); + // Each local is declared before it is used. + const declared = new Set(); + for (const line of fragment.split('\n')) { + const m = /^\s+(?:float|vec3) (n\d+_\w+) = (.*);$/.exec(line); + if (!m) continue; + for (const used of m[2]!.match(/n\d+_\w+/g) ?? []) expect(declared.has(used)).toBe(true); + declared.add(m[1]!); + } + }); + + it('passes the texel through an Output with nothing connected, and types literals as floats', () => { + const f = makeProject(); + const s = shaderEditor(f); + s.add(SHADER_OUTPUT); + expect(compileShader(s.asset().graph).compiled!.fragment).toContain('clamp(rgb, 0.0, 1.0) * texel.a'); + + const g = shaderEditor(f, 'Scaled'); + const times = g.add('ShaderMultiply', { a: 2, b: 0.75 }); + const output = g.add(SHADER_OUTPUT); + g.link(times, 'result', output, 'color'); + const { fragment } = compileShader(g.asset().graph).compiled!; + // Two numbers make a number; feeding it to the colour broadcasts it. + expect(fragment).toMatch(/float n0_result = \(2\.0 \* 0\.75\);/); + expect(fragment).toContain('clamp(vec3(n0_result), 0.0, 1.0)'); + + const c = shaderEditor(f, 'Tinted'); + const tint = c.add('ShaderColor', { value: 0xff8000 }); + const times2 = c.add('ShaderMultiply'); + const out2 = c.add(SHADER_OUTPUT); + c.link(tint, 'color', times2, 'b'); + c.link(times2, 'result', out2, 'color'); + const tinted = compileShader(c.asset().graph).compiled!.fragment; + expect(tinted).toContain('vec3 n0_color = vec3(1.0000, 0.5020, 0.0000);'); + // An unconnected `a` is its typed-in default, 1 for a Multiply, lifted to a colour. + expect(tinted).toMatch(/vec3 n1_result = \(vec3\(1\.0\) \* n0_color\);/); + }); + + it('names the problem on the node, and compiles nothing while there is one', () => { + const f = makeProject(); + const s = shaderEditor(f); + // A colour cannot be wired to a number port; a Multiply's `any` result can, and carries one. + const texel = s.add(SHADER_TEXTURE_COLOR); + const doubled = s.add('ShaderMultiply', { b: 2 }); + const mixed = s.add('ShaderMix'); + const output = s.add(SHADER_OUTPUT); + s.link(texel, 'color', doubled, 'a'); + s.link(doubled, 'result', mixed, 't'); + s.link(mixed, 'result', output, 'color'); + let build = compileShader(s.asset().graph); + expect(build.compiled).toBeNull(); + expect(build.problems).toEqual({ [mixed]: '"t" needs a number, not a colour' }); + expect(build.reason).toBe('"t" needs a number, not a colour'); + + const dup = shaderEditor(f, 'Dup'); + dup.add(SHADER_OUTPUT); + const first = dup.add(SHADER_PROPERTY, property('amount')); + const second = dup.add(SHADER_PROPERTY, property('amount')); + const odd = dup.add(SHADER_PROPERTY, property('2fast')); + build = compileShader(dup.asset().graph); + expect(build.compiled).toBeNull(); + expect(build.problems).toEqual({ + [second]: 'Another property is named "amount"', + [odd]: 'Name must be one word: letters, digits, _', + }); + expect(build.problems[first]).toBeUndefined(); + + // A node cannot be wired to itself, but two can be wired into a ring. + const loop = shaderEditor(f, 'Loop'); + const one = loop.add('ShaderAdd'); + const two = loop.add('ShaderAdd'); + const out = loop.add(SHADER_OUTPUT); + loop.link(one, 'result', two, 'a'); + loop.link(two, 'result', one, 'b'); + loop.link(two, 'result', out, 'color'); + build = compileShader(loop.asset().graph); + expect(build.compiled).toBeNull(); + expect(build.problems).toEqual({ [two]: 'Feeds itself' }); + }); +}); + +describe('a sprite wearing a shader', () => { + function wearing() { + const f = makeSpriteScene(); + const s = shaderEditor(f); + buildAdjustColors(s); + const sprite = () => findAssetOf(f.project, f.spriteId, 'sprite')!; + return { ...f, s, sprite }; + } + + it('has the shader picker and, once one is picked, that shader’s rows', () => { + const f = wearing(); + const keys = () => schemaForAsset(f.project, f.sprite()).map((d) => d.key); + expect(keys()).toEqual(['enabled', 'width', 'height', 'color', 'texture', 'shader']); + expect(schemaFor('sprite').find((d) => d.key === SHADER_KEY)).toMatchObject({ + kind: 'asset', + assetTypes: ['shader'], + default: '', + }); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, f.s.id)); + expect(keys()).toEqual([ + 'enabled', + 'width', + 'height', + 'color', + 'texture', + 'shader', + 'brightness', + 'contrast', + 'saturation', + ]); + expect(assetDeviates(f.project, f.sprite())).toBe(true); + // A dangling id adds no rows and draws nothing – the picker still shows it. + f.sprite().props[SHADER_KEY] = 'gone'; + expect(keys()).toHaveLength(6); + }); + + it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => { + const f = wearing(); + const id = place(f, f.spriteId, { x: 0, y: 0 }); + const object = () => f.object(id); + expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(6); + f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.s.id)); + expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation'); + expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({ + shader: f.s.id, + brightness: 1, + contrast: 1, + saturation: 1, + }); + // A parameter override passes through its row: clamped to the node's range. + f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'brightness', 9)); + expect(object().overrides.brightness).toBe(4); + expect(objectDeviates(f.project, f.sceneOf(), object())).toBe(true); + // Back to no shader the parameter row is gone, so the stored value marks nothing. + f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, '')); + expect(resolveSceneObjectProps(f.project, f.sprite(), object())).not.toHaveProperty('brightness'); + expect(objectDeviates(f.project, f.sceneOf(), object())).toBe(false); + }); + + it('rejects a parameter no shader of the sprite declares', () => { + const f = wearing(); + const id = place(f, f.spriteId, { x: 0, y: 0 }); + expect(() => f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'brightness', 0.5))).toThrow( + /not defined/, + ); + }); + + it('survives a round trip: the graph, the sprite’s values and an override-only shader’s values', () => { + const f = wearing(); + const other = shaderEditor(f, 'Fade'); + other.add(SHADER_OUTPUT); + other.add(SHADER_PROPERTY, { name: 'amount', default: 0.5, min: 0, max: 1 }); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, f.s.id)); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'contrast', 2)); + const id = place(f, f.spriteId, { x: 0, y: 0 }); + f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, other.id)); + f.stack.execute(new SetOverrideCommand(f.sceneId, id, 'amount', 0.25)); + + const loaded = roundTrip(f.project); + const shader = findAssetOf(loaded, f.s.id, 'shader')!; + expect(shader.graph).toEqual(f.s.asset().graph); + expect(compileShader(shader.graph).compiled!.fragment).toBe( + compileShader(f.s.asset().graph).compiled!.fragment, + ); + const sprite = findAssetOf(loaded, f.spriteId, 'sprite')!; + expect(sprite.props).toMatchObject({ shader: f.s.id, contrast: 2, brightness: 1 }); + const placed = findAssetOf(loaded, f.sceneId, 'scene')!.objects.find((o) => o.id === id)!; + expect(placed.overrides).toEqual({ shader: other.id, amount: 0.25 }); + }); + + it('drops anything but shader nodes from a shader graph on load', () => { + const graph: Graph = { + nodes: [ + { id: 'o', type: SHADER_OUTPUT, x: 0, y: 0, params: {} }, + { id: 'e', type: 'OnStart', x: 0, y: 0, params: {} }, + ], + edges: [], + }; + const loaded = deserializeProject( + { id: 'p', name: 'P', assets: [{ id: 'sh', type: 'shader', name: 'S', props: {}, graph }] }, + 'p', + ); + const shader = loaded.assets[0] as ShaderAsset; + expect(shader.graph.nodes.map((n) => n.id)).toEqual(['o']); + }); +}); diff --git a/tests/core/texture-commands.test.ts b/tests/core/texture-commands.test.ts index e0c8268a..200a35dc 100644 --- a/tests/core/texture-commands.test.ts +++ b/tests/core/texture-commands.test.ts @@ -54,6 +54,7 @@ describe('CreateAssetCommand with starting props', () => { height: 32, color: 0xffffff, texture: texture.asset.id, + shader: '', }); stack.undo(); expect(findAsset(project, sprite.asset.id)).toBeUndefined(); diff --git a/tests/editor/editor-run.test.ts b/tests/editor/editor-run.test.ts index df2c1ba6..1b231bab 100644 --- a/tests/editor/editor-run.test.ts +++ b/tests/editor/editor-run.test.ts @@ -93,11 +93,11 @@ describe('the run-in-editor switch', () => { expect(schemaFor('behavior').map((d) => d.key)).toContain('runInEditor'); const asset = findAsset(f.project, f.behavior.id)!; - expect(runsInEditor(resolveSceneObjectProps(asset, f.object(f.logic)))).toBe(false); + expect(runsInEditor(resolveSceneObjectProps(f.project, asset, f.object(f.logic)))).toBe(false); expect(hasEditorRun(f.project, f.sceneOf())).toBe(false); f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true)); - expect(runsInEditor(resolveSceneObjectProps(asset, f.object(f.logic)))).toBe(true); + expect(runsInEditor(resolveSceneObjectProps(f.project, asset, f.object(f.logic)))).toBe(true); expect(hasEditorRun(f.project, f.sceneOf())).toBe(true); }); diff --git a/tests/editor/struct-field-edit.test.ts b/tests/editor/struct-field-edit.test.ts index 02b8996c..d697c387 100644 --- a/tests/editor/struct-field-edit.test.ts +++ b/tests/editor/struct-field-edit.test.ts @@ -52,7 +52,7 @@ describe('editing one field of a struct', () => { const object = (): SceneObject => findScene(f.state.project, f.sceneId)!.objects.find((o) => o.id === f.objectId)!; const structDef = () => { - const def = requirePropDef(asset(), 'piece'); + const def = requirePropDef(f.state.project, asset(), 'piece'); if (def.kind !== 'struct') throw new Error('piece is not a struct'); return def; }; @@ -62,7 +62,7 @@ describe('editing one field of a struct', () => { structFieldsOf({ state: f.state }, structDef(), { target: object, read: (o) => o.overrides.piece, - current: (o) => resolveSceneObjectProps(asset(), o).piece, + current: (o) => resolveSceneObjectProps(f.state.project, asset(), o).piece, write: (o, value) => (o.overrides.piece = value), remove: (o) => delete o.overrides.piece, commit: (value) => new SetOverrideCommand(f.sceneId, f.objectId, 'piece', value), @@ -99,7 +99,11 @@ describe('editing one field of a struct', () => { objectHandlers()({ key: 'x', kind: 'number' }).onChange(9); f.state.execute(new SetAssetPropertyCommand(f.behaviorId, 'piece', { x: 3, y: 99, tag: 'T' })); // The whole bundle is overridden now, so the asset moving on no longer reaches y. - expect(resolveSceneObjectProps(asset(), object()).piece).toEqual({ x: 9, y: 4, tag: 'T' }); + expect(resolveSceneObjectProps(f.state.project, asset(), object()).piece).toEqual({ + x: 9, + y: 4, + tag: 'T', + }); }); it('takes the stored value where that already is the resolved one – the dense asset bag', () => { @@ -114,6 +118,6 @@ describe('editing one field of a struct', () => { }, ); handlers({ key: 'y', kind: 'number' }).onChange(8); - expect(resolveAssetProps(asset()).piece).toEqual({ x: 3, y: 8, tag: 'T' }); + expect(resolveAssetProps(f.state.project, asset()).piece).toEqual({ x: 3, y: 8, tag: 'T' }); }); }); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs new file mode 100644 index 00000000..9d647a33 --- /dev/null +++ b/tools/e2e/28-shader.mjs @@ -0,0 +1,302 @@ +import { colourDistance } from '../lib/harness.mjs'; + +/** + * Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through + * its Shader dropdown – whose Property nodes are the rows below it – and visible in the pixels, in + * the scene view and in the game. The fixture ships "Adjust colors"; the step removes what it made. + */ + +/** (64, 128, 255): three distinct channels, so brightness, contrast and saturation each read differently. */ +const BASE = '#4080ff'; +const ADJUST = 'adjust-colors'; + +export async function shader(t) { + const { + check, + sleep, + json, + evaluate, + mutate, + click, + clickText, + locate, + press, + select, + undo, + play, + stop, + shot, + } = t; + const { doubleClick, typeText } = t; + const field = (label) => json(`__gm.inspectorField(${JSON.stringify(label)})`); + const info = () => json('__gm.shader.info()'); + const shaders = () => + json(`__gm.state.project.assets + .filter((a) => a.type === 'shader') + .map((a) => ({ id: a.id, name: a.name, nodes: a.graph.nodes.map((n) => ({ id: n.id, type: n.type, name: n.params.name })) }))`); + /** The overlay is drawn last, so a menu entry wins over a tab button or a row saying the same. */ + const clickLast = async (text) => { + const p = await locate(text, true); + await click(p.x, p.y); + await sleep(200); + }; + const assetProps = (patch) => + mutate( + `Object.assign(__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props, ${JSON.stringify(patch)})`, + ); + + await press('Escape'); + await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); + await evaluate('__gm.state.select(null)'); + await sleep(200); + + // 28a. The fixture ships Adjust colors as an asset; the + menu makes another and opens its tab on it. + const seeded = (await shaders()).find((s) => s.id === ADJUST); + check( + 'the fixture carries the Adjust colors shader as an asset of eleven nodes', + Boolean(seeded) && seeded.nodes.length === 11, + JSON.stringify(seeded), + ); + await clickText('+'); + await sleep(200); + await clickLast('Shader'); + await sleep(400); + const made = (await shaders()).find((s) => s.id !== ADJUST); + const selection = await json('__gm.state.selection'); + check( + 'the + menu creates a shader asset, selected, and its tab opens on it', + Boolean(made) && + selection?.kind === 'asset' && + selection.assetId === made.id && + (await json('__gm.tab()')) === 'shader' && + (await json('__gm.shader.current()')) === made.id, + JSON.stringify({ made, selection, tab: await json('__gm.tab()') }), + ); + if (!made) return; + check('the tab button has room for its label', (await json("__gm.shownLabel('Shader')")) === 'Shader'); + // Named after its type, the asset's row in the Assets panel would say "Shader" like the inspector row. + await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).name = 'Glow'`); + made.name = 'Glow'; + check('an empty shader says what it lacks', (await info()) === 'No Output node', await info()); + + // 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette. + const canvas = await json('__gm.shader.canvasRect()'); + await click(canvas.x + 80, canvas.y + 60, 'right'); + await sleep(300); + const offered = await json('__gm.overlayTexts()'); + check( + 'right-click offers shader nodes and no behavior node', + ['Texture color', 'Output', 'Property', 'Mix', 'Luminance'].every((text) => offered.includes(text)) && + !offered.some((text) => /On start|Number|Branch|Set state/.test(text)), + JSON.stringify(offered), + ); + await clickLast('Texture color'); + await sleep(300); + await click(canvas.x + 420, canvas.y + 60, 'right'); + await sleep(300); + await clickLast('Output'); + await sleep(300); + const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? []; + check( + 'both nodes are in the graph, the Output where its right-click was', + (await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput', + JSON.stringify(await madeNodes()), + ); + await click(canvas.x + 420, canvas.y + 320, 'right'); + await sleep(300); + const again = await json('__gm.overlayTexts()'); + check( + 'the palette offers the Output only once', + !again.includes('Output') && again.includes('Property'), + JSON.stringify(again), + ); + await press('Escape'); + await sleep(200); + check( + 'an Output with nothing connected compiles: the texel passes through, no property declared', + (await info()) === '0 properties', + await info(), + ); + + // 28c. A Property node: its name is the row a sprite shows; a bad name is a problem on the node. + await click(canvas.x + 80, canvas.y + 320, 'right'); + await sleep(300); + await clickLast('Property'); + await sleep(300); + const propertyNode = (await madeNodes()).find((n) => n.type === 'ShaderProperty'); + check( + 'the placed property is named "value" and counted', + propertyNode?.name === 'value' && (await info()) === '1 property', + JSON.stringify({ propertyNode, info: await info() }), + ); + const nameField = await json("__gm.inspectorField('Name', true)"); + check('the inspector shows a Name field for the property', Boolean(nameField), JSON.stringify(nameField)); + await doubleClick(nameField.x, nameField.y); + await typeText('glow'); + await press('Enter'); + await sleep(300); + check( + 'typing in the Name field renames the property, on the node too', + (await madeNodes()).find((n) => n.id === propertyNode?.id)?.name === 'glow' && + (await json(`__gm.shader.nodeLabel(${JSON.stringify(propertyNode?.id)})`)) === 'glow', + JSON.stringify(await madeNodes()), + ); + await shot('28-shader-tab'); + await mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'no good';`); + await sleep(300); + const problem = await json(`__gm.shader.nodeProblem(${JSON.stringify(propertyNode?.id)})`); + check( + 'a name that is not one word is a problem on the node and the reason in the readout', + typeof problem === 'string' && problem.startsWith('Name must be') && (await info()) === problem, + JSON.stringify({ problem, info: await info() }), + ); + await mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'glow';`); + await sleep(300); + + // 28d. A flat sprite on the camera, drawn on top of everything; its Shader dropdown lists the assets. + await mutate(` + const project = __gm.state.project; + const sprite = structuredClone(project.assets.find((a) => a.id === 'crate')); + sprite.id = 'shade-sprite'; + sprite.name = 'Shaded'; + sprite.props = { ...sprite.props, width: 160, height: 120, color: 0x4080ff, texture: '', shader: '' }; + project.assets.push(sprite); + const scene = __gm.state.activeScene; + const cam = scene.objects.find((o) => o.id === 'o3').position; + scene.objects.push({ id: 'oshade', assetId: 'shade-sprite', name: '', position: { x: cam.x, y: cam.y, z: 950 }, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); + __gm.state.select(null)`); + await sleep(600); + // The pixels are the scene view's; the tab is still the shader's. + await evaluate("__gm.showTab('scene')"); + await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' }); + await sleep(400); + const box = await json("__gm.objectRect('oshade')"); + check('the shaded sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box)); + if (!box) return; + // Off centre: the camera symbol sits on the very same spot, and symbols draw above objects. + const centre = () => + json(`__gm.pixelAt(${Math.round(box.x + 0.3 * box.w)}, ${Math.round(box.y + 0.3 * box.h)})`); + const base = await centre(); + check('with no shader the sprite is its plain colour', colourDistance(base, BASE) < 12, base); + // The Shader row's dropdown reads "None"; it is the last such text in the inspector (Parent and + // Texture say it above). The label itself is no anchor: the tab button says "Shader" too. + const shaderField = await locate('None', true); + check( + 'a sprite has a Shader row reading None, and no shader brings no parameter rows', + shaderField.x > box.x + box.w && (await field('brightness')) === null, + JSON.stringify(shaderField), + ); + await click(shaderField.x, shaderField.y); + await sleep(300); + const choices = await json('__gm.overlayTexts()'); + check( + 'the dropdown offers None and every shader asset', + choices.some((text) => text.includes('None')) && + choices.some((text) => text.includes('Adjust colors')) && + choices.some((text) => text.includes(made.name)), + JSON.stringify({ choices, shaderField }), + ); + await clickLast(choices.find((text) => text.includes('Adjust colors'))); + await sleep(400); + const rows = await Promise.all([field('brightness'), field('contrast'), field('saturation')]); + check( + 'picking Adjust colors adds its three property rows below the dropdown', + rows.every((r) => r !== null) && + (await json("__gm.state.activeScene.objects.find((o) => o.id === 'oshade').overrides.shader")) === + ADJUST, + JSON.stringify(rows), + ); + await shot('28-shader-rows'); + await undo(); + await sleep(300); + check('undoing the choice takes the rows away again', (await field('brightness')) === null); + await select(null); + await sleep(200); + + // 28e. Each property is visible in the pixels, with 1 neutral. + await assetProps({ shader: ADJUST }); + await sleep(300); + const neutral = await centre(); + check( + 'Adjust colors at its defaults draws exactly what no shader draws', + colourDistance(neutral, base) < 6, + JSON.stringify({ base, neutral }), + ); + await assetProps({ brightness: 0.5 }); + await sleep(300); + const darker = await centre(); + check('brightness 0.5 halves every channel', colourDistance(darker, '#204080') < 24, darker); + await assetProps({ brightness: 1, contrast: 0 }); + await sleep(300); + const flat = await centre(); + check('contrast 0 flattens the colour to mid grey', colourDistance(flat, '#808080') < 24, flat); + await assetProps({ contrast: 1, saturation: 0 }); + await sleep(300); + const grey = await centre(); + check('saturation 0 leaves the luma as a grey', colourDistance(grey, '#7c7c7c') < 24, grey); + await shot('28-shader-desaturated'); + + // 28f. The game draws through the same path, so play shows the shader too. + await assetProps({ saturation: 1, brightness: 0.5 }); + await play(); + await sleep(500); + const at = await json("__gm.gameScreen('oshade')"); + const played = at ? await json(`__gm.pixelAt(${Math.round(at.x + 20)}, ${Math.round(at.y + 20)})`) : null; + check( + 'during play the sprite is drawn with its shader', + played !== null && colourDistance(played, '#204080') < 24, + JSON.stringify({ at, played }), + ); + await stop(); + await sleep(300); + + // 28g. Editing a shader's graph reaches every sprite wearing it: a Color wired into the new one's Output. + await assetProps({ brightness: 1, shader: made.id }); + await sleep(300); + const through = await centre(); + check( + 'the new shader passes the texel through', + colourDistance(through, base) < 6, + JSON.stringify({ base, through }), + ); + await mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput'); + a.graph.nodes.push({ id: 'sh-red', type: 'ShaderColor', x: 0, y: 200, params: { value: 0xff0000 } }); + a.graph.edges.push({ id: 'sh-red-edge', from: { node: 'sh-red', port: 'color' }, to: { node: out.id, port: 'color' } });`); + await sleep(400); + const red = await centre(); + check( + 'wiring a red Color into the Output recompiles: the sprite turns red', + colourDistance(red, '#ff0000') < 24, + red, + ); + + // 28h. Back to None: no filter at all, the plain colour returns bit for bit. + await assetProps({ shader: '' }); + await sleep(300); + const back = await centre(); + check( + 'switching back to None restores the plain colour', + colourDistance(back, base) < 6, + JSON.stringify({ base, back }), + ); + + await mutate(` + const project = __gm.state.project; + project.assets = project.assets.filter((a) => a.id !== 'shade-sprite' && a.id !== ${JSON.stringify(made.id)}); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'oshade')`); + await evaluate('__gm.state.select(null)'); + await sleep(200); + check( + 'the step leaves nothing behind but the fixture’s shader', + (await json("__gm.state.project.assets.some((a) => a.id === 'shade-sprite')")) === false && + (await json("__gm.state.activeScene.objects.some((o) => o.id === 'oshade')")) === false && + (await shaders()).map((s) => s.id).join(',') === ADJUST, + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 5d5552c3..b57af495 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -30,6 +30,7 @@ import { propertyNodes } from './24-property-nodes.mjs'; import { animation } from './25-animation.mjs'; import { stateMachine } from './26-state-machine.mjs'; import { consolePanel } from './27-console.mjs'; +import { shader } from './28-shader.mjs'; export const STEPS = [ hierarchy, @@ -59,4 +60,5 @@ export const STEPS = [ animation, stateMachine, consolePanel, + shader, ]; diff --git a/tools/lib/shaders.mjs b/tools/lib/shaders.mjs new file mode 100644 index 00000000..0ddfcea5 --- /dev/null +++ b/tools/lib/shaders.mjs @@ -0,0 +1,45 @@ +import { edge, node } from './builders.mjs'; + +/** + * The "Adjust colors" shader as an asset document: brightness, contrast and saturation, 1 neutral, + * on the straight colour – rgb *= brightness; rgb = (rgb - 0.5) * contrast + 0.5; + * rgb = mix(luma, rgb, saturation). The recipe the built-in prototype of #62 had, now eleven nodes. + */ +export function adjustColorsShader(id = 'adjust-colors', name = 'Adjust colors') { + const property = (nodeId, propertyName, x, y) => + node(nodeId, 'ShaderProperty', x, y, { name: propertyName, default: 1, min: 0, max: 4 }); + return { + id, + type: 'shader', + name, + props: {}, + graph: { + nodes: [ + node(`${id}-texel`, 'ShaderTextureColor', 0, 0), + property(`${id}-brightness`, 'brightness', 0, 160), + property(`${id}-contrast`, 'contrast', 0, 320), + property(`${id}-saturation`, 'saturation', 0, 480), + node(`${id}-bright`, 'ShaderMultiply', 300, 0), + node(`${id}-shift`, 'ShaderSubtract', 560, 0, { b: 0.5 }), + node(`${id}-scale`, 'ShaderMultiply', 820, 0), + node(`${id}-centre`, 'ShaderAdd', 1080, 0, { b: 0.5 }), + node(`${id}-luma`, 'ShaderLuminance', 1340, 160), + node(`${id}-mix`, 'ShaderMix', 1600, 0), + node(`${id}-out`, 'ShaderOutput', 1860, 0), + ], + edges: [ + edge(`${id}-e1`, `${id}-texel`, 'color', `${id}-bright`, 'a'), + edge(`${id}-e2`, `${id}-brightness`, 'value', `${id}-bright`, 'b'), + edge(`${id}-e3`, `${id}-bright`, 'result', `${id}-shift`, 'a'), + edge(`${id}-e4`, `${id}-shift`, 'result', `${id}-scale`, 'a'), + edge(`${id}-e5`, `${id}-contrast`, 'value', `${id}-scale`, 'b'), + edge(`${id}-e6`, `${id}-scale`, 'result', `${id}-centre`, 'a'), + edge(`${id}-e7`, `${id}-centre`, 'result', `${id}-luma`, 'color'), + edge(`${id}-e8`, `${id}-luma`, 'value', `${id}-mix`, 'a'), + edge(`${id}-e9`, `${id}-centre`, 'result', `${id}-mix`, 'b'), + edge(`${id}-e10`, `${id}-saturation`, 'value', `${id}-mix`, 't'), + edge(`${id}-e11`, `${id}-mix`, 'result', `${id}-out`, 'color'), + ], + }, + }; +} diff --git a/tools/seed-actor.mjs b/tools/seed-actor.mjs index 6f93ef15..c6493e4f 100644 --- a/tools/seed-actor.mjs +++ b/tools/seed-actor.mjs @@ -21,6 +21,7 @@ import { fileURLToPath } from 'node:url'; import { edge, node, port, propertiesOf, sceneObject, tagged, variable, written } from './lib/builders.mjs'; import { PROJECTS, cliArgs, uploadTexture, writeProject } from './lib/projects.mjs'; +import { adjustColorsShader } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('seed-actor.mjs', PROJECTS.actor); @@ -237,6 +238,8 @@ const project = { frames, }, ...walkAnimations, + // Present but worn by nothing: the smoke reads the actor's pixels, a filter pass must not touch them. + adjustColorsShader(), { id: 'actor-sprite', type: 'sprite', diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index ff6a1fdd..ff4172e6 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -8,6 +8,7 @@ */ import { edge, node, port, sceneObject, variable } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; +import { adjustColorsShader } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture); @@ -95,6 +96,8 @@ const project = { graph: moverGraph, }, { id: 'spawner', type: 'behavior', name: 'Spawner', props: {}, variables: [], graph: spawnerGraph }, + // Worn by nothing here; the shader step assigns it and reads the pixels. + adjustColorsShader(), ], }; diff --git a/tools/seed-jumprun.mjs b/tools/seed-jumprun.mjs index fde5218c..4d0025a6 100644 --- a/tools/seed-jumprun.mjs +++ b/tools/seed-jumprun.mjs @@ -16,6 +16,7 @@ */ import { edge, node, port, propertiesOf, sceneObject, variable, written } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; +import { adjustColorsShader } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('seed-jumprun.mjs', PROJECTS.jumpRun); @@ -388,7 +389,14 @@ const project = { ], }, { id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } }, - { id: 'box', type: 'sprite', name: 'Box', props: { width: 100, height: 100, color: STONE } }, + // The boxes wear the shader at its neutral values: the pass runs, the picture stays the same. + { + id: 'box', + type: 'sprite', + name: 'Box', + props: { width: 100, height: 100, color: STONE, shader: 'adjust-colors' }, + }, + adjustColorsShader(), { id: 'player-sprite', type: 'sprite', diff --git a/tools/seed-tetris.mjs b/tools/seed-tetris.mjs index b8432dbc..8a083f35 100644 --- a/tools/seed-tetris.mjs +++ b/tools/seed-tetris.mjs @@ -19,6 +19,7 @@ import { written, } from './lib/builders.mjs'; import { PROJECTS, cliArgs, writeProject } from './lib/projects.mjs'; +import { adjustColorsShader } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('seed-tetris.mjs', PROJECTS.tetris); @@ -936,8 +937,10 @@ const project = { id: 'well-sprite', type: 'sprite', name: 'Well', - props: { width: COLS * CELL, height: ROWS * CELL, color: WELL }, + // Wears the shader at its neutral values: the pass runs, the picture stays the same. + props: { width: COLS * CELL, height: ROWS * CELL, color: WELL, shader: 'adjust-colors' }, }, + adjustColorsShader(), { id: 'cell-sprite', type: 'sprite', name: 'Cell', props: { width: CELL - 1, height: CELL - 1 } }, { id: 'label',