Lay a shader over a particle source's whole field (#170)
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m6s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 12:24:51 +02:00
1 parent bf27e42e8a
commit d6d38a6422
14 files changed
+656 -28

No files matched your search

+3
View File
@@ -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. */
+6 -3
View File
@@ -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 {
+21
View File
@@ -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();