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

+147
View File
@@ -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 });
});
});