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 -8
View File
@@ -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
View File
@@ -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. */
+2
View File
@@ -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);
}
+5
View File
@@ -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,
+16 -2
View File
@@ -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);
}
+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;