Files
feuerwerk/src/viewport/shaders.ts
T
adminandClaude Opus 5.5 89aa92d2e3
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Failing after 11m40s
Put a shader on a text and draw pixel fonts sharp (#169)
A text could not wear a shader, and a pixel font was always smoothed.
Pixi's Text takes no custom shader. It also shares one texture between
texts of the same content and style, and the filter is not part of that
key, so two equal texts with different filters would overwrite each
other.

- TextPicture rasters a text into a texture of its own and draws it
  like a sprite. Shader, time animation and edge mode work without a
  special path. A new text, font or size rasters again and keeps the
  shader.
- The old texture is dropped only after the shader holds the new one.
  Before, Pixi warned about a destroyed texture that was still bound.
- A text has a Shader row with the shader's property rows below, as a
  sprite has. Overrides, saving, animation and the Properties node
  (type Text) know them. A shader parameter named like an own row of
  the text loses to that row, as on the particle source.
- The font asset has a Filter row, Linear or Nearest. The row existed
  three times already (texture, vector art, pixel art) and is now one
  helper filterRow.
- Nearest rasters the text 8 times finer and cuts every 8x8 block to
  one texel: at least half covered is full, else empty. Chrome on
  Windows smooths canvas text sideways through DirectWrite whatever
  filter we set later. The cut removes those half pixels on every
  system, and the texture stays one texel per world unit.

Found on the way:

- On a font that is no pixel font thin strokes break apart under
  Nearest. For a pixel font at its own size this does not matter.

Not done:

- The preview in the Shader tab does not take a selected text as its
  picture, it shows the test picture then.

Tests:

- ./check.sh: 189 files, 2759 tests, CHECK EXIT 0.
- text-shader.test.ts with 10 tests: rows and name precedence, a
  clamped override, save and load, animation, the font filter, and
  sharpCoverage on a smoothed edge and at the texture border.
- e2e 30 has 7 new checks: red glyphs through the shader picked with
  the mouse, the shader survives a new text, red in the game, soft
  edges under Linear, clean 4x4 blocks under Nearest, and half covered
  texels (Linear 238, Nearest 0).
- Full local run before the Nearest cut: all passed, 1543 checks.
  GM_STEPS=1,30 on the final bundle: all passed, 32 checks. The sum
  1544 in the README is counted from these two runs, not measured.
- Counter-checks fail as expected: in the browser no shader on a text
  fails 30h-30j, an always linear sampler fails the Nearest check, the
  old 1:1 raster leaves 107 half covered texels and fails 30k-ii.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:01:28 +02:00

282 lines
10 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;
}
/** The shader asset a mesh draws with, `''` for Pixi's own. */
export function wornShaderId(mesh: Mesh): string {
return wornBy(mesh)?.assetId ?? '';
}
/**
* 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();
}