A shader costs a source nothing, because a field was already one draw call (#88)
game-maker / suite (push) Successful in 10m9s
game-maker / suite (push) Successful in 10m9s
A particle source can wear a shader asset the way a sprite does, and it is the
cheapest place in the editor to wear one: a shaded sprite gives up its batch and
costs a draw call of its own, while `ParticleContainerPipe` breaks the batch for
every field regardless. Five hundred shaded particles are the one call five
hundred plain ones were.
The fragment the compiler emits needed no change, only a second vertex stage:
Pixi's particle vertex is GLSL ES 1.00 and ours is ES 3.00, so it is translated
rather than invented. What it sees is a particle's picture, not its life — the
buffer carries five fixed attributes and takes no sixth, so age, index and seed
stay on the graph, which is what the graph is for.
Two things the issue did not ask about:
`Shader.resources` is an accessor over the names its constructor declared, so a
slot that is not declared becomes a loose JS property and is never bound. The
adaptor writes `uTranslationMatrix` into whichever shader the container names —
without an empty block for it the vertex gets no matrix and the particles land
elsewhere.
`schemaOf` in serialize.ts hung on `schema !== schemaFor('sprite')`, an identity
check a source never satisfies, so its shader values would have been dropped on
save.
A source is the one asset drawing rows from two graphs, so a name can arrive
twice. Its own row wins and the uniform keeps its default; nothing is rejected
on the shader's side, because the collision exists only at this one wearer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e0a21dee1
commit
d5e67cd80c
9 files changed
+346
-26
No files matched your search
@@ -17,6 +17,7 @@ import {
|
||||
RATE_KEY,
|
||||
TIME_SCALE_KEY,
|
||||
} from '../core/properties/particles';
|
||||
import { disposeParticleShader, type ShaderSource, syncParticleShader } from './shaders';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
@@ -187,6 +188,11 @@ export class ParticleField {
|
||||
this.container.update();
|
||||
}
|
||||
|
||||
/** Puts the shader the source names on every particle of this field, or takes one off (#88). */
|
||||
wear(props: PropertyBag, shaders: ShaderSource): void {
|
||||
syncParticleShader(this.container, props, shaders);
|
||||
}
|
||||
|
||||
/** Particles are reused rather than rebuilt: a stream of 500 would otherwise allocate 500 a frame. */
|
||||
private take(slot: number, texture: Texture): Particle {
|
||||
let p = this.pool[slot];
|
||||
@@ -203,6 +209,7 @@ export class ParticleField {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
disposeParticleShader(this.container);
|
||||
this.container.destroy();
|
||||
}
|
||||
}
|
||||
+156
-17
@@ -1,4 +1,14 @@
|
||||
import { GlProgram, type Mesh, Shader, Texture, UniformGroup } from 'pixi.js';
|
||||
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';
|
||||
@@ -41,6 +51,64 @@ void main()
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 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 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). The program is shared (`GlProgram.from` caches
|
||||
@@ -48,8 +116,7 @@ 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;
|
||||
private readonly compiled: CompiledShader;
|
||||
readonly fragment: string;
|
||||
/** What `Mesh.shader` asks of a shader; kept in step with the mesh's own by `wear`. */
|
||||
texture: Texture = Texture.WHITE;
|
||||
@@ -58,12 +125,7 @@ class GraphShader extends Shader {
|
||||
readonly assetId: string,
|
||||
compiled: CompiledShader,
|
||||
) {
|
||||
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;
|
||||
const group = uniformGroupOf(compiled);
|
||||
super({
|
||||
glProgram: GlProgram.from({
|
||||
vertex: SPRITE_VERTEX,
|
||||
@@ -77,18 +139,12 @@ class GraphShader extends Shader {
|
||||
},
|
||||
});
|
||||
this.fragment = compiled.fragment;
|
||||
this.names = compiled.uniforms;
|
||||
this.timed = compiled.time;
|
||||
this.compiled = compiled;
|
||||
this.uniforms = group ? group.uniforms : null;
|
||||
}
|
||||
|
||||
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;
|
||||
if (this.uniforms) readUniforms(this.uniforms, this.compiled, props, now);
|
||||
}
|
||||
|
||||
/** Pixi binds a mesh's texture only for its own shader, so a custom one takes it here. */
|
||||
@@ -131,6 +187,89 @@ export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource
|
||||
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 Shader {
|
||||
private readonly uniforms: Record<string, number> | null;
|
||||
private readonly compiled: CompiledShader;
|
||||
readonly fragment: string;
|
||||
|
||||
constructor(
|
||||
readonly assetId: string,
|
||||
compiled: CompiledShader,
|
||||
) {
|
||||
const group = uniformGroupOf(compiled);
|
||||
super({
|
||||
glProgram: GlProgram.from({
|
||||
vertex: PARTICLE_VERTEX,
|
||||
fragment: compiled.fragment,
|
||||
name: `particle-shader-${assetId}`,
|
||||
}),
|
||||
// `GlParticleContainerAdaptor` writes the field's texture and the pipe's own uniforms into
|
||||
// whichever shader the container names – but `Shader.resources` only accepts the slots its
|
||||
// constructor declared, so both have to be here, empty, for Pixi to fill them.
|
||||
resources: {
|
||||
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>' },
|
||||
},
|
||||
...(group ? { shaderUniforms: group } : {}),
|
||||
},
|
||||
});
|
||||
this.fragment = compiled.fragment;
|
||||
this.compiled = compiled;
|
||||
this.uniforms = group ? group.uniforms : null;
|
||||
}
|
||||
|
||||
read(props: PropertyBag, now: number): void {
|
||||
if (this.uniforms) readUniforms(this.uniforms, this.compiled, props, now);
|
||||
}
|
||||
}
|
||||
|
||||
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.assetId === id && worn.fragment === compiled.fragment) {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user