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>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
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();
|
||
}
|