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
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user