Files
feuerwerk/src/editor/particles/ParticlePreview.ts
T
adminandClaude Opus 5.5 d6d38a6422
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m6s
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 <noreply@anthropic.com>
2026-10-03 12:24:51 +02:00

123 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Container, Graphics, Texture } from 'pixi.js';
import type { ParticlesAsset } from '../../core/model/Asset';
import { translation } from '../../core/model/matrix';
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, ui, Widget } from '../../ui';
import { ParticleField } from '../../viewport/particles';
import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { wornShader } from '../../viewport/shaders';
import { textureLibrary } from '../../viewport/TextureLibrary';
import type { EditorState } from '../state';
const MIN_HEIGHT = 40;
/**
* The asset's own particles, emitted from the middle of the panel by a source that wears the asset's
* own rows. It is the one place a formula graph can be judged: the numbers say nothing. What it is
* not is an instance – a source placed in a scene may override every one of those rows, and the tab
* has no way to know which of them is meant.
*/
export class ParticlePreview extends Widget {
private readonly bg = crisp(new Graphics());
private readonly content = new Container();
private readonly clip = new Graphics();
private readonly field = new ParticleField();
private asset: ParticlesAsset | null = null;
/** Null: the preview runs on the library clock. A number: it stands at that second. */
private held: number | null = null;
constructor(private readonly state: EditorState) {
super();
this.minHeight = MIN_HEIGHT;
this.content.addChild(this.field.node);
this.content.mask = this.clip;
this.addChild(this.bg, this.content, this.clip);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 100), h: Math.max(this.minHeight, MIN_HEIGHT) };
}
show(asset: ParticlesAsset | null): void {
this.asset = asset;
this.invalidate();
}
/** Seconds the shown source has been emitting, whether held or running. */
get time(): number {
return this.held ?? Math.max(0, this.field.elapsed(particleLibrary.now()));
}
get holding(): boolean {
return this.held !== null;
}
hold(at: number | null): void {
// Letting go has to move the origin, not just stop faking one: the library clock keeps counting
// under a freeze, so the panel would otherwise resume at the second it would have reached.
if (at === null && this.held !== null) this.field.rebase(particleLibrary.now(), this.held);
this.held = at;
this.invalidate();
}
/** Back to second 0 – a held panel stands there too, rather than keeping the second it was on. */
restart(): void {
this.field.restart(particleLibrary.now());
if (this.held !== null) this.held = 0;
this.invalidate();
}
/** How many particles the preview is drawing – what the checks read. */
get count(): number {
return this.field.count;
}
override redraw(): void {
const c = Theme.colors;
// A ground of its own and a rule along the top: on the graph's own background the strip was
// invisible as a thing, and a particle standing still read as a node that had lost its box.
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.node.visible = this.asset !== null;
if (!this.asset) return;
const build = particleLibrary.buildFor(this.asset);
const rows = build.compiled?.params ?? [];
const props = previewProps(this.asset, rows);
const params = rows.map((row) => Number(props[row.key] ?? row.default));
const ref = resolveTexture(this.state.project, props);
const shaders = shaderLibrary.source(this.state.project);
const texture = ref ? textureLibrary.get(ref, wornShader(props, shaders)?.edge ?? SHEET_EDGE) : null;
// 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, 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 {
this.field.destroy();
super.destroy();
}
}
/**
* A source as the asset alone describes it: the rows the asset carries, over the type's defaults for
* the ones it does not, with the graph's own parameters at their declared defaults underneath.
*/
function previewProps(
asset: ParticlesAsset,
params: readonly { key: string; default: number }[],
): PropertyBag {
const props = defaultsFor('particles');
for (const row of params) props[row.key] = row.default;
return { ...props, ...asset.props };
}