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
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user