Shader graph: UV, Sample texture, Texture size, Time, Sin/Floor/Fract/Step; edge mode on Output (#78)
game-maker / suite (push) Successful in 8m38s
game-maker / suite (push) Successful in 8m38s
Fragment-side coordinate nodes instead of vertex nodes: the quad stays the quad, a UV read plus a sample at a computed coordinate covers flip, scroll, wave, pixelate and scanlines. Time is a uniform only when a Time node reaches the Output; a viewport that drew a Time shader redraws from the ticker so the Scene tab animates without a running world. Edge (Mirror/Repeat/Clamp) is a second texture variant in TextureLibrary because WebGL binds no TextureStyle resource of the shader. The compiler now emits #version 300 es first: Pixi only compiles as ES 3.00 when the fragment says so, and textureSize needs it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e5e5ef925
commit
f85606427b
12 files changed
+586
-38
No files matched your search
@@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 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
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
@@ -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: '' };
|
||||
}
|
||||
+149
-2
@@ -26,6 +26,8 @@ export interface Expr {
|
||||
export interface ShaderNodeDef extends NodeDef {
|
||||
category: 'shader';
|
||||
emit(inputs: Record<string, Expr>, params: PropertyBag): Record<string, Expr>;
|
||||
/** What an input with nothing to type on reads while unconnected; without it, the texel. */
|
||||
unfed?: Record<string, Expr>;
|
||||
}
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, Mesh>();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,18 @@ import type { ShaderSource } from './shaders';
|
||||
*/
|
||||
export class ShaderLibrary {
|
||||
private readonly builds = new Map<string, { fingerprint: string; build: ShaderBuild }>();
|
||||
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<string, ShaderBuild | null>();
|
||||
return {
|
||||
now: this.now(),
|
||||
compiled: (assetId) => {
|
||||
let build = seen.get(assetId);
|
||||
if (build === undefined) {
|
||||
|
||||
@@ -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<EdgeMode, 'repeat' | 'mirror-repeat' | 'clamp-to-edge'> = {
|
||||
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();
|
||||
+32
-16
@@ -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<string, number> | 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+224
-1
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user