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>
This commit is contained in:
1 parent
bf27e42e8a
commit
d6d38a6422
14 files changed
+656
-28
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ import { FONT_KEY } from './fonts';
|
|||||||
import {
|
import {
|
||||||
BURST_COUNT_KEY,
|
BURST_COUNT_KEY,
|
||||||
EMITTING_KEY,
|
EMITTING_KEY,
|
||||||
|
FIELD_HEIGHT_KEY,
|
||||||
|
FIELD_SHADER_KEY,
|
||||||
|
FIELD_SIZE_DEFAULT,
|
||||||
|
FIELD_WIDTH_KEY,
|
||||||
|
fieldShaderOf,
|
||||||
LIFETIME_KEY,
|
LIFETIME_KEY,
|
||||||
MAX_PARTICLES_KEY,
|
MAX_PARTICLES_KEY,
|
||||||
MODE_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
|
* `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
|
* (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
|
* 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> = {
|
export type FlatDef<K extends FlatKind = FlatKind> = {
|
||||||
[P in Exclude<K, 'object'>]: {
|
[P in Exclude<K, 'object'>]: {
|
||||||
@@ -134,6 +140,7 @@ export type FlatDef<K extends FlatKind = FlatKind> = {
|
|||||||
initial?: ValueOf<P>;
|
initial?: ValueOf<P>;
|
||||||
shownWith?: string;
|
shownWith?: string;
|
||||||
shownFor?: ShownFor | readonly ShownFor[];
|
shownFor?: ShownFor | readonly ShownFor[];
|
||||||
|
shownWhenSet?: string;
|
||||||
} & Extras<P>;
|
} & Extras<P>;
|
||||||
}[Exclude<K, 'object'>];
|
}[Exclude<K, 'object'>];
|
||||||
|
|
||||||
@@ -153,6 +160,8 @@ export function isRowShown(def: PropertyDef, values: PropertyBag): boolean {
|
|||||||
const choices: readonly ShownFor[] =
|
const choices: readonly ShownFor[] =
|
||||||
'shownFor' in def && def.shownFor ? ('key' in def.shownFor ? [def.shownFor] : def.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 (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;
|
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 },
|
{ 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`.
|
// 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'] },
|
{ 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: [
|
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. */
|
/** 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 {
|
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
|
||||||
const shader = object.overrides[SHADER_KEY];
|
const bag: PropertyBag = {};
|
||||||
return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
|
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 {
|
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 === 'variables' || asset.type === 'struct') return asset.variables;
|
||||||
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
|
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.
|
// 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') {
|
if (asset.type === 'particles')
|
||||||
const own = [...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)];
|
return sourceRows(
|
||||||
return [...own, ...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), own)];
|
[...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)],
|
||||||
}
|
shaderParamRows(project, shaderOf(bag)),
|
||||||
|
shaderParamRows(project, fieldShaderOf(bag)),
|
||||||
|
);
|
||||||
if (asset.type === 'text')
|
if (asset.type === 'text')
|
||||||
return [
|
return [
|
||||||
...FULL_SCHEMAS.text,
|
...FULL_SCHEMAS.text,
|
||||||
@@ -932,6 +967,19 @@ export function rowsNotClaimedBy(rows: readonly PropertyDef[], taken: readonly P
|
|||||||
return rows.filter((row) => !claimed.has(row.key));
|
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. */
|
/** The rows a shader asset adds to the sprites wearing it; a dangling id adds none. */
|
||||||
export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] {
|
export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] {
|
||||||
const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined;
|
const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined;
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export const PARTICLE_SIZE_KEY = 'size';
|
|||||||
export const PARTICLE_TEXTURE_KEY = 'texture';
|
export const PARTICLE_TEXTURE_KEY = 'texture';
|
||||||
export const PARTICLE_COLOR_KEY = 'color';
|
export const PARTICLE_COLOR_KEY = 'color';
|
||||||
export const MOVE_WITH_SOURCE_KEY = 'moveWithSource';
|
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';
|
export type EmitMode = 'continuous' | 'burst';
|
||||||
|
|
||||||
@@ -62,8 +70,25 @@ const RESERVED_KEYS: ReadonlySet<string> = new Set([
|
|||||||
PARTICLE_TEXTURE_KEY,
|
PARTICLE_TEXTURE_KEY,
|
||||||
PARTICLE_COLOR_KEY,
|
PARTICLE_COLOR_KEY,
|
||||||
MOVE_WITH_SOURCE_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. */
|
/** The parameters a particle graph declares: a row on every source placed from the asset. */
|
||||||
export function particleParamDefs(graph: Graph): FlatDef<'number'>[] {
|
export function particleParamDefs(graph: Graph): FlatDef<'number'>[] {
|
||||||
return declaredNumberRows(graph, {
|
return declaredNumberRows(graph, {
|
||||||
|
|||||||
@@ -93,10 +93,11 @@ import {
|
|||||||
type PropertySchema,
|
type PropertySchema,
|
||||||
rowsNotClaimedBy,
|
rowsNotClaimedBy,
|
||||||
schemaFor,
|
schemaFor,
|
||||||
|
sourceRows,
|
||||||
type StructFieldDef,
|
type StructFieldDef,
|
||||||
variableDef,
|
variableDef,
|
||||||
} from '../properties/PropertySchema';
|
} from '../properties/PropertySchema';
|
||||||
import { particleParamDefs } from '../properties/particles';
|
import { FIELD_SHADER_KEY, particleParamDefs } from '../properties/particles';
|
||||||
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,10 +149,15 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
const schema = schemas.get(assetId);
|
const schema = schemas.get(assetId);
|
||||||
const wearer = wearers.get(assetId);
|
const wearer = wearers.get(assetId);
|
||||||
if (!schema || !wearer) return schema;
|
if (!schema || !wearer) return schema;
|
||||||
const own = bag[SHADER_KEY];
|
const rowsOf = (key: string): PropertySchema => {
|
||||||
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY];
|
const own = bag[key];
|
||||||
const rows = shaderRows.get(typeof shader === 'string' ? shader : '') ?? [];
|
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[key];
|
||||||
return [...schema, ...(wearer === 'sprite' ? rows : rowsNotClaimedBy(rows, schema))];
|
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`
|
// Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture`
|
||||||
|
|||||||
@@ -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. */
|
/** 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) =>
|
particlesOf: (objectId: string, where: 'scene' | 'game' = 'scene', limit = 8) =>
|
||||||
(where === 'game' ? gameView : viewport).particleInfo(objectId, limit),
|
(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). */
|
/** Graphs compiled so far; a moved node must not add one (#65). */
|
||||||
shaderCompiles: () => shaderLibrary.compiles,
|
shaderCompiles: () => shaderLibrary.compiles,
|
||||||
/** Which views redraw every tick because a Time shader is on screen. */
|
/** Which views redraw every tick because a Time shader is on screen. */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { PropertyBag } from '../../core/model/Properties';
|
|||||||
import { resolveTexture } from '../../core/model/texture';
|
import { resolveTexture } from '../../core/model/texture';
|
||||||
import { defaultsFor } from '../../core/properties/PropertySchema';
|
import { defaultsFor } from '../../core/properties/PropertySchema';
|
||||||
import { SHEET_EDGE } from '../../core/shader/nodes';
|
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 { ParticleField } from '../../viewport/particles';
|
||||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||||
@@ -33,7 +33,7 @@ export class ParticlePreview extends Widget {
|
|||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
super();
|
super();
|
||||||
this.minHeight = MIN_HEIGHT;
|
this.minHeight = MIN_HEIGHT;
|
||||||
this.content.addChild(this.field.container);
|
this.content.addChild(this.field.node);
|
||||||
this.content.mask = this.clip;
|
this.content.mask = this.clip;
|
||||||
this.addChild(this.bg, this.content, 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.clear().rect(0, 0, this.w, this.h).fill(c.panel);
|
||||||
this.bg.rect(0, 0, this.w, hairline()).fill(c.border);
|
this.bg.rect(0, 0, this.w, hairline()).fill(c.border);
|
||||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
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;
|
if (!this.asset) return;
|
||||||
const build = particleLibrary.buildFor(this.asset);
|
const build = particleLibrary.buildFor(this.asset);
|
||||||
const rows = build.compiled?.params ?? [];
|
const rows = build.compiled?.params ?? [];
|
||||||
@@ -97,6 +97,9 @@ export class ParticlePreview extends Widget {
|
|||||||
this.field.hold(this.held);
|
this.field.hold(this.held);
|
||||||
this.field.wear(props, shaders, texture);
|
this.field.wear(props, shaders, texture);
|
||||||
this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, particleLibrary.now());
|
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 {
|
override destroy(): void {
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { oneStep } from '../../core/commands/CompoundCommand';
|
|||||||
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||||
import { hasSceneIcon, isSymbolType } from '../../core/model/AssetTypes';
|
import { hasSceneIcon, isSymbolType } from '../../core/model/AssetTypes';
|
||||||
import { opensClip, regionContains } from '../../core/properties/clip';
|
import { opensClip, regionContains } from '../../core/properties/clip';
|
||||||
|
import { fieldShaderOf, fieldSizeOf } from '../../core/properties/particles';
|
||||||
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
|
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
|
||||||
import { objectRef, resolvedHolder } from '../../core/model/instance';
|
import { objectRef, resolvedHolder } from '../../core/model/instance';
|
||||||
import {
|
import {
|
||||||
apply as applyPoint,
|
apply as applyPoint,
|
||||||
applyVector,
|
applyVector,
|
||||||
|
boxCorners,
|
||||||
type Extent,
|
type Extent,
|
||||||
invert,
|
invert,
|
||||||
linearOf,
|
linearOf,
|
||||||
@@ -725,9 +727,28 @@ export class SceneViewport extends FreeViewport {
|
|||||||
this.placeIcons(items, view.scale);
|
this.placeIcons(items, view.scale);
|
||||||
this.drawParentLinks(items, view.scale);
|
this.drawParentLinks(items, view.scale);
|
||||||
this.drawJointLinks(items, view.scale);
|
this.drawJointLinks(items, view.scale);
|
||||||
|
this.drawFieldFrames(items, view.scale);
|
||||||
this.syncCursors();
|
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. */
|
/** A crosshair over everything while the eyedropper picks here; Pixi shows the cursor of what is hit. */
|
||||||
private syncCursors(): void {
|
private syncCursors(): void {
|
||||||
const picking = this.picking();
|
const picking = this.picking();
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { isSymbolType } from '../core/model/AssetTypes';
|
|||||||
import { type Extent, scaleOf } from '../core/model/matrix';
|
import { type Extent, scaleOf } from '../core/model/matrix';
|
||||||
import type { Vec2 } from '../core/model/Properties';
|
import type { Vec2 } from '../core/model/Properties';
|
||||||
import { footprintExtent, footprintOf, placementOf } from '../core/properties/bounds';
|
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 { ClipRegion } from '../core/properties/clip';
|
||||||
import type { Project } from '../core/model/Project';
|
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 { fontLibrary } from './FontLibrary';
|
||||||
import { drawSlots } from './drawOrder';
|
import { drawSlots } from './drawOrder';
|
||||||
import type { DrawItem } from './items';
|
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 }[];
|
drawn: { x: number; y: number; scaleX: number; alpha: number; tint: number }[];
|
||||||
} | null {
|
} | null {
|
||||||
const field = this.fields.get(objectId);
|
const field = this.fields.get(objectId);
|
||||||
if (!field || !field.container.visible) return null;
|
if (!field || !field.node.visible) return null;
|
||||||
return {
|
return {
|
||||||
count: field.count,
|
count: field.count,
|
||||||
drawn: field.container.particleChildren.slice(0, limit).map((p) => ({
|
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.syncDecorations(item, onScreen, view.scale, extent);
|
||||||
this.syncText(item, onScreen, view.scale, shaders);
|
this.syncText(item, onScreen, view.scale, shaders);
|
||||||
this.syncSprite(item, onScreen, size, shaders);
|
this.syncSprite(item, onScreen, size, shaders);
|
||||||
this.syncParticles(item, onScreen, shaders);
|
this.syncParticles(item, onScreen, shaders, view, bounds);
|
||||||
this.syncTilemap(item, onScreen);
|
this.syncTilemap(item, onScreen);
|
||||||
if (!onScreen) continue;
|
if (!onScreen) continue;
|
||||||
this.visibleCount++;
|
this.visibleCount++;
|
||||||
@@ -415,7 +415,7 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
|||||||
const text = this.texts.get(id);
|
const text = this.texts.get(id);
|
||||||
if (text) this.objects.addChild(this.clipTo(text.mesh, clip));
|
if (text) this.objects.addChild(this.clipTo(text.mesh, clip));
|
||||||
const field = this.fields.get(id);
|
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
|
* `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.
|
* 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);
|
let field = this.fields.get(item.id);
|
||||||
if (item.type !== 'particles' || !item.particles || !onScreen) {
|
if (item.type !== 'particles' || !item.particles || !onScreen) {
|
||||||
if (field) field.container.visible = false;
|
if (field) field.node.visible = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!field) {
|
if (!field) {
|
||||||
field = new ParticleField();
|
field = new ParticleField();
|
||||||
this.fields.set(item.id, field);
|
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.
|
// A source is a moving picture by definition, so the view keeps redrawing while one is on screen.
|
||||||
this.animated = true;
|
this.animated = true;
|
||||||
const build = particleLibrary.buildFor(item.particles);
|
const build = particleLibrary.buildFor(item.particles);
|
||||||
@@ -501,6 +507,14 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
|||||||
item.emits,
|
item.emits,
|
||||||
hashId(item.id),
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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 { type CompiledParticles, emptyParticleOut, type ParticleOut } from '../core/particles/compile';
|
||||||
import { birthTime, type EmitterSettings, fieldTime, liveRange } from '../core/particles/emission';
|
import { birthTime, type EmitterSettings, fieldTime, liveRange } from '../core/particles/emission';
|
||||||
import type { ParticleEnv } from '../core/particles/nodes';
|
import type { ParticleEnv } from '../core/particles/nodes';
|
||||||
|
import { rectsIntersect, type WorldRect } from '../core/properties/camera';
|
||||||
import { type Mat, scaleOf } from '../core/model/matrix';
|
import { type Mat, scaleOf } from '../core/model/matrix';
|
||||||
import { booleanOf, type PropertyBag } from '../core/model/Properties';
|
import { booleanOf, type PropertyBag } from '../core/model/Properties';
|
||||||
import {
|
import {
|
||||||
BURST_COUNT_KEY,
|
BURST_COUNT_KEY,
|
||||||
emitModeOf,
|
emitModeOf,
|
||||||
EMITTING_KEY,
|
EMITTING_KEY,
|
||||||
|
fieldShaderOf,
|
||||||
|
fieldSizeOf,
|
||||||
LIFETIME_KEY,
|
LIFETIME_KEY,
|
||||||
MAX_PARTICLES_KEY,
|
MAX_PARTICLES_KEY,
|
||||||
MOVE_WITH_SOURCE_KEY,
|
MOVE_WITH_SOURCE_KEY,
|
||||||
@@ -17,7 +29,10 @@ import {
|
|||||||
RATE_KEY,
|
RATE_KEY,
|
||||||
TIME_SCALE_KEY,
|
TIME_SCALE_KEY,
|
||||||
} from '../core/properties/particles';
|
} 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;
|
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
|
* 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
|
* 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.
|
* 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 {
|
export class ParticleField {
|
||||||
|
readonly node = new Container();
|
||||||
readonly container = new ParticleContainer<Particle>({
|
readonly container = new ParticleContainer<Particle>({
|
||||||
dynamicProperties: { position: true, vertex: true, rotation: true, color: true, uvs: false },
|
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 out: ParticleOut = emptyParticleOut();
|
||||||
private readonly pool: Particle[] = [];
|
private readonly pool: Particle[] = [];
|
||||||
/** Seconds, on the library's clock, at which this source last started emitting. */
|
/** Seconds, on the library's clock, at which this source last started emitting. */
|
||||||
@@ -64,6 +87,8 @@ export class ParticleField {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.container.eventMode = 'none';
|
this.container.eventMode = 'none';
|
||||||
|
this.node.eventMode = 'none';
|
||||||
|
this.node.addChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seconds the source has been emitting; -1 before it ever started. */
|
/** Seconds the source has been emitting; -1 before it ever started. */
|
||||||
@@ -196,6 +221,81 @@ export class ParticleField {
|
|||||||
syncParticleShader(this.container, props, shaders, picture);
|
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. */
|
/** Particles are reused rather than rebuilt: a stream of 500 would otherwise allocate 500 a frame. */
|
||||||
private take(slot: number, texture: Texture): Particle {
|
private take(slot: number, texture: Texture): Particle {
|
||||||
let p = this.pool[slot];
|
let p = this.pool[slot];
|
||||||
@@ -212,7 +312,10 @@ export class ParticleField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
this.unshade();
|
||||||
disposeParticleShader(this.container);
|
disposeParticleShader(this.container);
|
||||||
this.container.destroy();
|
this.container.destroy();
|
||||||
|
this.offstage.destroy();
|
||||||
|
this.node.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,8 +270,9 @@ export function syncShader(
|
|||||||
props: PropertyBag,
|
props: PropertyBag,
|
||||||
shaders: ShaderSource,
|
shaders: ShaderSource,
|
||||||
picture: { w: number; h: number } | null = null,
|
picture: { w: number; h: number } | null = null,
|
||||||
|
/** The shader asset to wear; a source's field names its own (#170). */
|
||||||
|
id = shaderOf(props),
|
||||||
): void {
|
): void {
|
||||||
const id = shaderOf(props);
|
|
||||||
const compiled = id ? shaders.compiled(id) : null;
|
const compiled = id ? shaders.compiled(id) : null;
|
||||||
const worn = wornBy(mesh);
|
const worn = wornBy(mesh);
|
||||||
if (worn && compiled && worn.wears(id, compiled)) {
|
if (worn && compiled && worn.wears(id, compiled)) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -655,6 +655,80 @@ export async function particles(t) {
|
|||||||
__gm.state.project.assets = __gm.state.project.assets.filter(
|
__gm.state.project.assets = __gm.state.project.assets.filter(
|
||||||
(a) => a.id !== ${JSON.stringify(SHADER)});`);
|
(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.
|
// 31p. The graph tab draws no Pixi complaint of its own.
|
||||||
const logged = await json('__gm.console.lines()');
|
const logged = await json('__gm.console.lines()');
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
Reference in new issue
Block a user