From d5e67cd80cdb16210ddba586d3a18e2bbda51c48 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 14 Sep 2026 11:06:45 +0200 Subject: [PATCH] A shader costs a source nothing, because a field was already one draw call (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- src/core/properties/PropertySchema.ts | 20 ++- src/core/store/serialize.ts | 14 +- src/editor/particles/ParticlePreview.ts | 2 + src/editor/viewport/WorldViewport.ts | 5 +- src/viewport/particles.ts | 7 + src/viewport/shaders.ts | 173 +++++++++++++++++++++--- tests/core/particles.test.ts | 65 ++++++++- tools/e2e/31-particles.mjs | 84 ++++++++++++ 9 files changed, 346 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b411e61b..fc45b2b8 100644 --- a/README.md +++ b/README.md @@ -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: ```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-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 0ea87eed..a9dc3b15 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -237,6 +237,8 @@ const ASSET_SCHEMAS: Record = { { key: PARTICLE_TEXTURE_KEY, label: 'Texture', kind: 'texture', default: '' }, { key: PARTICLE_COLOR_KEY, label: 'Color', kind: 'color', default: 0xffffff }, { 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: [ { @@ -297,7 +299,7 @@ export function schemaForAsset(project: Project, asset: Asset): PropertySchema { 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 { const shader = object.overrides[SHADER_KEY]; 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 === '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. - 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]; } +/** + * 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. */ export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] { const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined; diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 881261dd..e9b9cb11 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -45,6 +45,7 @@ import { isValidVariableName, type PropertyDef, type PropertySchema, + rowsNotClaimedBy, schemaFor, type StructFieldDef, variableDef, @@ -67,9 +68,11 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const shaderGraphs = new Map(); const shaderRows = new Map(); const particleGraphs = new Map(); + const wearers = new Map(); for (const a of rawAssets) { if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue; rawProps.set(a.id, asRecord(a.props)); + if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type); if (a.type === 'behavior') { const vars = normalizeVariables(a.variables); variables.set(a.id, vars); @@ -89,13 +92,18 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { 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): PropertySchema | undefined => { 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 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` diff --git a/src/editor/particles/ParticlePreview.ts b/src/editor/particles/ParticlePreview.ts index 6a464cbe..e649f0a1 100644 --- a/src/editor/particles/ParticlePreview.ts +++ b/src/editor/particles/ParticlePreview.ts @@ -8,6 +8,7 @@ import { SHEET_EDGE } from '../../core/shader/nodes'; import { type Size, Theme, Widget } from '../../ui'; import { ParticleField } from '../../viewport/particles'; import { particleLibrary } from '../../viewport/ParticleLibrary'; +import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { textureLibrary } from '../../viewport/TextureLibrary'; 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. const m = translation(this.w / 2, this.h / 2); 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()); } diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index 2a3161fc..ee64488b 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -244,7 +244,7 @@ export abstract class WorldViewport extends Widget { this.syncDecorations(item, onScreen, view.scale, extent); this.syncText(item, onScreen, view.scale); this.syncSprite(item, onScreen, size, shaders); - this.syncParticles(item, onScreen); + this.syncParticles(item, onScreen, shaders); if (!onScreen) continue; this.visibleCount++; 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 * 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); if (item.type !== 'particles' || !item.particles || !onScreen) { if (field) field.container.visible = false; @@ -310,6 +310,7 @@ export abstract class WorldViewport extends Widget { const compiled = build.compiled; 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; + field.wear(item.props, shaders); field.sync( compiled, item.props, diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts index ec528b55..633f1ce7 100644 --- a/src/viewport/particles.ts +++ b/src/viewport/particles.ts @@ -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(); } } diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index 165be9d2..64407626 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -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> | 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, + 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 | 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 | 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' }, + uColor: { value: new Color(0xffffff), type: 'vec4' }, + uRound: { value: 1, type: 'f32' }, + uResolution: { value: [0, 0], type: 'vec2' }, + }, + ...(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); diff --git a/tests/core/particles.test.ts b/tests/core/particles.test.ts index 55320828..b5f4d384 100644 --- a/tests/core/particles.test.ts +++ b/tests/core/particles.test.ts @@ -4,7 +4,7 @@ * 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 { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddNodeCommand, ConnectCommand, MoveNodeCommand } from '../../src/core/commands/graphCommands'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { findAssetOf } from '../../src/core/model/Project'; @@ -37,6 +37,7 @@ import { TIME_SCALE_KEY, } from '../../src/core/properties/particles'; 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 { 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']); }); }); + +/** + * 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); + }); +}); diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index 6e73b1ac..53c0435b 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -31,6 +31,10 @@ export async function particles(t) { await sleep(120); }; 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 p = await locate(text, true); await click(p.x, p.y); @@ -609,6 +613,86 @@ export async function particles(t) { 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. const logged = await json('__gm.console.lines()'); check(