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

+55 -7
View File
@@ -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 FlatKind> = 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<K extends FlatKind = FlatKind> = {
[P in Exclude<K, 'object'>]: {
@@ -134,6 +140,7 @@ export type FlatDef<K extends FlatKind = FlatKind> = {
initial?: ValueOf<P>;
shownWith?: string;
shownFor?: ShownFor | readonly ShownFor[];
shownWhenSet?: string;
} & Extras<P>;
}[Exclude<K, 'object'>];
@@ -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<AssetType, PropertySchema> = {
{ 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;
+25
View File
@@ -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<string> = 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, {
+11 -5
View File
@@ -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`
+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();
+23 -9
View File
@@ -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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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;
}
/**
+40
View File
@@ -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)));
}
+105 -2
View File
@@ -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<Particle>({
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();
}
}
+2 -1
View File
@@ -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)) {