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(