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

+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();