Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
game-maker / suite (push) Failing after 3m59s
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:
1 parent
6141d07d4f
commit
42cafa7543
62 files changed
+1682
-144
No files matched your search
@@ -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
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
+13
-3
@@ -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 {
|
||||
|
||||
@@ -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<AssetType, string> = {
|
||||
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<AssetType, 'scene' | 'texture' | 'animation' | 'statemachine'>;
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = 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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -48,7 +48,7 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri
|
||||
const refKeys = new Map<string, string[]>();
|
||||
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);
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
type ValueOf,
|
||||
type VariableKind,
|
||||
} from './kinds';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { flatListOf, listItemOf } from './listItems';
|
||||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||||
|
||||
/**
|
||||
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
|
||||
@@ -124,6 +127,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
// The fill while there is no picture, and a tint multiplier once there is one: white = no tint.
|
||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
|
||||
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
|
||||
// The shader's own parameters follow this row – see `schemaForAsset`. Empty is no shader at all.
|
||||
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
|
||||
],
|
||||
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
|
||||
texture: [
|
||||
@@ -157,6 +162,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
|
||||
// The states are the asset's graph (see StateMachineAsset), not a property.
|
||||
statemachine: [],
|
||||
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
|
||||
shader: [],
|
||||
collider: [
|
||||
{
|
||||
key: 'shape',
|
||||
@@ -204,15 +211,43 @@ export function schemaFor(type: AssetType): PropertySchema {
|
||||
return FULL_SCHEMAS[type];
|
||||
}
|
||||
|
||||
/** The type schema, for behaviors followed by their declared variables. */
|
||||
export function schemaForAsset(asset: Asset): PropertySchema {
|
||||
return asset.type === 'behavior'
|
||||
? [...FULL_SCHEMAS.behavior, ...asset.variables]
|
||||
: FULL_SCHEMAS[asset.type];
|
||||
/**
|
||||
* The type schema, for behaviors followed by their declared variables, for sprites by the
|
||||
* parameters of the shader asset their bag names – which is why the project is needed.
|
||||
*/
|
||||
export function schemaForAsset(project: Project, asset: Asset): PropertySchema {
|
||||
return schemaForBag(project, asset, asset.props);
|
||||
}
|
||||
|
||||
export function propDef(asset: Asset, key: string): PropertyDef | undefined {
|
||||
return schemaForAsset(asset).find((d) => d.key === key);
|
||||
/** A placed object's rows: a sprite overriding its shader has that shader's parameters. */
|
||||
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
|
||||
const shader = object.overrides[SHADER_KEY];
|
||||
return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
|
||||
}
|
||||
|
||||
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
|
||||
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
|
||||
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
|
||||
return FULL_SCHEMAS[asset.type];
|
||||
}
|
||||
|
||||
/** The rows a shader asset adds to the sprites wearing it; a dangling id adds none. */
|
||||
export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] {
|
||||
const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined;
|
||||
return shader ? shaderParamDefs(shader.graph) : [];
|
||||
}
|
||||
|
||||
/** The parameters of every shader in the project, one row per name – what a sprite *may* have. */
|
||||
export function allShaderParamRows(project: Project): PropertyDef[] {
|
||||
const rows = new Map<string, PropertyDef>();
|
||||
for (const asset of project.assets)
|
||||
if (asset.type === 'shader')
|
||||
for (const row of shaderParamDefs(asset.graph)) if (!rows.has(row.key)) rows.set(row.key, row);
|
||||
return [...rows.values()];
|
||||
}
|
||||
|
||||
export function propDef(project: Project, asset: Asset, key: string): PropertyDef | undefined {
|
||||
return schemaForAsset(project, asset).find((d) => d.key === key);
|
||||
}
|
||||
|
||||
/** A fresh bag of the type's default values (callers may mutate it). */
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Graph, GraphNode } from '../graph/Graph';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { type FlatDef, isValidFieldName } from './PropertySchema';
|
||||
|
||||
/** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */
|
||||
export const SHADER_KEY = 'shader';
|
||||
|
||||
/**
|
||||
* The node of a shader graph that declares a parameter: its `name` is a sprite property and a
|
||||
* GLSL uniform, `default`/`min`/`max` are the row the inspector shows.
|
||||
*/
|
||||
export const SHADER_PROPERTY = 'ShaderProperty';
|
||||
export const PROPERTY_NAME_PARAM = 'name';
|
||||
export const PROPERTY_DEFAULT_PARAM = 'default';
|
||||
export const PROPERTY_MIN_PARAM = 'min';
|
||||
export const PROPERTY_MAX_PARAM = 'max';
|
||||
|
||||
export function shaderOf(bag: PropertyBag): string {
|
||||
return textOf(bag[SHADER_KEY]);
|
||||
}
|
||||
|
||||
export function isPropertyNode(node: GraphNode): boolean {
|
||||
return node.type === SHADER_PROPERTY;
|
||||
}
|
||||
|
||||
export function propertyName(node: GraphNode): string {
|
||||
return textOf(node.params[PROPERTY_NAME_PARAM] ?? '');
|
||||
}
|
||||
|
||||
/** A property's name is a bag key and a uniform, so it has to be an identifier. */
|
||||
export function isValidPropertyName(name: string): boolean {
|
||||
return isValidFieldName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties a shader graph declares, in node order: one number row per `Property` node with
|
||||
* a valid name. A second node with the same name declares nothing new – both read one uniform.
|
||||
*/
|
||||
export function shaderParamDefs(graph: Graph): FlatDef<'number'>[] {
|
||||
const rows: FlatDef<'number'>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const node of graph.nodes) {
|
||||
if (!isPropertyNode(node)) continue;
|
||||
const name = propertyName(node);
|
||||
if (!isValidPropertyName(name) || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
rows.push(propertyRow(name, node.params));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function propertyRow(name: string, params: PropertyBag): FlatDef<'number'> {
|
||||
const min = numberParam(params, PROPERTY_MIN_PARAM, 0);
|
||||
const max = numberParam(params, PROPERTY_MAX_PARAM, 1);
|
||||
const row: FlatDef<'number'> = {
|
||||
key: name,
|
||||
label: name,
|
||||
kind: 'number',
|
||||
default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0),
|
||||
min,
|
||||
step: niceStep(max > min ? max - min : 1),
|
||||
};
|
||||
// A max at or below the min would pin the value; treated as "no max" instead.
|
||||
return max > min ? { ...row, max } : row;
|
||||
}
|
||||
|
||||
function numberParam(params: PropertyBag, key: string, fallback: number): number {
|
||||
const n = Number(params[key]);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/** A twentieth of the range's power of ten: 0.05 for 0-4, 0.05 for 0-1, 5 for 0-100. */
|
||||
function niceStep(range: number): number {
|
||||
return 10 ** Math.floor(Math.log10(range)) / 20;
|
||||
}
|
||||
|
||||
/** What a property placed from the palette is called: `value`, then `value2`, `value3` … */
|
||||
export function nextPropertyName(graph: Graph): string {
|
||||
const taken = new Set(graph.nodes.filter(isPropertyNode).map(propertyName));
|
||||
if (!taken.has('value')) return 'value';
|
||||
for (let n = 2; ; n++) if (!taken.has(`value${n}`)) return `value${n}`;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
/** 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<string, string> = {};
|
||||
const problem = (node: GraphNode, text: string) => {
|
||||
if (!(node.id in problems)) problems[node.id] = text;
|
||||
};
|
||||
|
||||
const names = new Set<string>();
|
||||
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<string, Record<string, Expr>>();
|
||||
const visiting = new Set<string>();
|
||||
let count = 0;
|
||||
|
||||
const emitNode = (node: GraphNode): Record<string, Expr> | 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<string, Expr> = {};
|
||||
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<string, Expr> = {};
|
||||
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: '' };
|
||||
}
|
||||
@@ -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<string, Expr>, params: PropertyBag): Record<string, Expr>;
|
||||
}
|
||||
|
||||
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)})`;
|
||||
}
|
||||
@@ -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<string, PropertySchema>();
|
||||
const variables = new Map<string, PropertyDef[]>();
|
||||
const rawProps = new Map<string, Record<string, unknown>>();
|
||||
const shaderGraphs = new Map<string, Graph>();
|
||||
const shaderRows = new Map<string, PropertyDef[]>();
|
||||
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<string, unknown>): 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<string, PropertySchema>): SceneObject[] {
|
||||
function normalizeObjects(
|
||||
raw: unknown,
|
||||
schemaOf: (assetId: string, overrides: Record<string, unknown>) => 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<string>();
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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
@@ -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' &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {} });
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || '?',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, { fingerprint: string; build: ShaderBuild }>();
|
||||
|
||||
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<string, ShaderBuild | null>();
|
||||
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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, number> | 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();
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('asset commands', () => {
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
shader: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+18
-13
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ describe('serialize', () => {
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
shader: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof shaderEditor>) {
|
||||
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<string>();
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<SceneObject>({ 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' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user