From d6d38a6422bf55199ca9267e5deb85a25039058f Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 3 Oct 2026 12:24:51 +0200 Subject: [PATCH] Lay a shader over a particle source's whole field (#170) A shader on a source runs per particle and stops at each quad, so a glow or blur across the whole cloud was not possible. A source now has a second shader row that is laid over everything it draws at once. - New rows Field shader, Field width and Field height (256) on a particle source. The size rows show only while a field shader is picked (new `shownWhenSet` on a row definition). - The particles are drawn into a RenderTexture of the frame, and a sprite mesh wearing the field shader draws that picture. The frame is centred on the source and turns and grows with it. - UV run 0 to 1 across the frame. Texture color, Texture alpha and Sample texture read the picture of the particles. - The picture has as many device pixels as the frame covers on screen, at most 2048 per side. A frame off screen draws no pass. - The field shader's Padding and Vertex output do nothing: the frame is the edge, and its corners stay put as on a nine-sliced sprite. - A name both shaders declare is one row, and both uniforms read it. - The scene view draws the frame dashed round a selected source. The Particles tab preview wears the field shader too. - `tools/perf-field-shaders.mjs` measures 1, 10 and 50 sources. On the HD 630 they ran at 60.0, 60.0 and 57.6 fps against 60.1, 60.0 and 59.2 without, one run per row. Tests: - Gate: 223 files, 3460 tests (3452 + 8), CHECK EXIT 0. - e2e 31: 43 checks, 3 of them new. Sum 1707 is computed (1704 + 3). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/properties/PropertySchema.ts | 62 ++++++++-- src/core/properties/particles.ts | 25 ++++ src/core/store/serialize.ts | 16 ++- src/editor/debug/renderHooks.ts | 3 + src/editor/particles/ParticlePreview.ts | 9 +- src/editor/viewport/SceneViewport.ts | 21 ++++ src/viewport/WorldViewport.ts | 32 +++-- src/viewport/fieldFrame.ts | 40 ++++++ src/viewport/particles.ts | 107 ++++++++++++++++- src/viewport/shaders.ts | 3 +- tests/core/particle-field-shader.test.ts | 147 +++++++++++++++++++++++ tools/e2e/31-particles.mjs | 74 ++++++++++++ tools/perf-field-shaders.mjs | 143 ++++++++++++++++++++++ 14 files changed, 656 insertions(+), 28 deletions(-) create mode 100644 src/viewport/fieldFrame.ts create mode 100644 tests/core/particle-field-shader.test.ts create mode 100644 tools/perf-field-shaders.mjs diff --git a/README.md b/README.md index 0d52a263..790c21b9 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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, 1704 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1707 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 1496e35f..6404ddca 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -82,6 +82,11 @@ import { FONT_KEY } from './fonts'; import { BURST_COUNT_KEY, EMITTING_KEY, + FIELD_HEIGHT_KEY, + FIELD_SHADER_KEY, + FIELD_SIZE_DEFAULT, + FIELD_WIDTH_KEY, + fieldShaderOf, LIFETIME_KEY, MAX_PARTICLES_KEY, MODE_KEY, @@ -122,7 +127,8 @@ type Extras = K extends 'number' * `shownWith` names a switch on the same bag; the inspector shows the row only while it is on * (see `isRowShown`), everything else – graph, defaults, files – always has it. `shownFor` does the * same by a choice: the row is shown while the enum under `key` holds one of `values` (#154); a - * list of such choices must all hold. + * list of such choices must all hold. `shownWhenSet` names a text or asset row the row belongs to: + * shown while that one is not empty (#170). */ export type FlatDef = { [P in Exclude]: { @@ -134,6 +140,7 @@ export type FlatDef = { initial?: ValueOf

; shownWith?: string; shownFor?: ShownFor | readonly ShownFor[]; + shownWhenSet?: string; } & Extras

; }[Exclude]; @@ -153,6 +160,8 @@ export function isRowShown(def: PropertyDef, values: PropertyBag): boolean { const choices: readonly ShownFor[] = 'shownFor' in def && def.shownFor ? ('key' in def.shownFor ? [def.shownFor] : def.shownFor) : []; if (choices.some(({ key, values: wanted }) => !wanted.includes(textOf(values[key] ?? '')))) return false; + if ('shownWhenSet' in def && def.shownWhenSet && textOf(values[def.shownWhenSet] ?? '') === '') + return false; return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true; } @@ -611,6 +620,26 @@ const ASSET_SCHEMAS: Record = { { 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'] }, + // Laid over the whole field at once (#170), and the frame it covers: shown only with one set. + { key: FIELD_SHADER_KEY, label: 'Field shader', kind: 'asset', default: '', assetTypes: ['shader'] }, + { + key: FIELD_WIDTH_KEY, + label: 'Field width', + kind: 'number', + default: FIELD_SIZE_DEFAULT, + min: 1, + step: 8, + shownWhenSet: FIELD_SHADER_KEY, + }, + { + key: FIELD_HEIGHT_KEY, + label: 'Field height', + kind: 'number', + default: FIELD_SIZE_DEFAULT, + min: 1, + step: 8, + shownWhenSet: FIELD_SHADER_KEY, + }, ], collider: [ { @@ -899,8 +928,12 @@ export function schemaForAsset(project: Project, asset: Asset): PropertySchema { /** 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 }); + const bag: PropertyBag = {}; + for (const key of [SHADER_KEY, FIELD_SHADER_KEY]) { + const value = object.overrides[key] ?? asset.props[key]; + if (value !== undefined) bag[key] = value; + } + return schemaForBag(project, asset, bag); } function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema { @@ -909,10 +942,12 @@ function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): Propert if (asset.type === 'variables' || asset.type === 'struct') return 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') { - const own = [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)]; - return [...own, ...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), own)]; - } + if (asset.type === 'particles') + return sourceRows( + [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)], + shaderParamRows(project, shaderOf(bag)), + shaderParamRows(project, fieldShaderOf(bag)), + ); if (asset.type === 'text') return [ ...FULL_SCHEMAS.text, @@ -932,6 +967,19 @@ export function rowsNotClaimedBy(rows: readonly PropertyDef[], taken: readonly P return rows.filter((row) => !claimed.has(row.key)); } +/** + * A source's rows: its own, then its particles' shader's, then its field shader's (#170). A name + * both shaders declare is one row, and both uniforms read it. + */ +export function sourceRows( + own: readonly PropertyDef[], + shaderRows: readonly PropertyDef[], + fieldRows: readonly PropertyDef[], +): PropertyDef[] { + const worn = [...own, ...rowsNotClaimedBy(shaderRows, own)]; + return [...worn, ...rowsNotClaimedBy(fieldRows, worn)]; +} + /** 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/properties/particles.ts b/src/core/properties/particles.ts index 18872c6e..dadbe07a 100644 --- a/src/core/properties/particles.ts +++ b/src/core/properties/particles.ts @@ -21,6 +21,14 @@ export const PARTICLE_SIZE_KEY = 'size'; export const PARTICLE_TEXTURE_KEY = 'texture'; export const PARTICLE_COLOR_KEY = 'color'; export const MOVE_WITH_SOURCE_KEY = 'moveWithSource'; +/** + * The shader laid over the whole field at once (#170), and the frame it is laid over: a rectangle + * of world units centred on the source, whose 0..1 are the shader's UV. What flies past it is cut off. + */ +export const FIELD_SHADER_KEY = 'fieldShader'; +export const FIELD_WIDTH_KEY = 'fieldWidth'; +export const FIELD_HEIGHT_KEY = 'fieldHeight'; +export const FIELD_SIZE_DEFAULT = 256; export type EmitMode = 'continuous' | 'burst'; @@ -62,8 +70,25 @@ const RESERVED_KEYS: ReadonlySet = new Set([ PARTICLE_TEXTURE_KEY, PARTICLE_COLOR_KEY, MOVE_WITH_SOURCE_KEY, + FIELD_SHADER_KEY, + FIELD_WIDTH_KEY, + FIELD_HEIGHT_KEY, ]); +/** The field shader the bag names, `''` for none. */ +export function fieldShaderOf(props: PropertyBag): string { + return textOf(props[FIELD_SHADER_KEY] ?? ''); +} + +/** The frame the field shader is laid over, in the source's own units. */ +export function fieldSizeOf(props: PropertyBag): { w: number; h: number } { + const size = (key: string) => { + const n = Number(props[key]); + return Number.isFinite(n) && n > 0 ? n : FIELD_SIZE_DEFAULT; + }; + return { w: size(FIELD_WIDTH_KEY), h: size(FIELD_HEIGHT_KEY) }; +} + /** The parameters a particle graph declares: a row on every source placed from the asset. */ export function particleParamDefs(graph: Graph): FlatDef<'number'>[] { return declaredNumberRows(graph, { diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 5a6f885c..e9526af4 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -93,10 +93,11 @@ import { type PropertySchema, rowsNotClaimedBy, schemaFor, + sourceRows, type StructFieldDef, variableDef, } from '../properties/PropertySchema'; -import { particleParamDefs } from '../properties/particles'; +import { FIELD_SHADER_KEY, particleParamDefs } from '../properties/particles'; import { SHADER_KEY, shaderParamDefs } from '../properties/shaders'; /** @@ -148,10 +149,15 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const schema = schemas.get(assetId); 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]; - const rows = shaderRows.get(typeof shader === 'string' ? shader : '') ?? []; - return [...schema, ...(wearer === 'sprite' ? rows : rowsNotClaimedBy(rows, schema))]; + const rowsOf = (key: string): PropertySchema => { + const own = bag[key]; + const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[key]; + return shaderRows.get(typeof shader === 'string' ? shader : '') ?? []; + }; + const rows = rowsOf(SHADER_KEY); + if (wearer === 'sprite') return [...schema, ...rows]; + if (wearer === 'particles') return sourceRows(schema, rows, rowsOf(FIELD_SHADER_KEY)); + return [...schema, ...rowsNotClaimedBy(rows, schema)]; }; // Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture` diff --git a/src/editor/debug/renderHooks.ts b/src/editor/debug/renderHooks.ts index 6260aa27..43210026 100644 --- a/src/editor/debug/renderHooks.ts +++ b/src/editor/debug/renderHooks.ts @@ -191,6 +191,9 @@ export function renderHooks({ app, state, editor }: HookContext) { /** What a source is drawing in the scene tab, or in the game view while playing. */ particlesOf: (objectId: string, where: 'scene' | 'game' = 'scene', limit = 8) => (where === 'game' ? gameView : viewport).particleInfo(objectId, limit), + /** The device pixels of a source's field-shader picture, null while none is drawn (#170). */ + fieldPixels: (objectId: string, where: 'scene' | 'game' = 'scene') => + (where === 'game' ? gameView : viewport).fieldPixels(objectId), /** Graphs compiled so far; a moved node must not add one (#65). */ shaderCompiles: () => shaderLibrary.compiles, /** Which views redraw every tick because a Time shader is on screen. */ diff --git a/src/editor/particles/ParticlePreview.ts b/src/editor/particles/ParticlePreview.ts index 53fcea90..f09b8c80 100644 --- a/src/editor/particles/ParticlePreview.ts +++ b/src/editor/particles/ParticlePreview.ts @@ -5,7 +5,7 @@ import type { PropertyBag } from '../../core/model/Properties'; import { resolveTexture } from '../../core/model/texture'; import { defaultsFor } from '../../core/properties/PropertySchema'; import { SHEET_EDGE } from '../../core/shader/nodes'; -import { crisp, hairline, type Size, Theme, Widget } from '../../ui'; +import { crisp, hairline, type Size, Theme, ui, Widget } from '../../ui'; import { ParticleField } from '../../viewport/particles'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; @@ -33,7 +33,7 @@ export class ParticlePreview extends Widget { constructor(private readonly state: EditorState) { super(); this.minHeight = MIN_HEIGHT; - this.content.addChild(this.field.container); + this.content.addChild(this.field.node); this.content.mask = this.clip; this.addChild(this.bg, this.content, this.clip); } @@ -83,7 +83,7 @@ export class ParticlePreview extends Widget { this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel); this.bg.rect(0, 0, this.w, hairline()).fill(c.border); this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); - this.field.container.visible = this.asset !== null; + this.field.node.visible = this.asset !== null; if (!this.asset) return; const build = particleLibrary.buildFor(this.asset); const rows = build.compiled?.params ?? []; @@ -97,6 +97,9 @@ export class ParticlePreview extends Widget { this.field.hold(this.held); this.field.wear(props, shaders, texture); this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, particleLibrary.now()); + // One panel pixel per unit; the frame may reach past the panel, which its mask cuts off. + const renderer = ui().app.renderer; + this.field.shadeField(props, shaders, m, renderer.resolution, renderer, null); } override destroy(): void { diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 83bbf3af..65f9d68d 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -8,11 +8,13 @@ import { oneStep } from '../../core/commands/CompoundCommand'; import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; import { hasSceneIcon, isSymbolType } from '../../core/model/AssetTypes'; import { opensClip, regionContains } from '../../core/properties/clip'; +import { fieldShaderOf, fieldSizeOf } from '../../core/properties/particles'; import { findObject, subtreeRoots } from '../../core/model/hierarchy'; import { objectRef, resolvedHolder } from '../../core/model/instance'; import { apply as applyPoint, applyVector, + boxCorners, type Extent, invert, linearOf, @@ -725,9 +727,28 @@ export class SceneViewport extends FreeViewport { this.placeIcons(items, view.scale); this.drawParentLinks(items, view.scale); this.drawJointLinks(items, view.scale); + this.drawFieldFrames(items, view.scale); this.syncCursors(); } + /** + * The frame a selected source's field shader covers (#170), dashed: what flies past it is cut off, + * and nothing else on screen says where that is. + */ + private drawFieldFrames(items: DrawItem[], scale: number): void { + for (const item of items) { + if (item.type !== 'particles' || !item.enabled || !this.isSelected(item.id)) continue; + if (!fieldShaderOf(item.props)) continue; + const { w, h } = fieldSizeOf(item.props); + const corners = boxCorners(item.m, w, h); + corners.forEach((from, i) => { + const to = corners[(i + 1) % 4]!; + dashedLine(this.links, from.x, from.y, to.x, to.y, 6 / scale, 6 / scale); + }); + this.links.stroke({ width: 1.5 / scale, color: Theme.colors.textMuted }); + } + } + /** A crosshair over everything while the eyedropper picks here; Pixi shows the cursor of what is hit. */ private syncCursors(): void { const picking = this.picking(); diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index 120da485..5f6acd30 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -3,10 +3,10 @@ import { isSymbolType } from '../core/model/AssetTypes'; import { type Extent, scaleOf } from '../core/model/matrix'; import type { Vec2 } from '../core/model/Properties'; import { footprintExtent, footprintOf, placementOf } from '../core/properties/bounds'; -import { rectsIntersect } from '../core/properties/camera'; +import { rectsIntersect, type WorldRect } from '../core/properties/camera'; import type { ClipRegion } from '../core/properties/clip'; import type { Project } from '../core/model/Project'; -import { type Size, Widget } from '../ui'; +import { type Size, ui, Widget } from '../ui'; import { fontLibrary } from './FontLibrary'; import { drawSlots } from './drawOrder'; import type { DrawItem } from './items'; @@ -218,7 +218,7 @@ export abstract class WorldViewport extends W drawn: { x: number; y: number; scaleX: number; alpha: number; tint: number }[]; } | null { const field = this.fields.get(objectId); - if (!field || !field.container.visible) return null; + if (!field || !field.node.visible) return null; return { count: field.count, drawn: field.container.particleChildren.slice(0, limit).map((p) => ({ @@ -350,7 +350,7 @@ export abstract class WorldViewport extends W this.syncDecorations(item, onScreen, view.scale, extent); this.syncText(item, onScreen, view.scale, shaders); this.syncSprite(item, onScreen, size, shaders); - this.syncParticles(item, onScreen, shaders); + this.syncParticles(item, onScreen, shaders, view, bounds); this.syncTilemap(item, onScreen); if (!onScreen) continue; this.visibleCount++; @@ -415,7 +415,7 @@ export abstract class WorldViewport extends W const text = this.texts.get(id); if (text) this.objects.addChild(this.clipTo(text.mesh, clip)); const field = this.fields.get(id); - if (field) this.objects.addChild(this.clipTo(field.container, clip)); + if (field) this.objects.addChild(this.clipTo(field.node, clip)); } } @@ -471,18 +471,24 @@ export abstract class WorldViewport extends W * `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, shaders: ShaderSource): void { + private syncParticles( + item: DrawItem, + onScreen: boolean, + shaders: ShaderSource, + view: View, + bounds: WorldRect, + ): void { let field = this.fields.get(item.id); if (item.type !== 'particles' || !item.particles || !onScreen) { - if (field) field.container.visible = false; + if (field) field.node.visible = false; return; } if (!field) { field = new ParticleField(); this.fields.set(item.id, field); - this.objects.addChild(field.container); + this.objects.addChild(field.node); } - field.container.visible = true; + field.node.visible = true; // A source is a moving picture by definition, so the view keeps redrawing while one is on screen. this.animated = true; const build = particleLibrary.buildFor(item.particles); @@ -501,6 +507,14 @@ export abstract class WorldViewport extends W item.emits, hashId(item.id), ); + const renderer = ui().app.renderer; + field.shadeField(item.props, shaders, item.m, view.scale * renderer.resolution, renderer, bounds); + } + + /** The device pixels of a source's field-shader picture while one is drawn (#170, test hook). */ + fieldPixels(objectId: string): { w: number; h: number } | null { + const field = this.fields.get(objectId); + return field?.node.visible ? field.fieldPixels : null; } /** diff --git a/src/viewport/fieldFrame.ts b/src/viewport/fieldFrame.ts new file mode 100644 index 00000000..0854bc90 --- /dev/null +++ b/src/viewport/fieldFrame.ts @@ -0,0 +1,40 @@ +/** + * The frame a source's field shader is laid over (#170), as numbers: how many pixels its picture + * gets, which transform takes the world's particles into that picture, and what of the world it + * covers. Pure, so the arithmetic is tested without a GPU. + */ +import { boxCorners, type Extent, extentOfPoints, invert, type Mat, multiply } from '../core/model/matrix'; + +/** The longest side the field's picture may have, whatever the zoom (the user's answer: 2048). */ +export const FIELD_MAX_PIXELS = 2048; + +export interface FieldFrame { + /** The picture's size in device pixels: the frame as large as it stands on screen, capped. */ + pixels: { w: number; h: number }; + /** World units to pixels of the picture: the frame's top left corner is 0,0. */ + toPicture: Mat; + /** The world rectangle around the frame, turn included – what decides whether it is on screen. */ + extent: Extent; +} + +/** + * `m` is the source's transform, `w`×`h` the frame in its own units centred on it, and + * `pixelsPerUnit` the device pixels one world unit covers on screen. + */ +export function fieldFrame(m: Mat, w: number, h: number, pixelsPerUnit: number): FieldFrame { + const pixels = { + w: pixelsAlong(w * Math.hypot(m.a, m.b) * pixelsPerUnit), + h: pixelsAlong(h * Math.hypot(m.c, m.d) * pixelsPerUnit), + }; + // Back into the source's own units, shift the frame's corner to the origin, then stretch to pixels. + const fit: Mat = { a: pixels.w / w, b: 0, c: 0, d: pixels.h / h, tx: pixels.w / 2, ty: pixels.h / 2 }; + return { + pixels, + toPicture: multiply(fit, invert(m)), + extent: extentOfPoints(boxCorners(m, w, h)), + }; +} + +function pixelsAlong(length: number): number { + return Math.min(FIELD_MAX_PIXELS, Math.max(1, Math.ceil(Number.isFinite(length) ? length : 1))); +} diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts index 42ec99a2..00389fee 100644 --- a/src/viewport/particles.ts +++ b/src/viewport/particles.ts @@ -1,13 +1,25 @@ -import { Particle, ParticleContainer, type Texture } from 'pixi.js'; +import { + Container, + Matrix, + type Mesh, + Particle, + ParticleContainer, + RenderTexture, + type Renderer, + type Texture, +} from 'pixi.js'; import { type CompiledParticles, emptyParticleOut, type ParticleOut } from '../core/particles/compile'; import { birthTime, type EmitterSettings, fieldTime, liveRange } from '../core/particles/emission'; import type { ParticleEnv } from '../core/particles/nodes'; +import { rectsIntersect, type WorldRect } from '../core/properties/camera'; import { type Mat, scaleOf } from '../core/model/matrix'; import { booleanOf, type PropertyBag } from '../core/model/Properties'; import { BURST_COUNT_KEY, emitModeOf, EMITTING_KEY, + fieldShaderOf, + fieldSizeOf, LIFETIME_KEY, MAX_PARTICLES_KEY, MOVE_WITH_SOURCE_KEY, @@ -17,7 +29,10 @@ import { RATE_KEY, TIME_SCALE_KEY, } from '../core/properties/particles'; -import { disposeParticleShader, type ShaderSource, syncParticleShader } from './shaders'; +import { fieldFrame } from './fieldFrame'; +import { disposeParticleShader, type ShaderSource, syncParticleShader, syncShader } from './shaders'; +import { disposeSpriteMesh, newSpriteMesh, placeNode } from './symbols'; +import { ADDRESS_MODE } from './TextureLibrary'; const DEG = Math.PI / 180; @@ -42,11 +57,19 @@ function numberOf(value: unknown, fallback: number): number { * One particle source as it is drawn: a Pixi `ParticleContainer`, which puts all of them in a single * draw call, plus the little state a formula cannot hold – when emission started and, for particles * left behind in the world, where their source stood when they were born. + * + * `node` is what a view attaches. It holds the particles themselves, or – while the source wears a + * field shader (#170) – the picture they were drawn into, with the shader laid over it. */ export class ParticleField { + readonly node = new Container(); readonly container = new ParticleContainer({ dynamicProperties: { position: true, vertex: true, rotation: true, color: true, uvs: false }, }); + /** Where the particles are drawn from while a field shader is worn: on no stage at all. */ + private readonly offstage = new Container(); + private fieldMesh: Mesh | null = null; + private fieldPicture: RenderTexture | null = null; private readonly out: ParticleOut = emptyParticleOut(); private readonly pool: Particle[] = []; /** Seconds, on the library's clock, at which this source last started emitting. */ @@ -64,6 +87,8 @@ export class ParticleField { constructor() { this.container.eventMode = 'none'; + this.node.eventMode = 'none'; + this.node.addChild(this.container); } /** Seconds the source has been emitting; -1 before it ever started. */ @@ -196,6 +221,81 @@ export class ParticleField { syncParticleShader(this.container, props, shaders, picture); } + /** + * Lays the field shader the source names over the whole field (#170): the particles are drawn into + * a picture of the frame, with as many pixels as the frame covers on screen, and the shader draws + * that picture where the frame stands. Without one – or with one that does not compile – the + * particles are drawn as they are. `bounds` is the world the view shows: a frame outside it costs + * no pass at all. Call it after `sync`, which is what moves the particles. + */ + shadeField( + props: PropertyBag, + shaders: ShaderSource, + m: Mat, + pixelsPerUnit: number, + renderer: Renderer, + bounds: WorldRect | null, + ): void { + const id = fieldShaderOf(props); + const compiled = id ? shaders.compiled(id) : null; + if (!compiled) { + this.unshade(); + return; + } + const mesh = this.fieldMesh ?? this.newFieldMesh(); + if (this.container.parent !== this.offstage) this.offstage.addChild(this.container); + const size = fieldSizeOf(props); + const frame = fieldFrame(m, size.w, size.h, pixelsPerUnit); + mesh.visible = !bounds || rectsIntersect(frame.extent, bounds); + if (!mesh.visible) return; + const previous = this.fieldPicture; + const fits = previous && previous.width === frame.pixels.w && previous.height === frame.pixels.h; + const picture = fits ? previous : RenderTexture.create({ width: frame.pixels.w, height: frame.pixels.h }); + const t = frame.toPicture; + renderer.render({ + container: this.offstage, + target: picture, + clear: true, + transform: new Matrix(t.a, t.b, t.c, t.d, t.tx, t.ty), + }); + // The picture is this field's alone, so the shader's edge is set on it rather than on a variant. + const style = picture.source.style; + const addressMode = ADDRESS_MODE[compiled.edge]; + if (style.addressMode !== addressMode) { + style.addressMode = addressMode; + style.update(); + } + placeNode(mesh, m, size.w, size.h); + mesh.texture = picture; + // Nothing to count pixels in: the frame is the edge, so neither Padding nor Vertex output moves it. + syncShader(mesh, props, shaders, null, id); + this.fieldPicture = picture; + // Only now: the shader kept the old picture bound until `syncShader` handed it the new one. + if (previous && previous !== picture) previous.destroy(true); + } + + /** The field shader's picture in device pixels while one is drawn, else null (test hook). */ + get fieldPixels(): { w: number; h: number } | null { + const picture = this.fieldPicture; + return picture && this.fieldMesh?.visible ? { w: picture.width, h: picture.height } : null; + } + + private newFieldMesh(): Mesh { + const mesh = newSpriteMesh(); + this.node.addChild(mesh); + this.fieldMesh = mesh; + return mesh; + } + + /** Back to drawing the particles themselves. */ + private unshade(): void { + if (this.container.parent !== this.node) this.node.addChild(this.container); + if (this.fieldMesh) disposeSpriteMesh(this.fieldMesh); + this.fieldMesh = null; + this.fieldPicture?.destroy(true); + this.fieldPicture = null; + } + /** 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]; @@ -212,7 +312,10 @@ export class ParticleField { } destroy(): void { + this.unshade(); disposeParticleShader(this.container); this.container.destroy(); + this.offstage.destroy(); + this.node.destroy(); } } diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index 72a1e6e4..e5d37cdb 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -270,8 +270,9 @@ export function syncShader( props: PropertyBag, shaders: ShaderSource, picture: { w: number; h: number } | null = null, + /** The shader asset to wear; a source's field names its own (#170). */ + id = shaderOf(props), ): void { - const id = shaderOf(props); const compiled = id ? shaders.compiled(id) : null; const worn = wornBy(mesh); if (worn && compiled && worn.wears(id, compiled)) { diff --git a/tests/core/particle-field-shader.test.ts b/tests/core/particle-field-shader.test.ts new file mode 100644 index 00000000..aa98f160 --- /dev/null +++ b/tests/core/particle-field-shader.test.ts @@ -0,0 +1,147 @@ +/** + * A field shader (#170) is laid over a source's whole field at once: the particles are drawn into a + * picture of a fixed frame around the source, and the shader draws that picture. These tests pin + * the rows it brings and the arithmetic of the frame; what it looks like is e2e 31. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { apply, transformMatrix } from '../../src/core/model/matrix'; +import { findAssetOf } from '../../src/core/model/Project'; +import type { SceneObject } from '../../src/core/model/SceneObject'; +import { + FIELD_HEIGHT_KEY, + FIELD_SHADER_KEY, + FIELD_WIDTH_KEY, + fieldSizeOf, + isValidParticlePropertyName, +} from '../../src/core/properties/particles'; +import { + isRowShown, + schemaFor, + schemaForAsset, + schemaForObject, +} from '../../src/core/properties/PropertySchema'; +import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders'; +import { FIELD_MAX_PIXELS, fieldFrame } from '../../src/viewport/fieldFrame'; +import { makeProject, roundTrip } from '../helpers'; + +function source() { + const f = makeProject(); + const create = new CreateAssetCommand('particles', 'Sparks'); + f.stack.execute(create); + const id = create.asset.id; + const asset = () => findAssetOf(f.project, id, 'particles')!; + const shader = (name: string, uniform: string, initial = 3) => { + const made = new CreateAssetCommand('shader', name); + f.stack.execute(made); + f.stack.execute( + new AddNodeCommand(made.asset.id, SHADER_PROPERTY, 0, 0, { + name: uniform, + default: initial, + min: 0, + max: 10, + }), + ); + return made.asset.id; + }; + const set = (key: string, value: string | number) => + f.stack.execute(new SetAssetPropertyCommand(id, key, value)); + const rows = () => schemaForAsset(f.project, asset()); + const keys = () => rows().map((d) => d.key); + return { ...f, id, asset, shader, set, rows, keys }; +} + +describe('the field shader rows', () => { + it('sit under the particles’ shader, and the frame shows only once one is picked', () => { + const own = schemaFor('particles').map((d) => d.key); + expect(own.slice(own.indexOf(SHADER_KEY))).toEqual([ + SHADER_KEY, + FIELD_SHADER_KEY, + FIELD_WIDTH_KEY, + FIELD_HEIGHT_KEY, + ]); + expect(schemaFor('particles').find((d) => d.key === FIELD_SHADER_KEY)).toMatchObject({ + label: 'Field shader', + kind: 'asset', + assetTypes: ['shader'], + default: '', + }); + const width = schemaFor('particles').find((d) => d.key === FIELD_WIDTH_KEY)!; + expect(width).toMatchObject({ label: 'Field width', default: 256 }); + expect(isRowShown(width, { [FIELD_SHADER_KEY]: '' })).toBe(false); + expect(isRowShown(width, { [FIELD_SHADER_KEY]: 'glow' })).toBe(true); + }); + + it('add the field shader’s properties, and a name both shaders declare is one row', () => { + const f = source(); + const each = f.shader('Each', 'level'); + const field = f.shader('Field', 'glow'); + f.set(FIELD_SHADER_KEY, field); + expect(f.keys().at(-1)).toBe('glow'); + f.set(SHADER_KEY, each); + expect(f.keys().slice(-2)).toEqual(['level', 'glow']); + const shared = f.shader('Also level', 'level', 7); + f.set(FIELD_SHADER_KEY, shared); + expect(f.keys().filter((k) => k === 'level')).toEqual(['level']); + // The first shader's row it is: both uniforms read the one value the source holds. + expect(f.rows().find((d) => d.key === 'level')).toMatchObject({ default: 3 }); + }); + + it('follow an object that picks a field shader of its own', () => { + const f = source(); + const field = f.shader('Field', 'glow'); + const object = { overrides: { [FIELD_SHADER_KEY]: field } } as unknown as SceneObject; + expect(schemaForObject(f.project, f.asset(), object).map((d) => d.key)).toContain('glow'); + expect(f.keys()).not.toContain('glow'); + }); + + it('carry the shader, the frame and the values through a save', () => { + const f = source(); + const field = f.shader('Field', 'glow'); + f.set(FIELD_SHADER_KEY, field); + f.set(FIELD_WIDTH_KEY, 400); + f.set('glow', 5); + const loaded = findAssetOf(roundTrip(f.project), f.id, 'particles')!; + expect(loaded.props[FIELD_SHADER_KEY]).toBe(field); + expect(fieldSizeOf(loaded.props)).toEqual({ w: 400, h: 256 }); + expect(loaded.props.glow).toBe(5); + }); + + it('are names a particle graph cannot take', () => { + for (const key of [FIELD_SHADER_KEY, FIELD_WIDTH_KEY, FIELD_HEIGHT_KEY]) + expect(isValidParticlePropertyName(key)).toBe(false); + }); +}); + +describe('the frame', () => { + it('has as many pixels as it covers on screen, its corner at the picture’s origin', () => { + const m = transformMatrix(100, 50, 0, 1, false, false); + const frame = fieldFrame(m, 256, 128, 2); + expect(frame.pixels).toEqual({ w: 512, h: 256 }); + expect(apply(frame.toPicture, { x: 100 - 128, y: 50 - 64 })).toEqual({ x: 0, y: 0 }); + expect(apply(frame.toPicture, { x: 100 + 128, y: 50 + 64 })).toEqual({ x: 512, y: 256 }); + expect(frame.extent).toEqual({ left: -28, top: -14, right: 228, bottom: 114 }); + }); + + it('turns and grows with its source', () => { + const m = transformMatrix(0, 0, 90, 2, false, false); + const frame = fieldFrame(m, 100, 50, 1); + expect(frame.pixels).toEqual({ w: 200, h: 100 }); + // The source's own +x points down the world now: the frame's right edge lies at world y 100. + const p = apply(frame.toPicture, { x: 0, y: 100 }); + expect(p.x).toBeCloseTo(200); + expect(p.y).toBeCloseTo(50); + expect(frame.extent.bottom).toBeCloseTo(100); + expect(frame.extent.right).toBeCloseTo(50); + }); + + it('keeps between one pixel and the cap, whatever the zoom', () => { + const m = transformMatrix(0, 0, 0, 1, false, false); + expect(fieldFrame(m, 256, 256, 40).pixels).toEqual({ w: FIELD_MAX_PIXELS, h: FIELD_MAX_PIXELS }); + expect(fieldFrame(m, 256, 256, 0.0001).pixels).toEqual({ w: 1, h: 1 }); + // The picture is squeezed into the cap, so the frame still spans all of it. + const capped = fieldFrame(m, 256, 256, 40); + expect(apply(capped.toPicture, { x: 128, y: 128 })).toEqual({ x: FIELD_MAX_PIXELS, y: FIELD_MAX_PIXELS }); + }); +}); diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index 0f46509d..8e14ca37 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -655,6 +655,80 @@ export async function particles(t) { __gm.state.project.assets = __gm.state.project.assets.filter( (a) => a.id !== ${JSON.stringify(SHADER)});`); + // 31v. (#170) A field shader draws the particles into a picture of a 256-unit frame round the + // source and lays the shader over that. A graph with nothing in it hands the picture through: + // at 0.3 s the burst has fallen ½·800·0.3² = 36 units, so it has to stand below the source and + // not above – a picture upside down would show it mirrored about the source. + const FIELD = 'pt-field'; + const setField = (graph) => + mutate(` + const project = __gm.state.project; + project.assets = project.assets.filter((a) => a.id !== ${JSON.stringify(FIELD)}); + if (${JSON.stringify(graph)}) project.assets.push({ + id: ${JSON.stringify(FIELD)}, type: 'shader', name: 'Field', props: {}, graph: ${JSON.stringify(graph)} }); + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + if (${JSON.stringify(graph)}) o.overrides.fieldShader = ${JSON.stringify(FIELD)}; + else delete o.overrides.fieldShader;`); + const fallen = async () => { + await at(0.3); + const pick = (dy) => json(`__gm.pixelAt(${Math.round(spotOn.x)}, ${Math.round(spotOn.y + dy * unit)})`); + return { + below: await pick(36), + above: await pick(-36), + pixels: await json(`__gm.fieldPixels(${JSON.stringify(SOURCE)})`), + }; + }; + await rewind(); + const unshaded = await fallen(); + await setField({ nodes: [{ id: 'fd-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] }); + const handedThrough = await fallen(); + const resolution = (await json('__gm.pixelGrid()')).resolution; + check( + 'a field shader that changes nothing draws the field where it was, the right way up', + unshaded.pixels === null && + colourDistance(unshaded.below, '#3cc85a') < 12 && + colourDistance(handedThrough.below, unshaded.below) < 12 && + colourDistance(handedThrough.above, unshaded.above) < 12 && + Math.abs((handedThrough.pixels?.w ?? 0) - 256 * unit * resolution) <= 2, + JSON.stringify({ unshaded, handedThrough, unit, resolution }), + ); + + // 31v-ii. (#170) The shader covers the frame, not the particles: a flat red fills it 100 units off + // the source, where no particle ever is, and stops at its edge 128 units out. + await setField({ + nodes: [ + { id: 'fd-red', type: 'ShaderColor', x: 0, y: 0, params: { value: 0xe63c3c } }, + { id: 'fd-one', type: 'ShaderNumber', x: 0, y: 80, params: { value: 1 } }, + { id: 'fd-out', type: 'ShaderOutput', x: 200, y: 0, params: {} }, + ], + edges: [ + { from: { node: 'fd-red', port: 'color' }, to: { node: 'fd-out', port: 'color' } }, + { from: { node: 'fd-one', port: 'value' }, to: { node: 'fd-out', port: 'alpha' } }, + ], + }); + await at(0.3); + const offAt = (dx, dy) => + json(`__gm.pixelAt(${Math.round(spotOn.x + dx * unit)}, ${Math.round(spotOn.y + dy * unit)})`); + const inFrame = await offAt(100, -100); + const pastFrame = await offAt(140, 0); + await shot('31-particles-field'); + check( + 'the field shader fills its whole frame, past every particle, and nothing beyond it', + colourDistance(inFrame, '#e63c3c') < 12 && colourDistance(pastFrame, '#e63c3c') > 60, + JSON.stringify({ inFrame, pastFrame }), + ); + + // 31v-iii. Taken off again, the source draws its particles itself and no picture is left. + await setField(null); + const back = await fallen(); + check( + 'without a field shader the particles are drawn as before', + back.pixels === null && + colourDistance(back.below, unshaded.below) < 12 && + colourDistance(back.above, unshaded.above) < 12, + JSON.stringify({ back, unshaded }), + ); + // 31p. The graph tab draws no Pixi complaint of its own. const logged = await json('__gm.console.lines()'); check( diff --git a/tools/perf-field-shaders.mjs b/tools/perf-field-shaders.mjs new file mode 100644 index 00000000..5522fd56 --- /dev/null +++ b/tools/perf-field-shaders.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * What a field shader costs (#170): the same scene of N particle sources drawn with and without one, + * measured as frame time in the running game. A field shader draws the source's particles into a + * picture of its frame and lays the shader over that – one extra pass per source, whose price is + * the frame's area on screen, not the number of particles in it. + * + * The field shader is a graph with nothing but its Output, which hands the picture through: both + * runs put the very same pixels on screen, so what differs is the pass alone. A graph that samples + * the picture many times (a blur) adds its own cost per pixel on top. + * + * Usage: node tools/seed-e2e.mjs && node tools/perf-field-shaders.mjs + * GM_PERF_COUNTS=... source counts (default 1,10,50), GM_PERF_FRAME=... frame edge in units + * + * It is a measuring tool, not a test: frame times depend on the machine, so CI does not run it. + * Read the rate, not the median – see `perf-shaders.mjs` for why, and for the 60 Hz cap. + */ +import { readFileSync } from 'node:fs'; +import { runChecks } from './lib/harness.mjs'; +import { PROJECTS, cliArgs } from './lib/projects.mjs'; + +const { projectId, baseUrl } = cliArgs('perf-field-shaders.mjs', PROJECTS.fixture); + +const COUNTS = (process.env.GM_PERF_COUNTS ?? '1,10,50').split(',').map(Number); +const FRAME = Number(process.env.GM_PERF_FRAME ?? 256); +const WARMUP_MS = 1500; +const SAMPLE_MS = 4000; + +const round = (x, digits = 2) => Number(x.toFixed(digits)); +const load = () => readFileSync('/proc/loadavg', 'utf8').split(' ').slice(0, 2).join(' '); + +await runChecks({ projectId, baseUrl, port: 9348, shotPrefix: 'gm-perf-field' }, async (t) => { + const { check, sleep, json, evaluate, mutate, play, stop, shot } = t; + + await t.navigate(); + + // One source asset and one shader for all of them: flipping the asset's `fieldShader` switches + // every instance at once. 50 particles a second, a second each, 24 units – a modest spark. + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'perf-source' && a.id !== 'perf-field'); + p.assets.push({ id: 'perf-field', type: 'shader', name: 'Perf field', props: {}, + graph: { nodes: [{ id: 'pf-out', type: 'ShaderOutput', x: 0, y: 0, params: {} }], edges: [] } }); + p.assets.push({ id: 'perf-source', type: 'particles', name: 'Perf source', + props: { rate: 50, lifetime: 1, size: 24, color: 0x4080ff, fieldShader: '', + fieldWidth: ${FRAME}, fieldHeight: ${FRAME} }, + graph: { nodes: [{ id: 'po-out', type: 'ParticleOutput', x: 0, y: 0, params: {} }], edges: [] } }); + `); + + /** `n` sources spread over the camera's view, so every frame is at least partly on screen. */ + const buildScene = async (n) => { + await mutate(` + const scene = __gm.state.activeScene; + const cam = scene.objects.find((o) => o.assetId === 'cam'); + const objects = [cam]; + const cols = Math.ceil(Math.sqrt(${n})); + for (let i = 0; i < ${n}; i++) { + const col = i % cols, row = Math.floor(i / cols); + objects.push({ id: 'perf-' + i, assetId: 'perf-source', name: '', + position: { x: cam.position.x - 300 + (col + 0.5) * (600 / cols), + y: cam.position.y - 160 + (row + 0.5) * (320 / cols), z: i }, + editorOffset: { x: 0, y: 0 }, overrides: {}, parentId: '', instanceEdits: {} }); + } + scene.objects = objects; + `); + }; + + const setField = (on) => + mutate( + `__gm.state.project.assets.find((a) => a.id === 'perf-source').props.fieldShader = ${on ? "'perf-field'" : "''"}`, + ); + + /** Frame times as the page itself sees them, over `SAMPLE_MS` after a warm-up. */ + const sample = async (n) => { + await sleep(WARMUP_MS); + await evaluate(`(() => { + window.__perfSamples = []; + window.__perfOn = true; + let last = performance.now(); + const tick = () => { + const now = performance.now(); + window.__perfSamples.push(now - last); + last = now; + if (window.__perfOn) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + })()`); + await sleep(SAMPLE_MS); + const frames = await json('(window.__perfOn = false, window.__perfSamples.slice(1))'); + const pictures = await json( + `Array.from({ length: ${n} }, (_, i) => __gm.fieldPixels('perf-' + i, 'game'))`, + ); + const elapsed = frames.reduce((a, b) => a + b, 0); + return { + frames: frames.length, + mean: round(elapsed / frames.length), + fps: round((frames.length * 1000) / elapsed, 1), + pictures: pictures.filter(Boolean).length, + picture: pictures.find(Boolean) ?? null, + }; + }; + + const rows = []; + for (const n of COUNTS) { + await buildScene(n); + const run = {}; + for (const on of [false, true]) { + await setField(on); + const before = load(); + await play(); + run[on ? 'on' : 'off'] = { ...(await sample(n)), load: before }; + await stop(); + } + check( + `${n} sources: a field picture each with the field shader on, none without`, + run.on.pictures === n && run.off.pictures === 0, + { on: run.on, off: run.off }, + ); + rows.push({ n, ...run }); + console.log( + ` n=${String(n).padStart(3)} off ${String(run.off.mean).padStart(6)} ms (${run.off.fps} fps)` + + ` on ${String(run.on.mean).padStart(6)} ms (${run.on.fps} fps)` + + ` picture ${JSON.stringify(run.on.picture)} load ${run.off.load} / ${run.on.load}`, + ); + } + + // The screenshot is of the last, heaviest scene with the field shader on – proof it really drew. + await setField(true); + await play(); + await sleep(500); + await shot('heaviest'); + await stop(); + + console.log(`\nframe ${FRAME}x${FRAME} units, pass-through field shader, ${SAMPLE_MS} ms per point`); + console.log('| sources | no field shader | field shader | picture |'); + console.log('| --- | --- | --- | --- |'); + for (const r of rows) + console.log( + `| ${r.n} | ${r.off.mean} ms / ${r.off.fps} fps | ${r.on.mean} ms / ${r.on.fps} fps |` + + ` ${r.on.picture ? `${r.on.picture.w}x${r.on.picture.h}` : '-'} |`, + ); + console.log(JSON.stringify(rows)); +});