A shader costs a source nothing, because a field was already one draw call (#88)
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:
rootandClaude Opus 5 committed 2026-09-14 11:06:45 +02:00
1 parent 0e0a21dee1
commit d5e67cd80c
9 files changed
+346 -26

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 833 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 837 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+18 -2
View File
@@ -237,6 +237,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: PARTICLE_TEXTURE_KEY, label: 'Texture', kind: 'texture', default: '' }, { key: PARTICLE_TEXTURE_KEY, label: 'Texture', kind: 'texture', default: '' },
{ key: PARTICLE_COLOR_KEY, label: 'Color', kind: 'color', default: 0xffffff }, { key: PARTICLE_COLOR_KEY, label: 'Color', kind: 'color', default: 0xffffff },
{ key: MOVE_WITH_SOURCE_KEY, label: 'Move with source', kind: 'boolean', default: false }, { key: MOVE_WITH_SOURCE_KEY, label: 'Move with source', kind: 'boolean', default: false },
// Worn by every particle of the source, not by the field as a whole – see `schemaForBag`.
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
], ],
collider: [ collider: [
{ {
@@ -297,7 +299,7 @@ export function schemaForAsset(project: Project, asset: Asset): PropertySchema {
return schemaForBag(project, asset, asset.props); return schemaForBag(project, asset, asset.props);
} }
/** A placed object's rows: a sprite overriding its shader has that shader's parameters. */ /** A placed object's rows: an object overriding its shader has that shader's parameters. */
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema { export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
const shader = object.overrides[SHADER_KEY]; const shader = object.overrides[SHADER_KEY];
return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader }); return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
@@ -307,10 +309,24 @@ function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): Propert
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables]; if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))]; if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
// Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up. // Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up.
if (asset.type === 'particles') return [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)]; if (asset.type === 'particles') {
const own = [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)];
return [...own, ...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), own)];
}
return FULL_SCHEMAS[asset.type]; return FULL_SCHEMAS[asset.type];
} }
/**
* A source draws its rows from two graphs, so one name can arrive twice: `speed` on the particle
* graph and `speed` on the shader it wears. The source's own row wins and the uniform keeps its
* declared default – the shader cannot be blamed for the collision, the same one is legitimate on a
* sprite, so nothing is rejected on its side.
*/
export function rowsNotClaimedBy(rows: readonly PropertyDef[], taken: readonly PropertyDef[]): PropertyDef[] {
const claimed = new Set(taken.map((d) => d.key));
return rows.filter((row) => !claimed.has(row.key));
}
/** The rows a shader asset adds to the sprites wearing it; a dangling id adds none. */ /** The rows a shader asset adds to the sprites wearing it; a dangling id adds none. */
export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] { export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] {
const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined; const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined;
+11 -3
View File
@@ -45,6 +45,7 @@ import {
isValidVariableName, isValidVariableName,
type PropertyDef, type PropertyDef,
type PropertySchema, type PropertySchema,
rowsNotClaimedBy,
schemaFor, schemaFor,
type StructFieldDef, type StructFieldDef,
variableDef, variableDef,
@@ -67,9 +68,11 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const shaderGraphs = new Map<string, Graph>(); const shaderGraphs = new Map<string, Graph>();
const shaderRows = new Map<string, PropertyDef[]>(); const shaderRows = new Map<string, PropertyDef[]>();
const particleGraphs = new Map<string, Graph>(); const particleGraphs = new Map<string, Graph>();
const wearers = new Map<string, 'sprite' | 'particles'>();
for (const a of rawAssets) { for (const a of rawAssets) {
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
rawProps.set(a.id, asRecord(a.props)); rawProps.set(a.id, asRecord(a.props));
if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type);
if (a.type === 'behavior') { if (a.type === 'behavior') {
const vars = normalizeVariables(a.variables); const vars = normalizeVariables(a.variables);
variables.set(a.id, vars); variables.set(a.id, vars);
@@ -89,13 +92,18 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
schemas.set(a.id, schemaFor(a.type)); schemas.set(a.id, schemaFor(a.type));
} }
} }
/** A sprite's rows end with the parameters of the shader its bag names – an override's, else the asset's. */ /**
* A sprite's or source's rows end with the parameters of the shader its bag names – an override's,
* else the asset's. A source already carries its own graph's rows, so a name it has stays its own.
*/
const schemaOf = (assetId: string, bag: Record<string, unknown>): PropertySchema | undefined => { const schemaOf = (assetId: string, bag: Record<string, unknown>): PropertySchema | undefined => {
const schema = schemas.get(assetId); const schema = schemas.get(assetId);
if (!schema || schema !== schemaFor('sprite')) return schema; const wearer = wearers.get(assetId);
if (!schema || !wearer) return schema;
const own = bag[SHADER_KEY]; const own = bag[SHADER_KEY];
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY]; const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY];
return [...schema, ...(shaderRows.get(typeof shader === 'string' ? shader : '') ?? [])]; const rows = shaderRows.get(typeof shader === 'string' ? shader : '') ?? [];
return [...schema, ...(wearer === 'particles' ? rowsNotClaimedBy(rows, schema) : rows)];
}; };
// Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture` // Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture`
+2
View File
@@ -8,6 +8,7 @@ import { SHEET_EDGE } from '../../core/shader/nodes';
import { type Size, Theme, Widget } from '../../ui'; import { type Size, Theme, Widget } from '../../ui';
import { ParticleField } from '../../viewport/particles'; import { ParticleField } from '../../viewport/particles';
import { particleLibrary } from '../../viewport/ParticleLibrary'; import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { textureLibrary } from '../../viewport/TextureLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
@@ -92,6 +93,7 @@ export class ParticlePreview extends Widget {
// The source sits in the middle of the panel; a fountain then has room above and below it. // The source sits in the middle of the panel; a fountain then has room above and below it.
const m = translation(this.w / 2, this.h / 2); const m = translation(this.w / 2, this.h / 2);
this.field.hold(this.held); this.field.hold(this.held);
this.field.wear(props, shaderLibrary.source(this.state.project));
this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, particleLibrary.now()); this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, particleLibrary.now());
} }
+3 -2
View File
@@ -244,7 +244,7 @@ export abstract class WorldViewport extends Widget {
this.syncDecorations(item, onScreen, view.scale, extent); this.syncDecorations(item, onScreen, view.scale, extent);
this.syncText(item, onScreen, view.scale); this.syncText(item, onScreen, view.scale);
this.syncSprite(item, onScreen, size, shaders); this.syncSprite(item, onScreen, size, shaders);
this.syncParticles(item, onScreen); this.syncParticles(item, onScreen, shaders);
if (!onScreen) continue; if (!onScreen) continue;
this.visibleCount++; this.visibleCount++;
drawItem(node, item, size, view.scale * scaleOf(placementOf(item.type, item.m))); drawItem(node, item, size, view.scale * scaleOf(placementOf(item.type, item.m)));
@@ -292,7 +292,7 @@ export abstract class WorldViewport extends Widget {
* `objects`, so the container itself carries no transform: the source's own goes into each * `objects`, so the container itself carries no transform: the source's own goes into each
* particle, which is what lets one stay where it was born while its source flies on. * particle, which is what lets one stay where it was born while its source flies on.
*/ */
private syncParticles(item: DrawItem, onScreen: boolean): void { private syncParticles(item: DrawItem, onScreen: boolean, shaders: ShaderSource): void {
let field = this.fields.get(item.id); let field = this.fields.get(item.id);
if (item.type !== 'particles' || !item.particles || !onScreen) { if (item.type !== 'particles' || !item.particles || !onScreen) {
if (field) field.container.visible = false; if (field) field.container.visible = false;
@@ -310,6 +310,7 @@ export abstract class WorldViewport extends Widget {
const compiled = build.compiled; const compiled = build.compiled;
const params = compiled ? compiled.params.map((row) => Number(item.props[row.key] ?? row.default)) : []; const params = compiled ? compiled.params.map((row) => Number(item.props[row.key] ?? row.default)) : [];
const texture = item.texture ? textureLibrary.get(item.texture, SHEET_EDGE) : null; const texture = item.texture ? textureLibrary.get(item.texture, SHEET_EDGE) : null;
field.wear(item.props, shaders);
field.sync( field.sync(
compiled, compiled,
item.props, item.props,
+7
View File
@@ -17,6 +17,7 @@ import {
RATE_KEY, RATE_KEY,
TIME_SCALE_KEY, TIME_SCALE_KEY,
} from '../core/properties/particles'; } from '../core/properties/particles';
import { disposeParticleShader, type ShaderSource, syncParticleShader } from './shaders';
const DEG = Math.PI / 180; const DEG = Math.PI / 180;
@@ -187,6 +188,11 @@ export class ParticleField {
this.container.update(); 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. */ /** Particles are reused rather than rebuilt: a stream of 500 would otherwise allocate 500 a frame. */
private take(slot: number, texture: Texture): Particle { private take(slot: number, texture: Texture): Particle {
let p = this.pool[slot]; let p = this.pool[slot];
@@ -203,6 +209,7 @@ export class ParticleField {
} }
destroy(): void { destroy(): void {
disposeParticleShader(this.container);
this.container.destroy(); this.container.destroy();
} }
} }
+156 -17
View File
@@ -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 type { PropertyBag } from '../core/model/Properties';
import { shaderOf } from '../core/properties/shaders'; import { shaderOf } from '../core/properties/shaders';
import type { CompiledShader } from '../core/shader/compile'; 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, * 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 * 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 { class GraphShader extends Shader {
private readonly uniforms: Record<string, number> | null; private readonly uniforms: Record<string, number> | null;
private readonly names: readonly { name: string; default: number }[]; private readonly compiled: CompiledShader;
private readonly timed: boolean;
readonly fragment: string; readonly fragment: string;
/** What `Mesh.shader` asks of a shader; kept in step with the mesh's own by `wear`. */ /** What `Mesh.shader` asks of a shader; kept in step with the mesh's own by `wear`. */
texture: Texture = Texture.WHITE; texture: Texture = Texture.WHITE;
@@ -58,12 +125,7 @@ class GraphShader extends Shader {
readonly assetId: string, readonly assetId: string,
compiled: CompiledShader, compiled: CompiledShader,
) { ) {
const entries: [string, { value: number; type: 'f32' }][] = compiled.uniforms.map((u) => [ const group = uniformGroupOf(compiled);
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({ super({
glProgram: GlProgram.from({ glProgram: GlProgram.from({
vertex: SPRITE_VERTEX, vertex: SPRITE_VERTEX,
@@ -77,18 +139,12 @@ class GraphShader extends Shader {
}, },
}); });
this.fragment = compiled.fragment; this.fragment = compiled.fragment;
this.names = compiled.uniforms; this.compiled = compiled;
this.timed = compiled.time;
this.uniforms = group ? group.uniforms : null; this.uniforms = group ? group.uniforms : null;
} }
read(props: PropertyBag, now: number): void { read(props: PropertyBag, now: number): void {
if (!this.uniforms) return; if (this.uniforms) readUniforms(this.uniforms, this.compiled, props, now);
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. */ /** 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(); 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. */ /** `Mesh.destroy` only lets go of its shader, so a mesh going away releases it here. */
export function disposeShader(mesh: Mesh): void { export function disposeShader(mesh: Mesh): void {
const worn = wornBy(mesh); const worn = wornBy(mesh);
+64 -1
View File
@@ -4,7 +4,7 @@
* pin: the same second always looks the same, and the whole field is a function of the clock. * pin: the same second always looks the same, and the whole field is a function of the clock.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand, MoveNodeCommand } from '../../src/core/commands/graphCommands'; import { AddNodeCommand, ConnectCommand, MoveNodeCommand } from '../../src/core/commands/graphCommands';
import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { findAssetOf } from '../../src/core/model/Project'; import { findAssetOf } from '../../src/core/model/Project';
@@ -37,6 +37,7 @@ import {
TIME_SCALE_KEY, TIME_SCALE_KEY,
} from '../../src/core/properties/particles'; } from '../../src/core/properties/particles';
import { schemaFor, schemaForAsset } from '../../src/core/properties/PropertySchema'; import { schemaFor, schemaForAsset } from '../../src/core/properties/PropertySchema';
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
import { paletteEntries } from '../../src/editor/graph/paletteEntries'; import { paletteEntries } from '../../src/editor/graph/paletteEntries';
import { makeProject, type ProjectFixture, roundTrip } from '../helpers'; import { makeProject, type ProjectFixture, roundTrip } from '../helpers';
@@ -379,3 +380,65 @@ describe('the source rows', () => {
expect([...PARTICLE_OUTPUTS]).toEqual(['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha']); expect([...PARTICLE_OUTPUTS]).toEqual(['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha']);
}); });
}); });
/**
* A source wears a shader the way a sprite does (#88) – but it is the only asset drawing its rows
* from two graphs at once, so what a name means when both declare it is pinned here.
*/
describe('a source wearing a shader', () => {
function wearing(uniform = 'glow') {
const f = makeProject();
const p = particlesEditor(f);
const create = new CreateAssetCommand('shader', 'Glow');
f.stack.execute(create);
const shaderId = create.asset.id;
const declared = { name: uniform, default: 3, min: 0, max: 10 };
f.stack.execute(new AddNodeCommand(shaderId, SHADER_PROPERTY, 0, 0, declared));
const keys = () => schemaForAsset(f.project, p.asset()).map((d) => d.key);
const wear = () => f.stack.execute(new SetAssetPropertyCommand(p.id, SHADER_KEY, shaderId));
return { ...f, p, shaderId, keys, wear };
}
it('has the picker, and the shader’s rows once one is picked', () => {
const f = wearing();
expect(schemaFor('particles').find((d) => d.key === SHADER_KEY)).toMatchObject({
kind: 'asset',
assetTypes: ['shader'],
default: '',
});
expect(f.keys()).not.toContain('glow');
f.wear();
expect(f.keys().at(-1)).toBe('glow');
expect(schemaForAsset(f.project, f.p.asset()).at(-1)).toMatchObject({ kind: 'number', default: 3 });
});
it('keeps a name it already has: the uniform gets no second row', () => {
const f = wearing(RATE_KEY);
f.wear();
// One row named `rate`, and it is the source's own – the uniform stays on its declared default.
expect(f.keys().filter((k) => k === RATE_KEY)).toEqual([RATE_KEY]);
expect(schemaForAsset(f.project, f.p.asset()).find((d) => d.key === RATE_KEY)).toMatchObject({
label: 'Rate',
default: 20,
});
});
it('takes a name the particle graph declares, too', () => {
const f = wearing('spin');
f.p.add(PARTICLE_PROPERTY, { name: 'spin', default: 7 });
f.wear();
expect(f.keys().filter((k) => k === 'spin')).toEqual(['spin']);
expect(schemaForAsset(f.project, f.p.asset()).find((d) => d.key === 'spin')).toMatchObject({
default: 7,
});
});
it('carries the shader and its values through a save', () => {
const f = wearing();
f.wear();
f.stack.execute(new SetAssetPropertyCommand(f.p.id, 'glow', 5));
const loaded = findAssetOf(roundTrip(f.project), f.p.id, 'particles')!;
expect(loaded.props[SHADER_KEY]).toBe(f.shaderId);
expect(loaded.props.glow).toBe(5);
});
});
+84
View File
@@ -31,6 +31,10 @@ export async function particles(t) {
await sleep(120); await sleep(120);
}; };
const drawn = (limit = 8) => json(`__gm.particlesOf(${JSON.stringify(SOURCE)}, 'scene', ${limit})`); const drawn = (limit = 8) => json(`__gm.particlesOf(${JSON.stringify(SOURCE)}, 'scene', ${limit})`);
const selectSource = async () => {
await select({ kind: 'object', holderId: 'scene1', objectIds: [SOURCE], objectId: SOURCE });
await sleep(350);
};
const clickLast = async (text) => { const clickLast = async (text) => {
const p = await locate(text, true); const p = await locate(text, true);
await click(p.x, p.y); await click(p.x, p.y);
@@ -609,6 +613,86 @@ export async function particles(t) {
JSON.stringify({ linked, unlinked }), JSON.stringify({ linked, unlinked }),
); );
// 31r. (#88) A source wears a shader like a sprite does: the picker is a row of its own, and the
// shader's Property nodes become rows under it. The graph is planted rather than clicked –
// step 28 owns the shader tab; what is new here is a *source* wearing one.
const SHADER = 'pt-shader';
await mutate(`
__gm.state.project.assets.push({
id: ${JSON.stringify(SHADER)}, type: 'shader', name: 'Level', props: {},
graph: { nodes: [
{ id: 'lv-prop', type: 'ShaderProperty', x: 0, y: 0,
params: { name: 'level', default: 1, min: 0, max: 1 } },
{ id: 'lv-out', type: 'ShaderOutput', x: 200, y: 0, params: {} },
], edges: [
{ from: { node: 'lv-prop', port: 'value' }, to: { node: 'lv-out', port: 'color' } },
] },
});
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
Object.assign(o.overrides, { shader: ${JSON.stringify(SHADER)}, level: 1 });`);
await sleep(300);
await selectSource();
// The tab bar has a button reading "Shader" too, and it is drawn first: the row wanted here is the
// *last* text of that name, and it has to stand in the inspector's own column to be one.
const shaderRow = await json('__gm.inspectorField("Shader", true)');
const levelRow = await json('__gm.inspectorField("level")');
check(
'a source has the shader picker, and the shader’s own rows below it',
Boolean(shaderRow) && Boolean(levelRow) && shaderRow.left === levelRow.left && levelRow.y > shaderRow.y,
JSON.stringify({ shaderRow, levelRow }),
);
// 31s. (#88) And the particles really wear it: the fragment replaces the container's own, so the
// green the source asks for gives way to what the graph computes – white at level 1.
await rewind();
await at(0.1);
const shaded = await json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y)})`);
check(
'the shader reaches every particle, over the colour the source itself sets',
colourDistance(shaded, '#ffffff') < 12 && colourDistance(shaded, '#3cc85a') > 60,
JSON.stringify({ shaded }),
);
await shot('31-particles-shaded');
// 31t. (#88) The row is the uniform: a quarter turns the same particles a quarter grey. Read at
// the same frozen second, so nothing but the number has changed.
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
o.overrides.level = 0.25;`);
await sleep(300);
await at(0.1);
const dimmed = await json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y)})`);
check(
'the shader’s row feeds its uniform, particle by particle',
colourDistance(dimmed, '#404040') < 16,
JSON.stringify({ dimmed }),
);
// 31u. (#88) A source draws rows from two graphs, so one name can arrive twice. The source's own
// row wins and the uniform keeps its default – two rows called Rate would be one row with two
// meanings, which is #63 in another coat.
await mutate(`
const shader = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(SHADER)});
shader.graph.nodes.find((n) => n.id === 'lv-prop').params.name = 'rate';
shader.graph.edges = [];`);
await sleep(300);
await select(null);
await selectSource();
const own = await json('__gm.locateAll("Rate")');
const uniform = await json('__gm.locateAll("rate")');
check(
'a uniform named like a row of the source adds no second row of that name',
own.length === 1 && uniform.length === 0,
JSON.stringify({ own: own.length, uniform: uniform.length }),
);
await mutate(`
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)});
delete o.overrides.shader;
delete o.overrides.level;
__gm.state.project.assets = __gm.state.project.assets.filter(
(a) => a.id !== ${JSON.stringify(SHADER)});`);
await sleep(300);
// 31p. The graph tab draws no Pixi complaint of its own. // 31p. The graph tab draws no Pixi complaint of its own.
const logged = await json('__gm.console.lines()'); const logged = await json('__gm.console.lines()');
check( check(