diff --git a/README.md b/README.md index e8427680..5ac82978 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 730 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 742 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 9f30ead6..811aaf09 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -4,12 +4,17 @@ import { colorOf } from '../model/Properties'; import { isPropertyNode, isValidPropertyName, propertyName, shaderParamDefs } from '../properties/shaders'; import { asVec3, + type EdgeMode, + edgeOf, type Expr, floatLiteral, isShaderNodeDef, SHADER_OUTPUT, + SHADER_TIME, + STRAIGHT_FN, TEXEL_ALPHA, TEXEL_COLOR, + TIME_UNIFORM, uniformName, vec3Literal, } from './nodes'; @@ -22,6 +27,10 @@ export interface ShaderUniform { export interface CompiledShader { fragment: string; uniforms: ShaderUniform[]; + /** A Time node feeds the Output: the fragment takes `uTime`, and a sprite wearing it redraws every tick. */ + time: boolean; + /** How the picture continues past its border – the texture variant the sprite's mesh draws with. */ + edge: EdgeMode; } export interface ShaderBuild { @@ -34,9 +43,9 @@ export interface ShaderBuild { } /** - * 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. + * A shader graph to the fragment of the sprite's own draw: every node reachable from the Output + * becomes one local per output, in dependency order, and every Property a float uniform. The texel + * arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back. */ export function compileShader(graph: Graph): ShaderBuild { const problems: Record = {}; @@ -91,8 +100,8 @@ export function compileShader(graph: Graph): ShaderBuild { }; /** - * What feeds an input: the edge's source, the typed-in value, or – with nothing to type – the - * texel: its colour for a colour port, its alpha for a number port. + * What feeds an input: the edge's source, the typed-in value, or – with nothing to type – what + * the node says (`unfed`), else the texel: its colour for a colour port, its alpha for a number. */ const input = (node: GraphNode, port: PortDef): Expr | null => { const edge = edgesInto(graph, node.id, port.key)[0]; @@ -107,7 +116,9 @@ export function compileShader(graph: Graph): ShaderBuild { 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 port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR; + const def = nodeDef(node.type); + const unfed = def && isShaderNodeDef(def) ? def.unfed?.[port.key] : undefined; + return unfed ?? (port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR); }; const conform = (node: GraphNode, port: PortDef, expr: Expr): Expr | null => { @@ -130,23 +141,29 @@ export function compileShader(graph: Graph): ShaderBuild { return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' }; const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default })); + const time = graph.nodes.some((n) => n.type === SHADER_TIME && emitted.has(n.id)); // The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the // texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader. + // The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for + // it in the fragment); without it `texture()` is a `#define` and `textureSize` does not exist. const fragment = [ + '#version 300 es', 'in vec2 vTextureCoord;', 'in vec4 vColor;', 'out vec4 finalColor;', 'uniform sampler2D uTexture;', + ...(time ? [`uniform float ${TIME_UNIFORM};`] : []), ...uniforms.map((u) => `uniform float ${uniformName(u.name)};`), + STRAIGHT_FN, 'void main()', '{', ' vec4 texel = texture(uTexture, vTextureCoord) * vColor;', - ` vec3 ${TEXEL_COLOR.code} = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;`, + ` vec3 ${TEXEL_COLOR.code} = straight(texel);`, ...lines.map((line) => ` ${line}`), ` float alpha = clamp(${alpha.code}, 0.0, 1.0);`, ` finalColor = vec4(clamp(${color.code}, 0.0, 1.0) * alpha, alpha);`, '}', '', ].join('\n'); - return { compiled: { fragment, uniforms }, problems, reason: '' }; + return { compiled: { fragment, uniforms, time, edge: edgeOf(output.params) }, problems, reason: '' }; } diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index 0c3edd1e..f4988f49 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -26,6 +26,8 @@ export interface Expr { export interface ShaderNodeDef extends NodeDef { category: 'shader'; emit(inputs: Record, params: PropertyBag): Record; + /** What an input with nothing to type on reads while unconnected; without it, the texel. */ + unfed?: Record; } export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef { @@ -37,14 +39,44 @@ export const SHADER_TEXTURE_ALPHA = 'ShaderTextureAlpha'; export const SHADER_OUTPUT = 'ShaderOutput'; export const SHADER_COLOR = 'ShaderColor'; export const SHADER_NUMBER = 'ShaderNumber'; +export const SHADER_UV = 'ShaderUV'; +export const SHADER_SAMPLE = 'ShaderSample'; +export const SHADER_TEXTURE_SIZE = 'ShaderTextureSize'; +export const SHADER_TIME = 'ShaderTime'; /** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */ export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' }; export const TEXEL_ALPHA: Expr = { code: 'texel.a', type: 'float' }; +/** This pixel's place on the picture, 0..1 – the vertex stage's `vTextureCoord`. */ +const OWN_U: Expr = { code: 'vTextureCoord.x', type: 'float' }; +const OWN_V: Expr = { code: 'vTextureCoord.y', type: 'float' }; +export const TIME_UNIFORM = 'uTime'; +/** The prelude's helper: a premultiplied sample to its straight colour. */ +export const STRAIGHT_FN = 'vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : c.rgb; }'; + +/** + * How the picture continues past its edge when a `Sample texture` reads outside 0..1 – a setting + * of the Output node, because the sampler state is one per draw. Mirror is what every picture has + * anyway (`TextureLibrary`); repeat is for tiles. + */ +export type EdgeMode = 'repeat' | 'mirror' | 'clamp'; +export const EDGE_PARAM = 'edge'; +export const DEFAULT_EDGE: EdgeMode = 'mirror'; +const EDGE_OPTIONS: { value: EdgeMode; label: string }[] = [ + { value: 'mirror', label: 'Mirror' }, + { value: 'repeat', label: 'Repeat' }, + { value: 'clamp', label: 'Clamp' }, +]; + +export function edgeOf(params: PropertyBag): EdgeMode { + const value = textOf(params[EDGE_PARAM] ?? ''); + return EDGE_OPTIONS.some((o) => o.value === value) ? (value as EdgeMode) : DEFAULT_EDGE; +} const NUMBER_STEP = 0.05; const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' }); +const numberOut = (key: string): PortDef => ({ key, label: key, type: 'number' }); /** A number input with an inline value, so a constant needs no node of its own. */ const floatIn = (key: string, value: number): PortDef => ({ @@ -117,13 +149,75 @@ export const ShaderOutput: ShaderNodeDef = { type: SHADER_OUTPUT, label: 'Output', category: 'shader', - description: 'The colour and opacity the sprite is drawn with; unconnected, each is the texture’s.', + description: + 'The colour and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.', inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }], outputs: [], - params: [], + params: [{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS }], + unfed: { color: TEXEL_COLOR, alpha: TEXEL_ALPHA }, emit: () => ({}), }; +export const ShaderUV: ShaderNodeDef = { + type: SHADER_UV, + label: 'UV', + category: 'shader', + description: "Where this pixel is on the sprite's picture: 0 to 1, u left to right, v top to bottom.", + inputs: [], + outputs: [numberOut('u'), numberOut('v')], + params: [], + emit: () => ({ u: OWN_U, v: OWN_V }), +}; + +/** Unconnected, `u` and `v` are this pixel's own, which makes the node Texture color. */ +export const ShaderSample: ShaderNodeDef = { + type: SHADER_SAMPLE, + label: 'Sample texture', + category: 'shader', + description: + "The sprite's picture at another spot, tinted like Texture color; past the border it continues as the Output's Edge says.", + inputs: [ + { key: 'u', label: 'u', type: 'number', inline: false }, + { key: 'v', label: 'v', type: 'number', inline: false }, + ], + outputs: [vec3Port('color'), numberOut('alpha')], + params: [], + unfed: { u: OWN_U, v: OWN_V }, + emit: ({ u, v }) => { + const sample = `texture(uTexture, vec2(${u!.code}, ${v!.code})) * vColor`; + return { + color: { code: `straight(${sample})`, type: 'vec3' }, + alpha: { code: `(${sample}).a`, type: 'float' }, + }; + }, +}; + +export const ShaderTextureSize: ShaderNodeDef = { + type: SHADER_TEXTURE_SIZE, + label: 'Texture size', + category: 'shader', + description: "The sprite's picture in pixels, for effects that need to know where its pixels are.", + inputs: [], + outputs: [numberOut('width'), numberOut('height')], + params: [], + emit: () => ({ + width: { code: 'float(textureSize(uTexture, 0).x)', type: 'float' }, + height: { code: 'float(textureSize(uTexture, 0).y)', type: 'float' }, + }), +}; + +export const ShaderTime: ShaderNodeDef = { + type: SHADER_TIME, + label: 'Time', + category: 'shader', + description: + 'Seconds, counting up since the editor started. A sprite wearing a graph with it keeps redrawing.', + inputs: [], + outputs: [numberOut('value')], + params: [], + emit: () => ({ value: { code: TIME_UNIFORM, type: 'float' } }), +}; + /** `name` is the sprite property and the uniform; the numbers are the row the inspector shows. */ export const ShaderProperty: ShaderNodeDef = { type: SHADER_PROPERTY, @@ -216,10 +310,59 @@ export const ShaderLuminance: ShaderNodeDef = { }), }; +function unary(type: string, label: string, fn: string, description: string): ShaderNodeDef { + return { + type, + label, + category: 'shader', + description, + inputs: [anyIn('value', 0)], + outputs: [anyOut('result')], + params: [], + emit: ({ value }) => ({ result: { code: `${fn}(${value!.code})`, type: value!.type } }), + }; +} + +export const ShaderSin = unary( + 'ShaderSin', + 'Sin', + 'sin', + 'The sine of a number in radians: one full wave every 6.283. Per channel for colours.', +); +export const ShaderFloor = unary( + 'ShaderFloor', + 'Floor', + 'floor', + 'Rounds down to a whole number: 2.75 becomes 2.', +); +export const ShaderFract = unary( + 'ShaderFract', + 'Fract', + 'fract', + 'The part after the decimal point: 2.75 becomes 0.75. A rising value becomes a sawtooth from 0 to 1.', +); + +export const ShaderStep: ShaderNodeDef = { + type: 'ShaderStep', + label: 'Step', + category: 'shader', + description: '0 while value is below edge, 1 from edge on: a hard cut. Per channel for colours.', + inputs: [floatIn('edge', 0.5), anyIn('value', 0)], + outputs: [anyOut('result')], + params: [], + emit: ({ edge, value }) => ({ + result: { code: `step(${edge!.code}, ${value!.code})`, type: value!.type }, + }), +}; + /** Every node a shader graph offers. Registered by `graph/nodes`, offered by no behavior palette. */ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderTextureColor, ShaderTextureAlpha, + ShaderUV, + ShaderSample, + ShaderTextureSize, + ShaderTime, ShaderOutput, ShaderProperty, ShaderColor, @@ -230,6 +373,10 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderDivide, ShaderMix, ShaderLuminance, + ShaderSin, + ShaderFloor, + ShaderFract, + ShaderStep, ]; /** A GLSL float literal: always with a decimal point, so `1` does not become an int. */ diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 52315afa..83398127 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -325,6 +325,8 @@ export class Editor implements PlayHost { this.measureFps(dt); this.play.tick(dt); this.editorRun.tick(dt); + this.viewport.animate(); + this.gameView.animate(); this.console.refresh(); if (this.tab === 'animation') this.animationEditor.tick(dt); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 47be625c..0860a7b2 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -3,6 +3,7 @@ import { resolveObjects } from '../core/model/instance'; import { findObjectHolder } from '../core/model/Project'; import type { FrameRect } from '../core/model/texture'; import { activeDrag, Button, ui, Widget } from '../ui'; +import { shaderLibrary } from '../viewport/ShaderLibrary'; import { textureLibrary } from '../viewport/TextureLibrary'; import type { CentreTab, Editor } from './Editor'; import { currentImagePicker } from './texture/ImagePicker'; @@ -410,6 +411,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E walk(app.stage); return n; }, + /** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */ + shaderTime: (at: number | null) => shaderLibrary.freezeTime(at), + /** Which views redraw every tick because a Time shader is on screen. */ + animates: () => ({ scene: viewport.animates, game: gameView.animates }), lowLatency: () => (app.renderer as unknown as { gl?: WebGL2RenderingContext }).gl?.getContextAttributes() ?.desynchronized === true, diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index 6ae297ed..934c7d88 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -7,7 +7,7 @@ import type { EditorState } from '../state'; import { type Size, Theme, Widget } from '../../ui'; import type { DrawItem } from '../../viewport/items'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; -import type { ShaderSource } from '../../viewport/shaders'; +import { type ShaderSource, wornShader } from '../../viewport/shaders'; import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from '../../viewport/symbols'; import { textureLibrary } from '../../viewport/TextureLibrary'; import { type View, worldBounds, worldToScreen } from '../../viewport/view'; @@ -32,6 +32,7 @@ export abstract class WorldViewport extends Widget { /** Pictures of sprite items: siblings as well, each right under its node. */ private readonly sprites = new Map(); private visibleCount = 0; + private animated = false; protected lastItems: DrawItem[] = []; protected constructor(protected readonly state: EditorState) { @@ -45,8 +46,9 @@ export abstract class WorldViewport extends Widget { state.on('project', () => this.redraw()); state.on('selection', () => this.redraw()); state.on('mode', () => this.redraw()); + // While a Time shader is on screen the ticker redraws every frame anyway (`animate`). state.on('runtime', () => { - if (this.visible) this.redraw(); + if (this.visible && !this.animated) this.redraw(); }); // Unconditional: a picture arriving fires once per image, not per frame. textureLibrary.onChange(() => this.redraw()); @@ -87,6 +89,16 @@ export abstract class WorldViewport extends Widget { return { rendering: this.currentView() !== null, visibleItems: this.visibleCount }; } + /** True after a redraw that drew a sprite wearing a Time shader: the picture moves by itself. */ + get animates(): boolean { + return this.animated; + } + + /** Called every tick: redraws while the last redraw found a Time shader on screen. */ + animate(): void { + if (this.visible && this.animated) this.redraw(); + } + layerOf(objectId: string): 'objects' | 'overlay' | null { const node = this.nodes.get(objectId); if (!node) return null; @@ -131,6 +143,7 @@ export abstract class WorldViewport extends Widget { this.contentMask.clear(); this.content.visible = view !== null; this.visibleCount = 0; + this.animated = false; if (!view) return; const { items, background } = this.collectFrame(); @@ -219,6 +232,7 @@ export abstract class WorldViewport extends Widget { this.objects.addChild(sprite); } sprite.visible = true; + if (wornShader(item.props, shaders)?.time) this.animated = true; syncSpriteMesh(sprite, item, size, textureLibrary, shaders); } diff --git a/src/viewport/ShaderLibrary.ts b/src/viewport/ShaderLibrary.ts index fd97c57b..1f0a842f 100644 --- a/src/viewport/ShaderLibrary.ts +++ b/src/viewport/ShaderLibrary.ts @@ -9,6 +9,18 @@ import type { ShaderSource } from './shaders'; */ export class ShaderLibrary { private readonly builds = new Map(); + private readonly started = performance.now(); + /** Set from the test hook: the clock stands at this second until released. */ + private frozenAt: number | null = null; + + /** Seconds since the library came up – what a Time node reads. */ + now(): number { + return this.frozenAt ?? (performance.now() - this.started) / 1000; + } + + freezeTime(at: number | null): void { + this.frozenAt = at; + } buildFor(asset: ShaderAsset): ShaderBuild { const fingerprint = JSON.stringify(asset.graph); @@ -23,6 +35,7 @@ export class ShaderLibrary { source(project: Project): ShaderSource { const seen = new Map(); return { + now: this.now(), compiled: (assetId) => { let build = seen.get(assetId); if (build === undefined) { diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index 56a725a1..9255358d 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -1,5 +1,6 @@ import { Assets, CanvasSource, Texture } from 'pixi.js'; import type { FrameRect, TextureRef } from '../core/model/texture'; +import { DEFAULT_EDGE, type EdgeMode } from '../core/shader/nodes'; const DEFAULT_BASE = '/game-maker/data/textures'; @@ -10,6 +11,17 @@ const DEFAULT_BASE = '/game-maker/data/textures'; */ const EDGE_MODE = 'mirror-repeat'; +/** + * A shader may want the picture to continue differently past its border (the Output's Edge). The + * sampler state lives on the source – WebGL takes no style from the shader – so that is a cut of + * its own with that address mode. + */ +const ADDRESS_MODE: Record = { + mirror: EDGE_MODE, + repeat: 'repeat', + clamp: 'clamp-to-edge', +}; + /** * Keeps uploaded images on the GPU, keyed by file name. Nothing is evicted: file names are * content addressed, so a cached texture can never go stale. A miss is not an error – it starts @@ -35,9 +47,11 @@ export class TextureLibrary { * shared source, so two assets naming the same file with different `smooth` fight over it and * the last one drawn wins; upload avoids that by reusing the existing asset for a known file. */ - get(ref: TextureRef): Texture | null { + get(ref: TextureRef, edge: EdgeMode = DEFAULT_EDGE): Texture | null { const sheet = this.load(ref.file); - const texture = sheet && (ref.rect ? this.crop(ref.file, sheet, ref.rect) : sheet); + if (!sheet) return null; + const rect = ref.rect ?? (edge === DEFAULT_EDGE ? null : wholeOf(sheet)); + const texture = rect ? this.crop(ref.file, sheet, rect, edge) : sheet; if (!texture) return null; const mode = ref.smooth ? 'linear' : 'nearest'; if (texture.source.scaleMode !== mode) { @@ -80,15 +94,17 @@ export class TextureLibrary { * through a fill matrix it showed a row of the neighbouring cell along any edge that fell * inside a screen pixel; a cut has no neighbour to show. */ - private crop(file: string, sheet: Texture, rect: FrameRect): Texture | null { + private crop(file: string, sheet: Texture, rect: FrameRect, edge: EdgeMode): Texture | null { if (rect.w <= 0 || rect.h <= 0) return null; - const key = cropKey(file, rect); + const key = edge === DEFAULT_EDGE ? cropKey(file, rect) : `${cropKey(file, rect)}|${edge}`; let texture = this.crops.get(key); if (!texture) { const canvas = new OffscreenCanvas(rect.w, rect.h); const picture = sheet.source.resource as CanvasImageSource; canvas.getContext('2d')?.drawImage(picture, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w, rect.h); - texture = new Texture({ source: new CanvasSource({ resource: canvas, addressMode: EDGE_MODE }) }); + texture = new Texture({ + source: new CanvasSource({ resource: canvas, addressMode: ADDRESS_MODE[edge] }), + }); this.crops.set(key, texture); } return texture; @@ -118,4 +134,8 @@ function cropKey(file: string, rect: FrameRect): string { return `${file}|${rect.x},${rect.y},${rect.w},${rect.h}`; } +function wholeOf(sheet: Texture): FrameRect { + return { x: 0, y: 0, w: sheet.source.pixelWidth, h: sheet.source.pixelHeight }; +} + export const textureLibrary = new TextureLibrary(); diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index 001489f3..165be9d2 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -2,11 +2,19 @@ import { GlProgram, type Mesh, Shader, Texture, 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'; +import { TIME_UNIFORM, 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; + /** The shader clock at this redraw, in seconds – what a Time node reads, the same on every sprite. */ + now: number; +} + +/** The compiled shader the bag names, if there is one and it compiles. */ +export function wornShader(props: PropertyBag, shaders: ShaderSource): CompiledShader | null { + const id = shaderOf(props); + return id ? shaders.compiled(id) : null; } /** @@ -40,39 +48,47 @@ void main() */ class GraphShader extends Shader { private readonly uniforms: Record | null; + private readonly names: readonly { name: string; default: number }[]; + private readonly timed: boolean; + readonly fragment: string; /** What `Mesh.shader` asks of a shader; kept in step with the mesh's own by `wear`. */ texture: Texture = Texture.WHITE; constructor( readonly assetId: string, - readonly fragment: string, - private readonly names: readonly { name: string; default: number }[], + compiled: CompiledShader, ) { - const group = - names.length > 0 - ? new UniformGroup( - Object.fromEntries( - names.map((u) => [uniformName(u.name), { value: u.default, type: 'f32' as const }]), - ), - ) - : null; + const entries: [string, { value: number; type: 'f32' }][] = compiled.uniforms.map((u) => [ + uniformName(u.name), + { value: u.default, type: 'f32' }, + ]); + if (compiled.time) entries.push([TIME_UNIFORM, { value: 0, type: 'f32' }]); + const group = entries.length > 0 ? new UniformGroup(Object.fromEntries(entries)) : null; super({ - glProgram: GlProgram.from({ vertex: SPRITE_VERTEX, fragment, name: `shader-${assetId}` }), + glProgram: GlProgram.from({ + vertex: SPRITE_VERTEX, + fragment: compiled.fragment, + name: `shader-${assetId}`, + }), resources: { uTexture: Texture.WHITE.source, uSampler: Texture.WHITE.source.style, ...(group ? { shaderUniforms: group } : {}), }, }); + this.fragment = compiled.fragment; + this.names = compiled.uniforms; + this.timed = compiled.time; this.uniforms = group ? group.uniforms : null; } - read(props: PropertyBag): void { + read(props: PropertyBag, now: number): 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; } + if (this.timed) this.uniforms[TIME_UNIFORM] = now; } /** Pixi binds a mesh's texture only for its own shader, so a custom one takes it here. */ @@ -97,14 +113,14 @@ export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource const compiled = id ? shaders.compiled(id) : null; const worn = wornBy(mesh); if (worn && compiled && worn.assetId === id && worn.fragment === compiled.fragment) { - worn.read(props); + worn.read(props, shaders.now); worn.wear(mesh.texture); return; } disposeShader(mesh); if (compiled) { - const shader = new GraphShader(id, compiled.fragment, compiled.uniforms); - shader.read(props); + const shader = new GraphShader(id, compiled); + shader.read(props, shaders.now); shader.wear(mesh.texture); mesh.shader = shader; } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index e64fb7e7..59d0970e 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -2,9 +2,10 @@ import { type Graphics, Mesh, MeshGeometry, Rectangle, Texture } from 'pixi.js'; import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/properties/bounds'; import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; +import { DEFAULT_EDGE } from '../core/shader/nodes'; import { Theme } from '../ui'; import type { DrawItem } from './items'; -import { disposeShader, type ShaderSource, syncShader } from './shaders'; +import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders'; import type { TextureLibrary } from './TextureLibrary'; /** Screen-pixel half-width of the grab band along a collider's outline. */ @@ -41,7 +42,8 @@ export function syncSpriteMesh( ): void { // Null while the picture is still loading: the box shows its plain colour until then, and once // the picture is there the colour tints it. - const texture = item.texture ? textures.get(item.texture) : null; + const edge = wornShader(item.props, shaders)?.edge ?? DEFAULT_EDGE; + const texture = item.texture ? textures.get(item.texture, edge) : null; mesh.position.set(item.x, item.y); mesh.scale.set(size.w, size.h); mesh.texture = texture ?? Texture.WHITE; diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 6604feb8..d6586519 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -32,9 +32,14 @@ import { SHADER_NODES, SHADER_NUMBER, SHADER_OUTPUT, + SHADER_SAMPLE, SHADER_TEXTURE_ALPHA, SHADER_TEXTURE_COLOR, + SHADER_TEXTURE_SIZE, + SHADER_TIME, + SHADER_UV, ShaderOutput, + ShaderSample, } from '../../src/core/shader/nodes'; import { deserializeProject } from '../../src/core/store/serialize'; import { paletteEntries } from '../../src/editor/graph/paletteEntries'; @@ -205,7 +210,9 @@ describe('compileShader', () => { expect(fragment).toContain(`uniform float u_${name};`); // Drawn in the sprite's own pass: the tint goes in before the graph, premultiplied like the texel. expect(fragment).toContain('vec4 texel = texture(uTexture, vTextureCoord) * vColor;'); - expect(fragment).toContain('vec3 rgb = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;'); + expect(fragment).toContain('vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : c.rgb; }'); + expect(fragment).toContain('vec3 rgb = straight(texel);'); + expect(compiled).toMatchObject({ time: false, edge: 'mirror' }); expect(fragment).toMatch(/float n\d+_result = \(n\d+_value - 1\.0\);/); expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_color \+ vec3\(n\d+_result\)\);/); expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_result - vec3\(0\.5\)\);/); @@ -355,6 +362,88 @@ describe('compileShader', () => { expect(build.compiled).toBeNull(); expect(build.problems).toEqual({ [two]: 'Feeds itself' }); }); + + it('gives the graph the pixel’s place, the picture at another spot, its size and the clock', () => { + const f = makeProject(); + const s = shaderEditor(f, 'Flip'); + const uv = s.add(SHADER_UV); + const flipped = s.add('ShaderSubtract', { a: 1 }); + const sample = s.add(SHADER_SAMPLE); + const size = s.add(SHADER_TEXTURE_SIZE); + const time = s.add(SHADER_TIME); + const wave = s.add('ShaderSin'); + const sum = s.add('ShaderAdd'); + const output = s.add(SHADER_OUTPUT, { edge: 'repeat' }); + s.link(uv, 'u', flipped, 'b'); + s.link(flipped, 'result', sample, 'u'); + s.link(sample, 'color', output, 'color'); + s.link(time, 'value', wave, 'value'); + s.link(size, 'width', sum, 'a'); + s.link(wave, 'result', sum, 'b'); + s.link(sum, 'result', output, 'alpha'); + // Nothing to type on u and v: unconnected, each is the pixel's own, and the node is Texture color. + expect(inlineInputs(ShaderSample)).toEqual([]); + const build = compileShader(s.asset().graph); + expect(build.problems).toEqual({}); + const compiled = build.compiled!; + expect(compiled).toMatchObject({ time: true, edge: 'repeat', uniforms: [] }); + const { fragment } = compiled; + expect(fragment).toContain('uniform float uTime;'); + expect(fragment).toMatch(/float n\d+_u = vTextureCoord\.x;/); + expect(fragment).toMatch(/float n\d+_result = \(1\.0 - n\d+_u\);/); + expect(fragment).toMatch( + /vec3 n\d+_color = straight\(texture\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\);/, + ); + expect(fragment).toMatch( + /float n\d+_alpha = \(texture\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)\.a;/, + ); + expect(fragment).toMatch(/float n\d+_width = float\(textureSize\(uTexture, 0\)\.x\);/); + expect(fragment).toMatch(/float n\d+_value = uTime;/); + expect(fragment).toMatch(/float n\d+_result = sin\(n\d+_value\);/); + }); + + it('takes the clock only for a Time node the Output reaches, and the Edge from the Output', () => { + const f = makeProject(); + const s = shaderEditor(f, 'Idle'); + s.add(SHADER_TIME); + const out = s.add(SHADER_OUTPUT); + let compiled = compileShader(s.asset().graph).compiled!; + expect(compiled).toMatchObject({ time: false, edge: 'mirror' }); + expect(compiled.fragment).not.toContain('uTime'); + const params = s.asset().graph.nodes.find((n) => n.id === out)!.params; + params.edge = 'clamp'; + expect(compileShader(s.asset().graph).compiled!.edge).toBe('clamp'); + // A value the dropdown never offers reads as the default rather than as a mode. + params.edge = 'sideways'; + compiled = compileShader(s.asset().graph).compiled!; + expect(compiled.edge).toBe('mirror'); + }); + + it('writes Fract, Step and Floor as the GLSL calls, per channel for a colour', () => { + const f = makeProject(); + const s = shaderEditor(f, 'Lines'); + const uv = s.add(SHADER_UV); + const scaled = s.add('ShaderMultiply', { b: 4 }); + const saw = s.add('ShaderFract'); + const cut = s.add('ShaderStep', { edge: 0.5 }); + const tint = s.add('ShaderColor', { value: 0xff8000 }); + const whole = s.add('ShaderFloor'); + const output = s.add(SHADER_OUTPUT); + s.link(uv, 'v', scaled, 'a'); + s.link(scaled, 'result', saw, 'value'); + s.link(saw, 'result', cut, 'value'); + s.link(cut, 'result', output, 'alpha'); + s.link(tint, 'color', whole, 'value'); + s.link(whole, 'result', output, 'color'); + const build = compileShader(s.asset().graph); + expect(build.problems).toEqual({}); + const { fragment } = build.compiled!; + expect(fragment).toMatch(/float n\d+_result = \(n\d+_v \* 4\.0\);/); + expect(fragment).toMatch(/float n\d+_result = fract\(n\d+_result\);/); + expect(fragment).toMatch(/float n\d+_result = step\(0\.5, n\d+_result\);/); + expect(fragment).toMatch(/vec3 n\d+_result = floor\(n\d+_color\);/); + expect(fragment).toMatch(/float alpha = clamp\(n\d+_result, 0\.0, 1\.0\);/); + }); }); describe('a sprite wearing a shader', () => { diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 8814752f..177124f2 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -10,6 +10,22 @@ import { colourDistance } from '../lib/harness.mjs'; const BASE = '#4080ff'; const ADJUST = 'adjust-colors'; +/** A 32 px picture, red on the left and green on the right, pasted in for the sampling checks (28l). */ +const PASTE_HALVES = `(async () => { + const canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 32; + const g = canvas.getContext('2d'); + g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 16, 32); + g.fillStyle = '#3cc85a'; g.fillRect(16, 0, 16, 32); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + const transfer = new DataTransfer(); + transfer.items.add(new File([blob], 'halves.png', { type: 'image/png' })); + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); +})()`; +const LEFT = '#e63c3c'; +const RIGHT = '#3cc85a'; + export async function shader(t) { const { check, @@ -381,6 +397,210 @@ export async function shader(t) { now: await centre(), }); + // 28k. UV into Step: the pixel's place on the picture decides its colour – a hard cut at u = 0.5, + // then at v = 0.5, which shows v runs top to bottom. `box` is the sprite's centre and size. + const node = (id, type, params = {}, x = 0, y = 0) => ({ id, type, x, y, params }); + const edge = (from, fromPort, to, toPort) => ({ + id: `${from}-${to}-${toPort}`, + from: { node: from, port: fromPort }, + to: { node: to, port: toPort }, + }); + const graph = (nodes, edges) => + mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + a.graph.nodes = ${JSON.stringify(nodes)}; + a.graph.edges = ${JSON.stringify(edges)};`); + const pixel = (fx, fy) => + json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`); + const redOrBlue = (t, edges) => + graph( + [ + ...t, + node('red', 'ShaderColor', { value: 0xff0000 }, 260, 200), + node('blue', 'ShaderColor', { value: 0x0000ff }, 260, 340), + node('mix', 'ShaderMix', {}, 520, 200), + node('out', 'ShaderOutput', {}, 780, 200), + ], + [ + ...edges, + edge('red', 'color', 'mix', 'a'), + edge('blue', 'color', 'mix', 'b'), + edge('mix', 'result', 'out', 'color'), + ], + ); + const cutAt = (port) => + redOrBlue( + [node('uv', 'ShaderUV'), node('cut', 'ShaderStep', { edge: 0.5 }, 260, 0)], + [edge('uv', port, 'cut', 'value'), edge('cut', 'result', 'mix', 't')], + ); + await cutAt('u'); + await sleep(400); + const uLeft = await pixel(-0.3, -0.3); + const uRight = await pixel(0.3, -0.3); + check( + 'UV into Step: left of u = 0.5 the sprite is red, right of it blue', + colourDistance(uLeft, '#ff0000') < 6 && colourDistance(uRight, '#0000ff') < 6, + { uLeft, uRight }, + ); + await cutAt('v'); + await sleep(400); + const vTop = await pixel(-0.3, -0.3); + const vBottom = await pixel(-0.3, 0.3); + check( + 'v runs top to bottom: above v = 0.5 red, below it blue', + colourDistance(vTop, '#ff0000') < 6 && colourDistance(vBottom, '#0000ff') < 6, + { vTop, vBottom }, + ); + + // 28l. Sample texture reads the picture where the graph says. On a pasted two-colour picture: + // flipped u swaps the halves; u + 0.5 past the border shows the Output's Edge – Repeat continues + // with the far half, Mirror with the near one. + await evaluate(PASTE_HALVES); + await sleep(2500); + const picture = ( + await json( + "__gm.state.project.assets.filter((a) => a.type === 'texture' && a.width === 32 && a.height === 32).map((a) => ({ id: a.id, file: a.file }))", + ) + )[0]; + check( + 'a two-colour picture is pasted in for the sampling checks', + Boolean(picture), + JSON.stringify(picture), + ); + await evaluate('__gm.state.select(null)'); + await assetProps({ texture: picture?.id ?? '', color: 0xffffff }); + await graph( + [node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput')], + [edge('texel', 'color', 'out', 'color')], + ); + await sleep(600); + const plainLeft = await pixel(-0.3, -0.3); + const plainRight = await pixel(0.3, -0.3); + check( + 'through Texture color the picture is on: red left, green right', + colourDistance(plainLeft, LEFT) < 12 && colourDistance(plainRight, RIGHT) < 12, + { plainLeft, plainRight }, + ); + const sampleAt = (uNodes, uEdges, outParams = {}) => + graph( + [ + node('uv', 'ShaderUV'), + ...uNodes, + node('sample', 'ShaderSample', {}, 520, 0), + node('out', 'ShaderOutput', outParams, 780, 0), + ], + [...uEdges, edge('sample', 'color', 'out', 'color')], + ); + await sampleAt( + [node('flip', 'ShaderSubtract', { a: 1 }, 260, 0)], + [edge('uv', 'u', 'flip', 'b'), edge('flip', 'result', 'sample', 'u')], + ); + await sleep(400); + const flipLeft = await pixel(-0.3, -0.3); + const flipRight = await pixel(0.3, -0.3); + check( + 'sampling at 1 - u mirrors the picture: green left, red right', + colourDistance(flipLeft, RIGHT) < 12 && colourDistance(flipRight, LEFT) < 12, + { flipLeft, flipRight }, + ); + const scrolled = async (edgeMode) => { + await sampleAt( + [node('shift', 'ShaderAdd', { b: 0.5 }, 260, 0)], + [edge('uv', 'u', 'shift', 'a'), edge('shift', 'result', 'sample', 'u')], + { edge: edgeMode }, + ); + await sleep(400); + return { left: await pixel(-0.3, -0.3), right: await pixel(0.3, -0.3) }; + }; + const repeat = await scrolled('repeat'); + check( + 'u + 0.5 with Edge Repeat: the near half shows the far one, and past the border the picture starts over', + colourDistance(repeat.left, RIGHT) < 12 && colourDistance(repeat.right, LEFT) < 12, + repeat, + ); + const mirror = await scrolled('mirror'); + check( + 'the same with Edge Mirror: past the border the picture turns back on itself', + colourDistance(mirror.left, RIGHT) < 12 && colourDistance(mirror.right, RIGHT) < 12, + mirror, + ); + await evaluate("__gm.showTab('shader')"); + await sleep(300); + await shot('28-shader-scroll-graph'); + await evaluate("__gm.showTab('scene')"); + await sleep(300); + const sizeAtLeast = async (px) => { + await redOrBlue( + [node('size', 'ShaderTextureSize'), node('cut', 'ShaderStep', { edge: px }, 260, 0)], + [edge('size', 'width', 'cut', 'value'), edge('cut', 'result', 'mix', 't')], + ); + await sleep(400); + return pixel(-0.3, -0.3); + }; + const wide = await sizeAtLeast(31.5); + const wider = await sizeAtLeast(32.5); + check( + 'Texture size reads the picture’s pixel width: at least 31.5, not 32.5', + colourDistance(wide, '#0000ff') < 6 && colourDistance(wider, '#ff0000') < 6, + { wide, wider }, + ); + + // 28m. Time: with the clock held the pixel is a function of the second; released, a sprite wearing + // a Time shader redraws by itself in the scene view, where nothing else ticks, and in play. + 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.shaderTime(0.2)'); + await sleep(400); + const early = await pixel(-0.3, -0.3); + await evaluate('__gm.shaderTime(0.7)'); + await sleep(400); + const late = await pixel(-0.3, -0.3); + check( + 'Time into Fract and Step: red at 0.2 s, blue at 0.7 s', + colourDistance(early, '#ff0000') < 6 && colourDistance(late, '#0000ff') < 6, + { early, late }, + ); + await redOrBlue( + [node('time', 'ShaderTime'), node('saw', 'ShaderFract', {}, 260, 0)], + [edge('time', 'value', 'saw', 'value'), edge('saw', 'result', 'mix', 't')], + ); + await evaluate('__gm.shaderTime(null)'); + await sleep(400); + const first = await pixel(-0.3, -0.3); + await sleep(400); + const second = await pixel(-0.3, -0.3); + const animates = await json('__gm.animates()'); + check( + 'released, the scene view redraws every tick for the Time shader: the colour moves with nothing running', + animates.scene === true && animates.game === false && first !== second, + { animates, first, second }, + ); + await play(); + await sleep(500); + const playing = await json('__gm.animates()'); + await stop(); + await sleep(300); + check('and so does the game view while playing', playing.game === true, JSON.stringify(playing)); + await assetProps({ shader: '' }); + await sleep(400); + check( + 'with the shader off the scene view stops redrawing', + (await json('__gm.animates()')).scene === false, + JSON.stringify(await json('__gm.animates()')), + ); + await assetProps({ texture: '', color: 0x4080ff, shader: made.id }); + await sleep(400); + // 28j. Back to None: no filter at all, the plain colour returns bit for bit. await assetProps({ shader: '' }); await sleep(300); @@ -393,7 +613,8 @@ export async function shader(t) { await mutate(` const project = __gm.state.project; - project.assets = project.assets.filter((a) => a.id !== 'shade-sprite' && a.id !== ${JSON.stringify(made.id)}); + const gone = new Set(['shade-sprite', ${JSON.stringify(made.id)}, ${JSON.stringify(picture?.id ?? '')}]); + project.assets = project.assets.filter((a) => !gone.has(a.id)); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'oshade')`); await evaluate('__gm.state.select(null)'); @@ -402,6 +623,8 @@ export async function shader(t) { '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 json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(picture?.id ?? '')})`)) === + false && (await shaders()).map((s) => s.id).join(',') === ADJUST, ); }