diff --git a/README.md b/README.md index f483b7a1..dc97923e 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 51d82f9d..aa4364d8 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -714,12 +714,13 @@ export class Editor implements PlayHost { // to do it, and its states move just the same. if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); if (this.tab === 'particles') this.particlesEditor.tick(); + if (this.tab === 'shader') this.shaderEditor.tick(); if (this.movesByItself()) requestRender(); } /** * 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 { return ( @@ -728,6 +729,7 @@ export class Editor implements PlayHost { (this.viewport.visible && this.viewport.animates) || (this.gameView.visible && this.gameView.animates) || this.tab === 'particles' || + (this.tab === 'shader' && this.shaderEditor.animates) || (this.tab === 'animation' && this.animationEditor.isPlaying) || (this.tab === 'tile' && this.tileEditor.isPlaying) ); diff --git a/src/editor/PreviewSplit.ts b/src/editor/PreviewSplit.ts new file mode 100644 index 00000000..9c6c07a0 --- /dev/null +++ b/src/editor/PreviewSplit.ts @@ -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); + } +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index d238e2cf..b7e20194 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -417,6 +417,21 @@ export function installDebugHook(app: Application, state: EditorState, editor: E center: () => centreOf(editor.shaderEditor.canvas), snapping: () => editor.shaderEditor.canvas.snapEnabled, 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. */ console: { diff --git a/src/editor/particles/ParticlesEditor.ts b/src/editor/particles/ParticlesEditor.ts index e88a51f9..6a3d5e4a 100644 --- a/src/editor/particles/ParticlesEditor.ts +++ b/src/editor/particles/ParticlesEditor.ts @@ -2,9 +2,10 @@ import type { Asset, ParticlesAsset } from '../../core/model/Asset'; import { LIFETIME_KEY, TIME_SCALE_KEY } from '../../core/properties/particles'; import { defaultsFor } from '../../core/properties/PropertySchema'; 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 { GraphCanvas } from '../graph/GraphCanvas'; +import { PreviewSplit } from '../PreviewSplit'; import { SnapToggle } from '../graph/SnapToggle'; import type { EditorState } from '../state'; 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. */ const SPAN_LIFETIMES = 3; 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. */ const HOLD_WIDTH = 52; @@ -35,11 +33,9 @@ export class ParticlesEditor extends AssetTabEditor onClick: () => this.toggleHold(), }); private readonly timeLabel = new Label('0.00 s', { style: 'small' }); - readonly splitter = new Splitter('x'); - private readonly side = new VStack(); - private row = new HStack(); + /** Assigned by `body`; public for the check scripts, which drag it. */ + splitter!: Splitter; private span = MIN_SPAN; - private previewWidth = 280; constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); @@ -71,22 +67,12 @@ export class ParticlesEditor extends AssetTabEditor bar.add(buttons); bar.add(time); - this.side.add(bar); - this.side.add(this.preview, 1); - this.side.minWidth = this.previewWidth; - // Draggable like the console: how much of the tab is graph and how much is picture is a matter - // of what is being worked on, and on a narrow window a fixed panel eats the canvas. - this.splitter.onDrag = (delta) => { - 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; + const side = new VStack(); + side.add(bar); + side.add(this.preview, 1); + const split = new PreviewSplit(canvas, side); + this.splitter = split.splitter; + return split; } /** Called every frame while the tab is showing: the panel is a moving picture. */ diff --git a/src/editor/shader/ShaderEditor.ts b/src/editor/shader/ShaderEditor.ts index 4fd43acc..e2465177 100644 --- a/src/editor/shader/ShaderEditor.ts +++ b/src/editor/shader/ShaderEditor.ts @@ -1,26 +1,142 @@ import type { Asset, ShaderAsset } from '../../core/model/Asset'; +import type { FlatDef } from '../../core/properties/PropertySchema'; 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 { AssetTabEditor } from '../AssetTabEditor'; import { GraphCanvas } from '../graph/GraphCanvas'; import { SnapToggle } from '../graph/SnapToggle'; +import { PreviewSplit } from '../PreviewSplit'; 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. */ 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 - * on every change (see `ShaderLibrary`); the readout says whether it did. + * The nodes of one shader asset, on the graph canvas the Behavior tab uses, and beside them a picture + * 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 { 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(); + /** What the sliders are set to – the preview's values, never anyone's rows. */ + private values: Record = {}; + 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) { super(state, { infoWidth: INFO_WIDTH }); + this.preview = new ShaderPreview(state); 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()); + // 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; + } { + 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 { @@ -40,5 +156,85 @@ export class ShaderEditor extends AssetTabEditor { protected override syncExtras(asset: ShaderAsset | null): void { 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` : ''); } } diff --git a/src/editor/shader/ShaderPreview.ts b/src/editor/shader/ShaderPreview.ts new file mode 100644 index 00000000..69117572 --- /dev/null +++ b/src/editor/shader/ShaderPreview.ts @@ -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(); + +/** 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; +} diff --git a/src/editor/shader/previewPicture.ts b/src/editor/shader/previewPicture.ts new file mode 100644 index 00000000..f5a87f9e --- /dev/null +++ b/src/editor/shader/previewPicture.ts @@ -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 { + const wears = shaderOf(props) === shaderId; + const values: Record = {}; + 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 { + 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; +} diff --git a/tests/editor/shader-preview.test.ts b/tests/editor/shader-preview.test.ts new file mode 100644 index 00000000..37d5eb8a --- /dev/null +++ b/tests/editor/shader-preview.test.ts @@ -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'> { + 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); + }); +}); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 27553e7a..494bf340 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -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 * 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. */ @@ -80,6 +81,8 @@ export async function shader(t) { // 28b. Right-click offers shader nodes only; Texture color and Output placed from the palette. 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'); const offered = await json('__gm.overlayTexts()'); check( @@ -92,7 +95,7 @@ export async function shader(t) { // A dozen entries scroll; searched, the row is the only one and in view. await typeText('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'); const madeNodes = async () => (await shaders()).find((s) => s.id === made.id)?.nodes ?? []; check( @@ -100,7 +103,7 @@ export async function shader(t) { (await madeNodes()).map((n) => n.type).join(',') === 'ShaderTextureColor,ShaderOutput', 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()'); check( 'the palette offers the Output only once', @@ -739,6 +742,162 @@ export async function shader(t) { (await json('__gm.animates()')).scene === false, 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 }); // 28j. Back to None: no filter at all, the plain colour returns bit for bit.