Shader graph: UV, Sample texture, Texture size, Time, Sin/Floor/Fract/Step; edge mode on Output (#78)
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:
rootandClaude Fable 5.1 committed 2026-09-13 16:18:22 +02:00
1 parent 0e5e5ef925
commit f85606427b
12 files changed
+586 -38

No files matched your search

+13
View File
@@ -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) {
+25 -5
View File
@@ -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
View File
@@ -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;
}
+4 -2
View File
@@ -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;