Files
feuerwerk/src/viewport/shaders.ts
T
adminandClaude Fable 5.1 cc6db5f350
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s
Three stores, three importers and three pickers that differ only in their type are one of each (#85)
Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:38:54 +02:00

277 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Color,
GlProgram,
Matrix,
type Mesh,
type ParticleContainer,
Shader,
Texture,
TextureStyle,
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 { 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;
}
/**
* Pixi's own mesh vertex stage, minus what a sprite quad does not need: a frame is a whole texture
* (`TextureLibrary`), so `aUV` maps 1:1 and there is no texture matrix. `uRound` is left out –
* Pixi's uniform sync skips uniforms the program does not declare.
*/
const SPRITE_VERTEX = `
in vec2 aPosition;
in vec2 aUV;
out vec2 vTextureCoord;
out vec4 vColor;
uniform mat3 uProjectionMatrix;
uniform mat3 uWorldTransformMatrix;
uniform vec4 uWorldColorAlpha;
uniform mat3 uTransformMatrix;
uniform vec4 uColor;
void main()
{
mat3 m = uProjectionMatrix * uWorldTransformMatrix * uTransformMatrix;
gl_Position = vec4((m * vec3(aPosition, 1.0)).xy, 0.0, 1.0);
vTextureCoord = aUV;
vColor = uColor * uWorldColorAlpha;
}
`;
/**
* Pixi's own particle vertex stage in GLSL ES 3.00, which is what the compiled fragment is written
* in: `attribute`/`varying` become `in`/`out` and `vUV` takes the name the fragment reads. The
* rounding is kept so a particle sits on the same pixel shaded as unshaded.
*/
const PARTICLE_VERTEX = `
in vec2 aVertex;
in vec2 aUV;
in vec4 aColor;
in vec2 aPosition;
in float aRotation;
out vec2 vTextureCoord;
out vec4 vColor;
uniform mat3 uTranslationMatrix;
uniform float uRound;
uniform vec2 uResolution;
uniform vec4 uColor;
vec2 roundPixels(vec2 position, vec2 targetSize)
{
return (floor(((position * 0.5 + 0.5) * targetSize) + 0.5) / targetSize) * 2.0 - 1.0;
}
void main(void)
{
float c = cos(aRotation);
float s = sin(aRotation);
vec2 v = vec2(aVertex.x * c - aVertex.y * s, aVertex.x * s + aVertex.y * c) + aPosition;
gl_Position = vec4((uTranslationMatrix * vec3(v, 1.0)).xy, 0.0, 1.0);
if (uRound == 1.0) gl_Position.xy = roundPixels(gl_Position.xy, uResolution);
vTextureCoord = aUV;
vColor = vec4(aColor.rgb * aColor.a, aColor.a) * uColor;
}
`;
/** One float uniform per graph parameter, plus the clock where a Time node reads one. */
function uniformGroupOf(compiled: CompiledShader): UniformGroup<Record<string, UniformFloat>> | null {
const entries: [string, UniformFloat][] = compiled.uniforms.map((u) => [
uniformName(u.name),
{ value: u.default, type: 'f32' },
]);
if (compiled.time) entries.push([TIME_UNIFORM, { value: 0, type: 'f32' }]);
return entries.length > 0 ? new UniformGroup(Object.fromEntries(entries)) : null;
}
type UniformFloat = { value: number; type: 'f32' };
function readUniforms(
into: Record<string, number>,
compiled: CompiledShader,
props: PropertyBag,
now: number,
): void {
for (const u of compiled.uniforms) {
const n = Number(props[u.name]);
into[uniformName(u.name)] = Number.isFinite(n) ? n : u.default;
}
if (compiled.time) into[TIME_UNIFORM] = now;
}
/**
* A compiled graph as a Pixi program. The program is shared (`GlProgram.from` caches by source),
* the uniforms are this wearer's; `read` takes them from the item's bag. `resources` are the slots
* Pixi fills for the vertex stage in question – `Shader.resources` only accepts the slots its
* constructor declared, so a subclass names them all up front.
*/
abstract class CompiledGraphShader extends Shader {
private readonly uniforms: Record<string, number> | null;
private readonly compiled: CompiledShader;
readonly fragment: string;
constructor(
readonly assetId: string,
compiled: CompiledShader,
program: { vertex: string; name: string },
resources: Record<string, unknown>,
) {
const group = uniformGroupOf(compiled);
super({
glProgram: GlProgram.from({ vertex: program.vertex, fragment: compiled.fragment, name: program.name }),
resources: { ...resources, ...(group ? { shaderUniforms: group } : {}) },
});
this.fragment = compiled.fragment;
this.compiled = compiled;
this.uniforms = group ? group.uniforms : null;
}
/** True while this is still the shader `props` ask for, so it can be updated instead of rebuilt. */
wears(id: string, compiled: CompiledShader): boolean {
return this.assetId === id && this.fragment === compiled.fragment;
}
read(props: PropertyBag, now: number): void {
if (this.uniforms) readUniforms(this.uniforms, this.compiled, props, now);
}
}
/**
* A shader asset worn by one sprite mesh: the fragment replaces Pixi's in the sprite's own draw,
* so there is no filter and no extra pass (#77).
*/
class GraphShader extends CompiledGraphShader {
/** What `Mesh.shader` asks of a shader; kept in step with the mesh's own by `wear`. */
texture: Texture = Texture.WHITE;
constructor(assetId: string, compiled: CompiledShader) {
super(
assetId,
compiled,
{ vertex: SPRITE_VERTEX, name: `shader-${assetId}` },
{ uTexture: Texture.WHITE.source, uSampler: Texture.WHITE.source.style },
);
}
/** Pixi binds a mesh's texture only for its own shader, so a custom one takes it here. */
wear(texture: Texture): void {
this.texture = texture;
this.resources.uTexture = texture.source;
this.resources.uSampler = texture.source.style;
}
}
function wornBy(mesh: Mesh): GraphShader | null {
return mesh.shader instanceof GraphShader ? mesh.shader : null;
}
/**
* Makes the mesh's shader match `props`: updated in place while asset and fragment are the same,
* rebuilt when either changed, dropped for no shader – and for one that does not compile, which
* leaves the mesh on Pixi's batched draw.
*/
export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource): void {
const id = shaderOf(props);
const compiled = id ? shaders.compiled(id) : null;
const worn = wornBy(mesh);
if (worn && compiled && worn.wears(id, compiled)) {
worn.read(props, shaders.now);
worn.wear(mesh.texture);
return;
}
disposeShader(mesh);
if (compiled) {
const shader = new GraphShader(id, compiled);
shader.read(props, shaders.now);
shader.wear(mesh.texture);
mesh.shader = shader;
}
// Pixi caches whether a mesh batches (MeshPipe's meshData) and re-checks it only in
// validateRenderable, which it skips in any frame whose structure changed – and the draw order
// re-attaches every object per redraw. A mesh that ever drew batched would so keep drawing
// batched, plain, its shader ignored. `unload` drops that cache; the next frame reads `batched` afresh.
if (Boolean(worn) !== Boolean(compiled)) mesh.unload();
}
/**
* A shader asset worn by every particle of one source (#88). It replaces the container's whole
* program, so the fragment sees one particle's own quad – its picture, not its life: Pixi's particle
* buffer carries five fixed attributes and takes no sixth, so age, index and seed stay on the graph.
*
* The pipe breaks the batch for every field anyway, so unlike a shaded sprite (#77) this costs no
* draw call at all: five hundred shaded particles are the one call five hundred plain ones were.
*/
class ParticleGraphShader extends CompiledGraphShader {
constructor(assetId: string, compiled: CompiledShader) {
// `GlParticleContainerAdaptor` writes the field's texture and the pipe's own uniforms into
// whichever shader the container names, so both slots have to exist, empty, for Pixi to fill.
super(
assetId,
compiled,
{ vertex: PARTICLE_VERTEX, name: `particle-shader-${assetId}` },
{
uTexture: Texture.WHITE.source,
uSampler: new TextureStyle({}),
uniforms: {
uTranslationMatrix: { value: new Matrix(), type: 'mat3x3<f32>' },
uColor: { value: new Color(0xffffff), type: 'vec4<f32>' },
uRound: { value: 1, type: 'f32' },
uResolution: { value: [0, 0], type: 'vec2<f32>' },
},
},
);
}
}
function wornByField(container: ParticleContainer): ParticleGraphShader | null {
return container.shader instanceof ParticleGraphShader ? container.shader : null;
}
/** The compiled shader a source's particles are drawn with, if it names one that compiles. */
export function syncParticleShader(
container: ParticleContainer,
props: PropertyBag,
shaders: ShaderSource,
): void {
const id = shaderOf(props);
const compiled = id ? shaders.compiled(id) : null;
const worn = wornByField(container);
if (worn && compiled && worn.wears(id, compiled)) {
worn.read(props, shaders.now);
return;
}
disposeParticleShader(container);
if (compiled) {
const shader = new ParticleGraphShader(id, compiled);
shader.read(props, shaders.now);
container.shader = shader;
}
}
export function disposeParticleShader(container: ParticleContainer): void {
const worn = wornByField(container);
if (!worn) return;
// Typed as always present, but the adaptor reads `container.shader || defaultShader` – dropping it
// is how a field says it wears none.
(container as { shader?: Shader }).shader = undefined;
worn.destroy();
}
/** `Mesh.destroy` only lets go of its shader, so a mesh going away releases it here. */
export function disposeShader(mesh: Mesh): void {
const worn = wornBy(mesh);
if (!worn) return;
mesh.shader = null;
worn.destroy();
}