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

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

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

No files matched your search

+5
View File
@@ -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;
+13 -3
View File
@@ -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<CentreTab, string> = {
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' &&
+13
View File
@@ -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,
+7 -1
View File
@@ -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<string>();
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)) {
+4
View File
@@ -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;
}
+14 -3
View File
@@ -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: {} });
}
+7 -4
View File
@@ -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`,
+11 -9
View File
@@ -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 = {
+2 -2
View File
@@ -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,
+12 -9
View File
@@ -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);
}
}
+5 -3
View File
@@ -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.',
});
+9 -4
View File
@@ -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<Target>(
function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): Owner<Asset> | 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<ObjectRef> | 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,
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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 || '?',
+44
View File
@@ -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<ShaderAsset, GraphCanvas> {
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));
}
}
+6 -1
View File
@@ -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;
+5 -1
View File
@@ -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<string>();
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);