The shader tab previews the effect on the last picked image (#168)
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m43s

A shader could only be judged by placing it on a sprite and running
the scene. The shader tab now shows its effect next to the graph, on
the image the user picked last, without a picker to maintain.

- The preview follows the last selection that has an image: a placed
  sprite with its overrides, size and tint, a sprite asset, a texture
  or a frame. Clicking the shader, a node, a behavior or a scene keeps
  the picture.
- Without such a selection, or when the picked image is deleted, a
  built-in 64 px test image is shown. Its colour runs with u and v, a
  checkerboard shows where a UV lands and the border fades for alpha.
- Each property gets a slider. The sliders belong to the preview and
  never write to the project. They start at the sprite's values when
  it carries this shader, otherwise at the default.
- The preview has its own clock. Hold and Restart are only enabled
  when a Time node is in the graph.
- The graph | splitter | panel layout of the particles tab moves into
  PreviewSplit, and both tabs use it.
- A nine-sliced frame is previewed unsliced. This is documented.

Findings:

- The slider row stayed squeezed to a few pixels when the properties
  changed while the tab was hidden. The layout was triggered one level
  too high. The slider check in block 28n holds this.
- The graph canvas is narrower now, 385 px at a 1280 px window, the
  same as in the particles tab. Block 28b clicked at a fixed x+420 and
  now uses the measured width.

Tests:

- ./check.sh: 132 files, 1952 tests (was 131 / 1941), exit 0
- npm run build: exit 0
- GM_STEPS=28,31 freshly seeded: ALL PASSED, 105 checks
- 11 new unit tests in tests/editor/shader-preview.test.ts
- 7 new e2e checks in block 28n
- Counter-probes: start values ignoring the sprite fail 1 unit test,
  a preview ignoring the selection fails 2 e2e checks
- Check total 1209 in README is computed (1202 + 7), CI measures it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 08:55:02 +02:00
1 parent 2d79230b55
commit 73a71473f1
10 files changed
+843 -31

No files matched your search

+1 -1
View File
@@ -43,7 +43,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: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1202 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1209 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+3 -1
View File
@@ -714,12 +714,13 @@ export class Editor implements PlayHost {
// to do it, and its states move just the same. // to do it, and its states move just the same.
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
if (this.tab === 'particles') this.particlesEditor.tick(); if (this.tab === 'particles') this.particlesEditor.tick();
if (this.tab === 'shader') this.shaderEditor.tick();
if (this.movesByItself()) requestRender(); if (this.movesByItself()) requestRender();
} }
/** /**
* What changes without anyone touching it, and therefore cannot be woken by an event: the * What changes without anyone touching it, and therefore cannot be woken by an event: the
* running world, a Time shader on screen, the two previews that are moving pictures. * running world, a Time shader on screen, the previews that are moving pictures.
*/ */
private movesByItself(): boolean { private movesByItself(): boolean {
return ( return (
@@ -728,6 +729,7 @@ export class Editor implements PlayHost {
(this.viewport.visible && this.viewport.animates) || (this.viewport.visible && this.viewport.animates) ||
(this.gameView.visible && this.gameView.animates) || (this.gameView.visible && this.gameView.animates) ||
this.tab === 'particles' || this.tab === 'particles' ||
(this.tab === 'shader' && this.shaderEditor.animates) ||
(this.tab === 'animation' && this.animationEditor.isPlaying) || (this.tab === 'animation' && this.animationEditor.isPlaying) ||
(this.tab === 'tile' && this.tileEditor.isPlaying) (this.tab === 'tile' && this.tileEditor.isPlaying)
); );
+29
View File
@@ -0,0 +1,29 @@
import { clamp, HStack, Splitter, type Widget } from '../ui';
/** The panel can be dragged in to the width of its own controls, and open to most of a wide window. */
const MIN_PREVIEW = 200;
const MAX_PREVIEW = 640;
/**
* A graph with its preview panel right of it, the two dragged apart like the console: how much of
* the tab is graph and how much is picture depends on what is being worked on, and on a narrow
* window a fixed panel eats the canvas. The Particles and Shader tabs (#86, #168).
*/
export class PreviewSplit extends HStack {
readonly splitter = new Splitter('x');
private panelWidth = 280;
constructor(canvas: Widget, side: Widget) {
super();
side.minWidth = this.panelWidth;
this.splitter.onDrag = (delta) => {
this.panelWidth = clamp(this.panelWidth - delta, MIN_PREVIEW, MAX_PREVIEW);
side.minWidth = this.panelWidth;
// The row keeps its own size, so resizing it would not lay out again: ask it directly.
this.relayout();
};
this.add(canvas, 1);
this.add(this.splitter);
this.add(side);
}
}
+15
View File
@@ -417,6 +417,21 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
center: () => centreOf(editor.shaderEditor.canvas), center: () => centreOf(editor.shaderEditor.canvas),
snapping: () => editor.shaderEditor.canvas.snapEnabled, snapping: () => editor.shaderEditor.canvas.snapEnabled,
snapButton: () => snapButton(editor.shaderEditor.snap), snapButton: () => snapButton(editor.shaderEditor.snap),
/** The panel beside the graph (#168): what it shows, its clock and the sliders' values. */
preview: () => ({
...editor.shaderEditor.controls,
holding: editor.shaderEditor.preview.holding,
seconds: Math.round(editor.shaderEditor.preview.time * 100) / 100,
}),
previewRect: () => rectOf(editor.shaderEditor.preview),
/** Where the picture sits inside the panel, in screen pixels. */
pictureRect: () => editor.shaderEditor.preview.pictureRect,
sliderAt: (key: string) => {
const slider = editor.shaderEditor.sliderOf(key);
return slider ? rectOf(slider) : null;
},
splitterAt: () => centreOf(editor.shaderEditor.splitter),
hold: (at: number | null) => editor.shaderEditor.hold(at),
}, },
/** The console panel: its lines as kept, its drawn height, and the divider above it. */ /** The console panel: its lines as kept, its drawn height, and the divider above it. */
console: { console: {
+10 -24
View File
@@ -2,9 +2,10 @@ import type { Asset, ParticlesAsset } from '../../core/model/Asset';
import { LIFETIME_KEY, TIME_SCALE_KEY } from '../../core/properties/particles'; import { LIFETIME_KEY, TIME_SCALE_KEY } from '../../core/properties/particles';
import { defaultsFor } from '../../core/properties/PropertySchema'; import { defaultsFor } from '../../core/properties/PropertySchema';
import { particleLibrary } from '../../viewport/ParticleLibrary'; import { particleLibrary } from '../../viewport/ParticleLibrary';
import { Button, clamp, HStack, Label, Slider, Spacer, Splitter, Theme, VStack, type Widget } from '../../ui'; import { Button, HStack, Label, Slider, Spacer, type Splitter, Theme, VStack, type Widget } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor'; import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas'; import { GraphCanvas } from '../graph/GraphCanvas';
import { PreviewSplit } from '../PreviewSplit';
import { SnapToggle } from '../graph/SnapToggle'; import { SnapToggle } from '../graph/SnapToggle';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { ParticlePreview } from './ParticlePreview'; import { ParticlePreview } from './ParticlePreview';
@@ -14,9 +15,6 @@ const INFO_WIDTH = 220;
/** How far the time slider reaches: three lifetimes, so a particle's whole arc fits with room after. */ /** How far the time slider reaches: three lifetimes, so a particle's whole arc fits with room after. */
const SPAN_LIFETIMES = 3; const SPAN_LIFETIMES = 3;
const MIN_SPAN = 2; const MIN_SPAN = 2;
/** The panel can be dragged in to the width of its own controls, and open to most of a wide window. */
const MIN_PREVIEW = 200;
const MAX_PREVIEW = 640;
/** The button reads "Hold" or "Run"; a floor keeps the row from moving when it changes. */ /** The button reads "Hold" or "Run"; a floor keeps the row from moving when it changes. */
const HOLD_WIDTH = 52; const HOLD_WIDTH = 52;
@@ -35,11 +33,9 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
onClick: () => this.toggleHold(), onClick: () => this.toggleHold(),
}); });
private readonly timeLabel = new Label('0.00 s', { style: 'small' }); private readonly timeLabel = new Label('0.00 s', { style: 'small' });
readonly splitter = new Splitter('x'); /** Assigned by `body`; public for the check scripts, which drag it. */
private readonly side = new VStack(); splitter!: Splitter;
private row = new HStack();
private span = MIN_SPAN; private span = MIN_SPAN;
private previewWidth = 280;
constructor(state: EditorState) { constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH }); super(state, { infoWidth: INFO_WIDTH });
@@ -71,22 +67,12 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
bar.add(buttons); bar.add(buttons);
bar.add(time); bar.add(time);
this.side.add(bar); const side = new VStack();
this.side.add(this.preview, 1); side.add(bar);
this.side.minWidth = this.previewWidth; side.add(this.preview, 1);
// Draggable like the console: how much of the tab is graph and how much is picture is a matter const split = new PreviewSplit(canvas, side);
// of what is being worked on, and on a narrow window a fixed panel eats the canvas. this.splitter = split.splitter;
this.splitter.onDrag = (delta) => { return split;
this.previewWidth = clamp(this.previewWidth - delta, MIN_PREVIEW, MAX_PREVIEW);
this.side.minWidth = this.previewWidth;
// The row keeps its own size, so resizing it would not lay out again: ask it directly.
this.row.relayout();
};
this.row = new HStack();
this.row.add(canvas, 1);
this.row.add(this.splitter);
this.row.add(this.side);
return this.row;
} }
/** Called every frame while the tab is showing: the panel is a moving picture. */ /** Called every frame while the tab is showing: the panel is a moving picture. */
+198 -2
View File
@@ -1,26 +1,142 @@
import type { Asset, ShaderAsset } from '../../core/model/Asset'; import type { Asset, ShaderAsset } from '../../core/model/Asset';
import type { FlatDef } from '../../core/properties/PropertySchema';
import { shaderParamDefs } from '../../core/properties/shaders'; import { shaderParamDefs } from '../../core/properties/shaders';
import {
Button,
formatNumber,
HStack,
Label,
Slider,
snapToStep,
Spacer,
type Splitter,
Theme,
VStack,
type Widget,
} from '../../ui';
import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { AssetTabEditor } from '../AssetTabEditor'; import { AssetTabEditor } from '../AssetTabEditor';
import { GraphCanvas } from '../graph/GraphCanvas'; import { GraphCanvas } from '../graph/GraphCanvas';
import { SnapToggle } from '../graph/SnapToggle'; import { SnapToggle } from '../graph/SnapToggle';
import { PreviewSplit } from '../PreviewSplit';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import {
type PictureSubject,
pictureSubjectOf,
previewPicture,
sliderRange,
startValues,
} from './previewPicture';
import { ShaderPreview } from './ShaderPreview';
/** Room kept for "N properties", or for the reason the graph does not compile. */ /** Room kept for "N properties", or for the reason the graph does not compile. */
const INFO_WIDTH = 220; const INFO_WIDTH = 220;
/** The button reads "Hold" or "Run"; a floor keeps the row from moving when it changes. */
const HOLD_WIDTH = 52;
const PARAM_NAME_WIDTH = 72;
const PARAM_VALUE_WIDTH = 44;
/** /**
* The nodes of one shader asset, on the graph canvas the Behavior tab uses. The graph is compiled * The nodes of one shader asset, on the graph canvas the Behavior tab uses, and beside them a picture
* on every change (see `ShaderLibrary`); the readout says whether it did. * wearing it (#168). The picture is whatever was last selected that has one – a sprite, a texture, a
* frame – so the shader is judged on the image it is meant for; before that, a built-in test image.
*/ */
export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> { export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas); readonly snap = new SnapToggle(() => this.canvas);
readonly preview: ShaderPreview;
/** Assigned by `body`; public for the check scripts, which drag it. */
splitter!: Splitter;
private readonly side = new VStack();
private readonly holdButton = new Button('Hold', {
minWidth: HOLD_WIDTH,
onClick: () => this.toggleHold(),
});
private readonly restartButton = new Button('Restart', { onClick: () => this.restart() });
private readonly timeLabel = new Label('0.00 s', { style: 'small', align: 'right' });
private readonly subjectLabel = new Label('', { style: 'small' });
private readonly params = new VStack({ gap: Theme.gap, padding: Theme.padding });
private readonly sliders = new Map<string, { slider: Slider; value: Label }>();
/** What the sliders are set to – the preview's values, never anyone's rows. */
private values: Record<string, number> = {};
private subject: PictureSubject | null = null;
/** Rebuild the sliders only when the declared properties change, not on every edit of the graph. */
private paramsKey = '';
private valuesFor = '';
constructor(state: EditorState) { constructor(state: EditorState) {
super(state, { infoWidth: INFO_WIDTH }); super(state, { infoWidth: INFO_WIDTH });
this.preview = new ShaderPreview(state);
this.mount(new GraphCanvas(state, 'shader'), this.snap.widgets); this.mount(new GraphCanvas(state, 'shader'), this.snap.widgets);
// Play swaps in the runtime copy of the project, and back. // Play swaps in the runtime copy of the project, and back.
state.on('mode', () => this.refresh()); state.on('mode', () => this.refresh());
// Remembered while anything else is selected: selecting the shader itself is how one gets here.
state.on('selection', () => {
const subject = pictureSubjectOf(state.project, state.selection);
if (!subject) return;
this.subject = subject;
this.valuesFor = '';
this.refresh();
});
state.on('load', () => {
this.subject = null;
this.valuesFor = '';
});
}
protected override body(canvas: GraphCanvas): Widget {
const buttons = new HStack({ gap: Theme.gap, align: 'center' });
buttons.minHeight = Theme.controlHeight;
buttons.add(new Label('Preview', { style: 'muted' }));
buttons.add(new Spacer(), 1);
this.timeLabel.minWidth = 48;
buttons.add(this.timeLabel);
buttons.add(this.holdButton);
buttons.add(this.restartButton);
const bar = new VStack({ gap: Theme.gap, padding: Theme.padding });
bar.add(buttons);
bar.add(this.subjectLabel);
this.side.add(bar);
this.side.add(this.preview, 1);
this.side.add(this.params);
const split = new PreviewSplit(canvas, this.side);
this.splitter = split.splitter;
return split;
}
/** Whether the panel is a moving picture: a Time node reaches the output and the clock runs. */
get animates(): boolean {
const asset = this.asset();
return Boolean(asset && !this.preview.holding && shaderLibrary.buildFor(asset).compiled?.time);
}
/** Called every frame while the tab is showing. */
tick(): void {
if (!this.animates) return;
this.preview.invalidate();
this.syncControls();
}
/** What the panel shows and what its controls say – the check scripts read this. */
get controls(): {
subject: string;
hold: string;
time: string;
timed: boolean;
values: Record<string, number>;
} {
return {
subject: this.subjectLabel.text,
hold: this.holdButton.text,
time: this.timeLabel.text,
timed: this.holdButton.enabled,
values: { ...this.values },
};
}
/** The slider of one property, for the check scripts to drag. */
sliderOf(key: string): Slider | undefined {
return this.sliders.get(key)?.slider;
} }
override selectedId(): string { override selectedId(): string {
@@ -40,5 +156,85 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
protected override syncExtras(asset: ShaderAsset | null): void { protected override syncExtras(asset: ShaderAsset | null): void {
this.snap.setEnabled(Boolean(asset)); this.snap.setEnabled(Boolean(asset));
const picture = this.subject && previewPicture(this.state.project, this.subject);
this.subjectLabel.setText(asset ? (picture?.name ?? 'Test image') : '');
const defs = asset ? shaderParamDefs(asset.graph) : [];
// A new picture or another shader starts over from what that sprite wears; an edit of the graph
// keeps what the sliders were moved to.
const valuesFor = `${asset?.id ?? ''}\u0000${picture ? JSON.stringify(this.subject) : ''}`;
const fresh = startValues(defs, picture?.props ?? {}, asset?.id ?? '');
if (valuesFor !== this.valuesFor) {
this.valuesFor = valuesFor;
this.values = fresh;
} else {
this.values = Object.fromEntries(
defs.map((d) => [d.key, this.values[d.key] ?? fresh[d.key] ?? d.default]),
);
}
this.syncParams(defs);
this.preview.show(asset, picture, this.values);
this.holdButton.setEnabled(Boolean(asset && shaderLibrary.buildFor(asset).compiled?.time));
this.restartButton.setEnabled(this.holdButton.enabled);
this.syncControls();
}
private syncParams(defs: readonly FlatDef<'number'>[]): void {
const key = JSON.stringify(defs.map((d) => [d.key, d.min, d.max, d.default]));
if (key !== this.paramsKey) {
this.paramsKey = key;
for (const row of this.params.removeChildren()) row.destroy({ children: true });
this.sliders.clear();
for (const def of defs) this.params.add(this.paramRow(def));
// The column keeps its size, so a resize would not reach the rows: the column lays out anew.
this.side.relayout();
}
for (const def of defs) {
const row = this.sliders.get(def.key);
const value = this.values[def.key] ?? def.default;
row?.slider.setValue(value);
row?.value.setText(formatNumber(value));
}
}
private paramRow(def: FlatDef<'number'>): Widget {
const { min, max } = sliderRange(def);
const slider = new Slider(this.values[def.key] ?? def.default, min, max, 80);
const value = new Label('', { style: 'small', align: 'right' });
value.minWidth = PARAM_VALUE_WIDTH;
slider.onChange = (raw) => {
const snapped = snapToStep(raw, def.step ?? 0);
this.values[def.key] = snapped;
value.setText(formatNumber(snapped));
this.preview.invalidate();
};
this.sliders.set(def.key, { slider, value });
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.controlHeight;
const name = new Label(def.label, { style: 'small' });
name.minWidth = PARAM_NAME_WIDTH;
row.add(name);
row.add(slider, 1);
row.add(value);
return row;
}
/** Stands the preview at a second, or lets it run again with `null` – what Hold and Run do. */
hold(at: number | null): void {
this.preview.hold(at);
this.syncControls();
}
private toggleHold(): void {
this.hold(this.preview.holding ? null : this.preview.time);
}
private restart(): void {
this.preview.restart();
this.syncControls();
}
private syncControls(): void {
this.holdButton.setText(this.preview.holding ? 'Run' : 'Hold');
this.timeLabel.setText(this.holdButton.enabled ? `${this.preview.time.toFixed(2)} s` : '');
} }
} }
+131
View File
@@ -0,0 +1,131 @@
import { Container, Graphics, Texture } from 'pixi.js';
import type { ShaderAsset } from '../../core/model/Asset';
import type { PropertyBag } from '../../core/model/Properties';
import { SHADER_KEY } from '../../core/properties/shaders';
import { type EdgeMode, SHEET_EDGE } from '../../core/shader/nodes';
import { type Size, Theme, Widget } from '../../ui';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { syncShader } from '../../viewport/shaders';
import { disposeSpriteMesh, newSpriteMesh } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
import { type PreviewPicture, TEST_IMAGE_SIZE, testImagePixels } from './previewPicture';
const MIN_HEIGHT = 40;
const MARGIN = 12;
/** A 16-px sprite fills a big panel only blurred by the zoom; past this it stops growing. */
const MAX_ZOOM = 8;
/**
* One picture wearing the shown shader, with the sliders' values instead of anyone's rows (#168).
* The shader is the scene's own (`syncShader`), so what the panel shows is what a sprite would.
*/
export class ShaderPreview extends Widget {
private readonly bg = new Graphics();
private readonly content = new Container();
private readonly clip = new Graphics();
private readonly mesh = newSpriteMesh();
private shader: ShaderAsset | null = null;
private picture: PreviewPicture | null = null;
private values: PropertyBag = {};
/** The library second the preview counts from; Restart moves it. */
private clockOrigin = 0;
/** Null: the clock runs. A number: the preview stands at that second. */
private held: number | null = null;
constructor(private readonly state: EditorState) {
super();
this.minHeight = MIN_HEIGHT;
this.content.addChild(this.mesh);
this.content.mask = this.clip;
this.addChild(this.bg, this.content, this.clip);
textureLibrary.onChange(() => this.invalidate());
}
override measure(): Size {
return { w: Math.max(this.minWidth, 100), h: Math.max(this.minHeight, MIN_HEIGHT) };
}
show(shader: ShaderAsset | null, picture: PreviewPicture | null, values: PropertyBag): void {
this.shader = shader;
this.picture = picture;
this.values = values;
this.invalidate();
}
/** Seconds a Time node reads in the panel, whether held or running. */
get time(): number {
return this.held ?? Math.max(0, shaderLibrary.now() - this.clockOrigin);
}
get holding(): boolean {
return this.held !== null;
}
hold(at: number | null): void {
// Letting go carries on from the held second rather than jumping to where the clock got to.
if (at === null && this.held !== null) this.clockOrigin = shaderLibrary.now() - this.held;
this.held = at;
this.invalidate();
}
restart(): void {
this.clockOrigin = shaderLibrary.now();
if (this.held !== null) this.held = 0;
this.invalidate();
}
/** Where the picture sits in the panel and how big – what the checks sample. */
get pictureRect(): { x: number; y: number; w: number; h: number } {
const w = Math.abs(this.mesh.scale.x);
const h = Math.abs(this.mesh.scale.y);
const at = this.toGlobal({ x: this.mesh.x - w / 2, y: this.mesh.y - h / 2 });
return { x: at.x, y: at.y, w, h };
}
override redraw(): void {
const c = Theme.colors;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel);
this.bg.rect(0, 0, this.w, 1).fill(c.border);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
this.mesh.visible = this.shader !== null;
if (!this.shader) return;
const source = { ...shaderLibrary.source(this.state.project), now: this.time };
const edge = source.compiled(this.shader.id)?.edge ?? SHEET_EDGE;
const picture = this.picture;
const w = picture?.w ?? TEST_IMAGE_SIZE;
const h = picture?.h ?? TEST_IMAGE_SIZE;
const zoom = Math.max(0, Math.min((this.w - 2 * MARGIN) / w, (this.h - 2 * MARGIN) / h, MAX_ZOOM));
this.mesh.position.set(Math.round(this.w / 2), Math.round(this.h / 2));
this.mesh.scale.set(w * zoom, h * zoom);
// A sprite whose picture is still loading shows its plain colour, like the scene does.
this.mesh.texture = picture
? ((picture.texture && textureLibrary.get(picture.texture, edge)) ?? Texture.WHITE)
: testTexture(edge);
this.mesh.tint = picture?.tint ?? 0xffffff;
syncShader(this.mesh, { ...this.values, [SHADER_KEY]: this.shader.id }, source);
}
override destroy(): void {
disposeSpriteMesh(this.mesh);
super.destroy();
}
}
const TEST_ADDRESS = { clamp: 'clamp-to-edge', mirror: 'mirror-repeat', repeat: 'repeat' } as const;
const testTextures = new Map<EdgeMode, Texture>();
/** The test image, one copy per edge mode: the sampler decides what a UV past the edge reads. */
function testTexture(edge: EdgeMode): Texture {
let texture = testTextures.get(edge);
if (texture) return texture;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = TEST_IMAGE_SIZE;
const image = new ImageData(testImagePixels(), TEST_IMAGE_SIZE, TEST_IMAGE_SIZE);
canvas.getContext('2d')!.putImageData(image, 0, 0);
texture = Texture.from(canvas);
texture.source.style.addressMode = TEST_ADDRESS[edge];
texture.source.style.scaleMode = 'nearest';
testTextures.set(edge, texture);
return texture;
}
+132
View File
@@ -0,0 +1,132 @@
import { findObject } from '../../core/model/hierarchy';
import { resolvedHolder } from '../../core/model/instance';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { findFrame, resolvePicture, resolveTexture, type TextureRef } from '../../core/model/texture';
import type { FlatDef } from '../../core/properties/PropertySchema';
import { resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { shaderOf } from '../../core/properties/shaders';
import type { Selection } from '../state';
/** Something the Shader tab's preview can show: a sprite, placed or not, a texture or one of its frames (#168). */
export type PictureSubject =
| { kind: 'object'; holderId: string; objectId: string }
| { kind: 'asset'; assetId: string }
| { kind: 'frame'; frameId: string };
/** What the preview draws for a subject, resolved again on every redraw so an edit shows at once. */
export interface PreviewPicture {
name: string;
/** Null for a sprite without a picture: the preview shows its plain colour, as the scene does. */
texture: TextureRef | null;
w: number;
h: number;
tint: number;
/** The rows the subject carries; a sprite already wearing the shader starts the sliders at its values. */
props: PropertyBag;
}
export const TEST_IMAGE_SIZE = 64;
const TEST_IMAGE_CELL = 8;
/** The subject a selection names, if it has a picture; anything else leaves the preview where it was. */
export function pictureSubjectOf(project: Project, selection: Selection): PictureSubject | null {
let subject: PictureSubject | null = null;
if (selection?.kind === 'object')
subject = { kind: 'object', holderId: selection.holderId, objectId: selection.objectId };
else if (selection?.kind === 'asset') subject = { kind: 'asset', assetId: selection.assetId };
else if (selection?.kind === 'frame') subject = { kind: 'frame', frameId: selection.frameId };
return subject && previewPicture(project, subject) ? subject : null;
}
/** Null once the subject is gone or no longer has a picture – the preview then falls back to the test image. */
export function previewPicture(project: Project, subject: PictureSubject): PreviewPicture | null {
if (subject.kind === 'object') {
const holder = findObjectHolder(project, subject.holderId);
const object = holder && findObject(resolvedHolder(project, holder), subject.objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || asset?.type !== 'sprite') return null;
return spritePicture(project, object.name || asset.name, resolveSceneObjectProps(project, asset, object));
}
if (subject.kind === 'frame') {
const found = findFrame(project, subject.frameId);
const ref = resolvePicture(project, subject.frameId);
return found && ref ? texturePicture(`${found.asset.name} / ${found.frame.name}`, ref) : null;
}
const asset = findAsset(project, subject.assetId);
if (!asset) return null;
if (asset.type === 'sprite') return spritePicture(project, asset.name, resolveAssetProps(project, asset));
const ref = resolvePicture(project, asset.id);
return ref ? texturePicture(asset.name, ref) : null;
}
function spritePicture(project: Project, name: string, props: PropertyBag): PreviewPicture {
return {
name,
texture: resolveTexture(project, props),
w: Number(props.width) || 1,
h: Number(props.height) || 1,
tint: Number(props.color ?? 0xffffff),
props,
};
}
function texturePicture(name: string, ref: TextureRef): PreviewPicture {
return {
name,
texture: ref,
w: ref.rect?.w ?? ref.sourceWidth,
h: ref.rect?.h ?? ref.sourceHeight,
tint: 0xffffff,
props: {},
};
}
/** Where a slider starts: the subject's own value when it wears this shader, the declared default otherwise. */
export function startValues(
defs: readonly FlatDef<'number'>[],
props: PropertyBag,
shaderId: string,
): Record<string, number> {
const wears = shaderOf(props) === shaderId;
const values: Record<string, number> = {};
for (const def of defs) {
const own = Number(props[def.key]);
values[def.key] = wears && Number.isFinite(own) ? own : def.default;
}
return values;
}
/**
* A property declares its min, its max only when it is above the min. Without one the slider
* reaches twice the default's distance, so the default sits in the middle.
*/
export function sliderRange(def: FlatDef<'number'>): { min: number; max: number } {
const min = def.min ?? 0;
if (def.max !== undefined && def.max > min) return { min, max: def.max };
return { min, max: def.default > min ? min + 2 * (def.default - min) : min + 1 };
}
/**
* The built-in picture for when nothing with a picture was selected: colour runs from left to right
* and top to bottom, a checker shows where a UV lands, and the edge fades out so alpha has something
* to act on. Straight RGBA, row by row.
*/
export function testImagePixels(size = TEST_IMAGE_SIZE): Uint8ClampedArray<ArrayBuffer> {
const border = size / 8;
const pixels = new Uint8ClampedArray(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const u = (x + 0.5) / size;
const v = (y + 0.5) / size;
const shade = (Math.floor(x / TEST_IMAGE_CELL) + Math.floor(y / TEST_IMAGE_CELL)) % 2 ? 0.55 : 1;
const edge = Math.min(x, y, size - 1 - x, size - 1 - y);
const i = (y * size + x) * 4;
pixels[i] = Math.round(255 * u * shade);
pixels[i + 1] = Math.round(255 * v * shade);
pixels[i + 2] = Math.round(255 * (1 - u) * shade);
pixels[i + 3] = edge >= border ? 255 : Math.round((255 * (edge + 0.5)) / border);
}
}
return pixels;
}
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import { RemoveSceneObjectsCommand } from '../../src/core/commands/sceneObjectCommands';
import { findAsset } from '../../src/core/model/Project';
import type { FlatDef } from '../../src/core/properties/PropertySchema';
import {
pictureSubjectOf,
previewPicture,
sliderRange,
startValues,
TEST_IMAGE_SIZE,
testImagePixels,
} from '../../src/editor/shader/previewPicture';
import type { Selection } from '../../src/editor/state';
import { makeSpriteScene, place } from '../helpers';
const FILE = `${'cd'.repeat(32)}.png`;
/** A scene with a sprite "Box", a 64×32 texture "Sheet" with a 16×8 frame "Idle", and one placed Box. */
function setup() {
const fixture = makeSpriteScene();
const texture = new CreateTextureCommand('Sheet', { file: FILE, width: 64, height: 32 });
fixture.stack.execute(texture);
const frame = new AddFrameCommand(texture.asset.id, { x: 16, y: 8, w: 16, h: 8 }, 'Idle');
fixture.stack.execute(frame);
const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 });
const stored = () => fixture.sceneOf().objects.find((o) => o.id === objectId)!;
return { ...fixture, textureId: texture.asset.id, frameId: frame.frameId, objectId, stored };
}
function numberRow(key: string, extra: Partial<FlatDef<'number'>> = {}): FlatDef<'number'> {
return { key, label: key, kind: 'number', default: 1, min: 0, max: 2, ...extra };
}
describe('the picture the Shader tab previews (#168)', () => {
it('is named by a selected sprite, placed or not, a texture or a frame', () => {
const f = setup();
const object: Selection = {
kind: 'object',
holderId: f.sceneId,
objectId: f.objectId,
objectIds: [f.objectId],
};
expect(pictureSubjectOf(f.project, object)).toEqual({
kind: 'object',
holderId: f.sceneId,
objectId: f.objectId,
});
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.spriteId })).toEqual({
kind: 'asset',
assetId: f.spriteId,
});
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.textureId })).not.toBeNull();
expect(pictureSubjectOf(f.project, { kind: 'frame', assetId: f.textureId, frameId: f.frameId })).toEqual({
kind: 'frame',
frameId: f.frameId,
});
});
it('is not named by anything without a picture, so the preview keeps the one before', () => {
const f = setup();
const behavior = new CreateAssetCommand('behavior', 'Mover');
f.stack.execute(behavior);
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: behavior.asset.id })).toBeNull();
expect(pictureSubjectOf(f.project, { kind: 'asset', assetId: f.sceneId })).toBeNull();
expect(pictureSubjectOf(f.project, null)).toBeNull();
const node: Selection = {
kind: 'node',
assetId: behavior.asset.id,
nodeId: 'n',
nodeIds: ['n'],
annotationIds: [],
};
expect(pictureSubjectOf(f.project, node)).toBeNull();
});
it('shows a frame at its own size, a whole texture at the sheet size', () => {
const f = setup();
const frame = previewPicture(f.project, { kind: 'frame', frameId: f.frameId })!;
expect(frame).toMatchObject({ name: 'Sheet / Idle', w: 16, h: 8, tint: 0xffffff, props: {} });
expect(frame.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 });
const sheet = previewPicture(f.project, { kind: 'asset', assetId: f.textureId })!;
expect(sheet).toMatchObject({ name: 'Sheet', w: 64, h: 32 });
expect(sheet.texture?.rect).toBeNull();
});
it('shows a placed sprite as the scene does: its overrides, size and colour', () => {
const f = setup();
Object.assign(f.stored().overrides, { texture: f.frameId, width: 48, height: 24, color: 0xff8040 });
const picture = previewPicture(f.project, { kind: 'object', holderId: f.sceneId, objectId: f.objectId })!;
expect(picture).toMatchObject({ name: 'Box', w: 48, h: 24, tint: 0xff8040 });
expect(picture.texture?.rect).toEqual({ x: 16, y: 8, w: 16, h: 8 });
// The asset itself is untouched by the object's overrides.
const asset = previewPicture(f.project, { kind: 'asset', assetId: f.spriteId })!;
expect(asset).toMatchObject({ name: 'Box', w: 100, h: 100, texture: null });
});
it('follows an edit, and is gone with its object – the tab then shows the test image', () => {
const f = setup();
const subject = { kind: 'object', holderId: f.sceneId, objectId: f.objectId } as const;
f.stored().overrides.width = 20;
expect(previewPicture(f.project, subject)?.w).toBe(20);
f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [f.objectId]));
expect(previewPicture(f.project, subject)).toBeNull();
f.stack.undo();
expect(previewPicture(f.project, subject)?.w).toBe(20);
expect(findAsset(f.project, f.spriteId)).toBeDefined();
});
});
describe('the preview sliders (#168)', () => {
const defs = [numberRow('glow'), numberRow('speed', { default: 0.5 })];
it('start at the values of a sprite wearing this shader', () => {
expect(startValues(defs, { shader: 'neon', glow: 1.75, speed: 0.25 }, 'neon')).toEqual({
glow: 1.75,
speed: 0.25,
});
// A row the sprite does not carry yet starts at its default.
expect(startValues(defs, { shader: 'neon', glow: 0.2 }, 'neon')).toEqual({ glow: 0.2, speed: 0.5 });
});
it('start at the defaults when the picture wears another shader or none', () => {
expect(startValues(defs, { shader: 'other', glow: 1.75 }, 'neon')).toEqual({ glow: 1, speed: 0.5 });
expect(startValues(defs, {}, 'neon')).toEqual({ glow: 1, speed: 0.5 });
});
it('reach the declared range, or twice the default without a max', () => {
expect(sliderRange(numberRow('a', { min: -1, max: 3 }))).toEqual({ min: -1, max: 3 });
expect(sliderRange(numberRow('b', { max: undefined, default: 1.5 }))).toEqual({ min: 0, max: 3 });
expect(sliderRange(numberRow('c', { min: 2, max: undefined, default: 2 }))).toEqual({ min: 2, max: 3 });
});
});
describe('the built-in test image (#168)', () => {
const pixels = testImagePixels();
const at = (x: number, y: number) =>
Array.from(pixels.slice((y * TEST_IMAGE_SIZE + x) * 4, (y * TEST_IMAGE_SIZE + x) * 4 + 4));
it('runs red from left to right and green from top to bottom', () => {
expect(pixels.length).toBe(TEST_IMAGE_SIZE * TEST_IMAGE_SIZE * 4);
const [r1, g1, b1] = at(16, 16);
const [r2, g2, b2] = at(48, 48);
expect(r2).toBeGreaterThan(r1!);
expect(g2).toBeGreaterThan(g1!);
expect(b2).toBeLessThan(b1!);
});
it('has a checker, so a moved UV shows', () => {
// Two neighbours across a cell border: same place in the gradient, a different shade.
expect(at(24, 20)[0]! / at(23, 20)[0]!).toBeCloseTo(0.55, 1);
});
it('fades out at the edge and is opaque inside', () => {
expect(at(0, 32)[3]).toBe(16);
expect(at(32, TEST_IMAGE_SIZE - 1)[3]).toBe(16);
expect(at(4, 32)[3]).toBe(143);
expect(at(8, 32)[3]).toBe(255);
expect(at(32, 32)[3]).toBe(255);
});
});
+162 -3
View File
@@ -4,7 +4,8 @@ import { FIND_HALVES, HALVES_LEFT as LEFT, HALVES_RIGHT as RIGHT, PASTE_HALVES }
/** /**
* Shader assets: made from the + menu, built from nodes in the Shader tab, worn by a sprite through * 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 * 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. * the scene view and in the game – and in the tab's own preview of the picture selected last (28n).
* 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. */ /** (64, 128, 255): three distinct channels, so brightness, contrast and saturation each read differently. */
@@ -80,6 +81,8 @@ export async function shader(t) {
// 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette. // 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette.
const canvas = await json('__gm.shader.canvasRect()'); const canvas = await json('__gm.shader.canvasRect()');
// The preview takes the right of the tab (#168); the second column stays inside what is left.
const right = canvas.x + Math.min(420, canvas.w - 100);
await click(canvas.x + 80, canvas.y + 60, 'right'); await click(canvas.x + 80, canvas.y + 60, 'right');
const offered = await json('__gm.overlayTexts()'); const offered = await json('__gm.overlayTexts()');
check( check(
@@ -92,7 +95,7 @@ export async function shader(t) {
// A dozen entries scroll; searched, the row is the only one and in view. // A dozen entries scroll; searched, the row is the only one and in view.
await typeText('Texture color'); await typeText('Texture color');
await clickLast('Texture color'); await clickLast('Texture color');
await click(canvas.x + 420, canvas.y + 60, 'right'); await click(right, canvas.y + 60, 'right');
await clickLast('Output'); await clickLast('Output');
const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? []; const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? [];
check( check(
@@ -100,7 +103,7 @@ export async function shader(t) {
(await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput', (await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput',
JSON.stringify(await madeNodes()), JSON.stringify(await madeNodes()),
); );
await click(canvas.x + 420, canvas.y + 320, 'right'); await click(right, canvas.y + 320, 'right');
const again = await json('__gm.overlayTexts()'); const again = await json('__gm.overlayTexts()');
check( check(
'the palette offers the Output only once', 'the palette offers the Output only once',
@@ -739,6 +742,162 @@ export async function shader(t) {
(await json('__gm.animates()')).scene === false, (await json('__gm.animates()')).scene === false,
JSON.stringify(await json('__gm.animates()')), JSON.stringify(await json('__gm.animates()')),
); );
// 28n. The Shader tab previews the picture selected last (#168), through sliders of its own that
// never touch the project, and falls back to a built-in test image once that picture is gone.
await graph(
[
node('uv', 'ShaderUV'),
node('flip', 'ShaderSubtract', { a: 1 }, 260, 0),
node('sample', 'ShaderSample', {}, 520, 0),
node('level', 'ShaderProperty', { name: 'level', default: 1, min: 0, max: 1 }, 520, 200),
node('dim', 'ShaderMultiply', {}, 780, 0),
node('out', 'ShaderOutput', {}, 1040, 0),
],
[
edge('uv', 'u', 'flip', 'b'),
edge('flip', 'result', 'sample', 'u'),
edge('sample', 'color', 'dim', 'a'),
edge('level', 'value', 'dim', 'b'),
edge('dim', 'result', 'out', 'color'),
],
);
const openPreview = async (selection) => {
await select(selection);
await select({ kind: 'asset', assetId: made.id });
await showTab('shader');
await sleep(400);
return json('__gm.shader.preview()');
};
const inPicture = async (fx, fy) => {
const r = await json('__gm.shader.pictureRect()');
return json(`__gm.pixelAt(${Math.floor(r.x + fx * r.w)}, ${Math.floor(r.y + fy * r.h)})`);
};
const halvesName = await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(picture?.id ?? '')})?.name ?? null`,
);
const onHalves = await openPreview({ kind: 'asset', assetId: picture?.id ?? '' });
const flippedLeft = await inPicture(0.25, 0.5);
const flippedRight = await inPicture(0.75, 0.5);
check(
'the preview shows the texture selected last, through the shader: 1 - u puts green left, red right',
onHalves.subject === halvesName &&
(await json('__gm.shader.current()')) === made.id &&
colourDistance(flippedLeft, RIGHT) < 12 &&
colourDistance(flippedRight, LEFT) < 12,
{ halvesName, onHalves, flippedLeft, flippedRight },
);
check(
'the preview has a slider for the property at its default, and no clock without a Time node',
onHalves.values.level === 1 && onHalves.timed === false && onHalves.time === '',
onHalves,
);
await shot('28-shader-preview');
const levelSlider = await json("__gm.shader.sliderAt('level')");
await click(levelSlider.x + 2, levelSlider.y + levelSlider.h / 2);
await sleep(300);
const pulled = await json('__gm.shader.preview()');
const dark = await inPicture(0.25, 0.5);
const written = await json(`({
sprite: __gm.state.project.assets.find((a) => a.id === 'shade-sprite').props.level ?? null,
declared: __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).graph.nodes.find((n) => n.id === 'level').params.default,
})`);
check(
'the slider pulled to its left end darkens the preview to black and writes nothing into the project',
pulled.values.level === 0 &&
colourDistance(dark, '#000000') < 12 &&
written.sprite === null &&
written.declared === 1,
{ pulled, dark, written },
);
await assetProps({ texture: picture?.id ?? '', color: 0xffffff, shader: made.id, level: 0.5 });
const onSprite = await openPreview({
kind: 'object',
holderId: 'scene1',
objectIds: ['oshade'],
objectId: 'oshade',
});
const halfGreen = await inPicture(0.25, 0.5);
const halfOfRight = `#${[1, 3, 5]
.map((i) =>
Math.round(parseInt(RIGHT.slice(i, i + 2), 16) / 2)
.toString(16)
.padStart(2, '0'),
)
.join('')}`;
check(
'a sprite wearing the shader starts the slider at its own value: level 0.5 halves the green',
onSprite.subject === 'Shaded' &&
onSprite.values.level === 0.5 &&
colourDistance(halfGreen, halfOfRight) < 12,
{ onSprite, halfGreen, halfOfRight },
);
await redOrBlue(
[
node('time', 'ShaderTime'),
node('saw', 'ShaderFract', {}, 260, 0),
node('cut', 'ShaderStep', { edge: 0.5 }, 260, 120),
],
[
edge('time', 'value', 'saw', 'value'),
edge('saw', 'result', 'cut', 'value'),
edge('cut', 'result', 'mix', 't'),
],
);
await evaluate('__gm.shader.hold(0.2)');
await sleep(300);
const heldEarly = { preview: await json('__gm.shader.preview()'), pixel: await inPicture(0.5, 0.5) };
await evaluate('__gm.shader.hold(0.7)');
await sleep(300);
const heldLate = { preview: await json('__gm.shader.preview()'), pixel: await inPicture(0.5, 0.5) };
check(
'with a Time node the preview has a clock of its own: held at 0.2 s red, at 0.7 s blue, the button reads Run',
heldEarly.preview.timed &&
heldLate.preview.hold === 'Run' &&
heldLate.preview.time === '0.70 s' &&
colourDistance(heldEarly.pixel, '#ff0000') < 6 &&
colourDistance(heldLate.pixel, '#0000ff') < 6,
{ heldEarly, heldLate },
);
await evaluate('__gm.shader.hold(null)');
await sleep(300);
const runningFirst = await json('__gm.shader.preview()');
await sleep(600);
const runningLater = await json('__gm.shader.preview()');
check(
'let go, the clock runs on from the held second',
runningFirst.hold === 'Hold' &&
runningFirst.seconds >= 0.7 &&
runningLater.seconds > runningFirst.seconds &&
runningLater.time !== runningFirst.time,
{ runningFirst, runningLater },
);
// The halves texture selected once more, then deleted: the preview has nothing left to show.
await graph(
[node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput', {}, 260, 0)],
[edge('texel', 'color', 'out', 'color')],
);
await openPreview({ kind: 'asset', assetId: picture?.id ?? '' });
await mutate(`
const project = __gm.state.project;
project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(picture?.id ?? '')});
project.assets.find((a) => a.id === 'shade-sprite').props.texture = '';`);
await sleep(400);
const fallback = await json('__gm.shader.preview()');
// Texel (16, 48) of the 64-px test image, hit in its middle: u 0.26, v 0.76, a light checker cell.
const testTexel = await inPicture(16.5 / 64, 48.5 / 64);
const testEdge = await inPicture(0.5 / 64, 0.5);
check(
'with that picture gone the preview shows the test image: colour runs with u and v, the edge fades out',
fallback.subject === 'Test image' &&
colourDistance(testTexel, '#42c1bd') < 12 &&
colourDistance(testEdge, testTexel) > 60,
{ fallback, testTexel, testEdge },
);
await select(null);
await showTab('scene');
await assetProps({ texture: '', color: 0x4080ff, shader: made.id }); await assetProps({ texture: '', color: 0x4080ff, shader: made.id });
// 28j. Back to None: no filter at all, the plain colour returns bit for bit. // 28j. Back to None: no filter at all, the plain colour returns bit for bit.