A shader ran only inside the rectangle of what wears it: the quad has the size of the sprite, and outside of it no fragment runs. An outline or a glow built with Sample texture was cut off at the border. The issue still spoke of Filter.padding, but since #77 the shader is no filter any more, the limit is the geometry. - The Output node has a number Padding next to Edge: so many pixels of the picture per side the wearer draws beyond it. 0 is the default and draws as before, 256 is the most. - The quad grows and its UVs grow with it, so the picture keeps its place and size. In the margin the UVs run on below 0 and above 1. - Transparent is a fourth Edge mode: outside of the picture there is nothing. It is cut in the fragment (texelAt), so it needs no copy of the picture with an address mode of its own. - Edge holds everywhere alike, also for Texture color, Texture alpha and unconnected inputs. - Padding above 0 with Edge on Clamp puts the hint "Padding with Clamp smears the border" on the Output. The shader still compiles. A chosen Clamp and the default cannot be told apart. - All four wearers draw the margin. A sprite counts in pixels of its frame, a text in its units, a particle source grows every particle about its centre. - A nine-slice sprite gets a ring around its nine fields, as wide as so many border pixels. The fields themselves stay. - Without a picture a pixel is one unit of the wearer's own size, or of the particle size. - Particles now take the Edge mode of their shader. It had no effect on them before. - An object wearing a padded shader is never culled, or its margin would vanish at the edge of the screen. - The preview in the shader tab shows the margin and leaves room for it. - Picking, the selection frame, the size and the collider stay with the rectangle without the margin. Tests: - Gate on melektaus-atx right before this commit: 216 files, 3336 tests (3319 + 17), exit 0. - e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass. e2e 1-37,47,48,58 ran before the last change (no culling with padding): 1281 checks, all pass. - 17 new unit tests: the compiler (padding, Transparent, the hint) and the geometry (quad, nine-slice ring). - Seven new e2e checks: four at the sprite in module 28, one each for text, particles and nine-slice. - 17 unit counter-probes all fail their tests. Three browser counter-probes fail their checks (5, 3 and 1 of them). - Three screenshots looked at: the sprite with Clamp and margin in the scene, the preview in the shader tab, the text. - The e2e sum of 1687 is counted (1680 + 7), the full suite and the smokes did not run. - Not seen: the hint at the node in a picture, particles and nine-slice in a picture, the game view while playing, sprites without a picture, vector and pixel art pictures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
4.8 KiB
TypeScript
120 lines
4.8 KiB
TypeScript
import { Container, Graphics, Texture } from 'pixi.js';
|
||
import type { ParticlesAsset } from '../../core/model/Asset';
|
||
import { translation } from '../../core/model/matrix';
|
||
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 { ParticleField } from '../../viewport/particles';
|
||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||
import { wornShader } from '../../viewport/shaders';
|
||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||
import type { EditorState } from '../state';
|
||
|
||
const MIN_HEIGHT = 40;
|
||
|
||
/**
|
||
* The asset's own particles, emitted from the middle of the panel by a source that wears the asset's
|
||
* own rows. It is the one place a formula graph can be judged: the numbers say nothing. What it is
|
||
* not is an instance – a source placed in a scene may override every one of those rows, and the tab
|
||
* has no way to know which of them is meant.
|
||
*/
|
||
export class ParticlePreview extends Widget {
|
||
private readonly bg = crisp(new Graphics());
|
||
private readonly content = new Container();
|
||
private readonly clip = new Graphics();
|
||
private readonly field = new ParticleField();
|
||
private asset: ParticlesAsset | null = null;
|
||
/** Null: the preview runs on the library clock. A number: it stands at that second. */
|
||
private held: number | null = null;
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super();
|
||
this.minHeight = MIN_HEIGHT;
|
||
this.content.addChild(this.field.container);
|
||
this.content.mask = this.clip;
|
||
this.addChild(this.bg, this.content, this.clip);
|
||
}
|
||
|
||
override measure(): Size {
|
||
return { w: Math.max(this.minWidth, 100), h: Math.max(this.minHeight, MIN_HEIGHT) };
|
||
}
|
||
|
||
show(asset: ParticlesAsset | null): void {
|
||
this.asset = asset;
|
||
this.invalidate();
|
||
}
|
||
|
||
/** Seconds the shown source has been emitting, whether held or running. */
|
||
get time(): number {
|
||
return this.held ?? Math.max(0, this.field.elapsed(particleLibrary.now()));
|
||
}
|
||
|
||
get holding(): boolean {
|
||
return this.held !== null;
|
||
}
|
||
|
||
hold(at: number | null): void {
|
||
// Letting go has to move the origin, not just stop faking one: the library clock keeps counting
|
||
// under a freeze, so the panel would otherwise resume at the second it would have reached.
|
||
if (at === null && this.held !== null) this.field.rebase(particleLibrary.now(), this.held);
|
||
this.held = at;
|
||
this.invalidate();
|
||
}
|
||
|
||
/** Back to second 0 – a held panel stands there too, rather than keeping the second it was on. */
|
||
restart(): void {
|
||
this.field.restart(particleLibrary.now());
|
||
if (this.held !== null) this.held = 0;
|
||
this.invalidate();
|
||
}
|
||
|
||
/** How many particles the preview is drawing – what the checks read. */
|
||
get count(): number {
|
||
return this.field.count;
|
||
}
|
||
|
||
override redraw(): void {
|
||
const c = Theme.colors;
|
||
// A ground of its own and a rule along the top: on the graph's own background the strip was
|
||
// invisible as a thing, and a particle standing still read as a node that had lost its box.
|
||
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;
|
||
if (!this.asset) return;
|
||
const build = particleLibrary.buildFor(this.asset);
|
||
const rows = build.compiled?.params ?? [];
|
||
const props = previewProps(this.asset, rows);
|
||
const params = rows.map((row) => Number(props[row.key] ?? row.default));
|
||
const ref = resolveTexture(this.state.project, props);
|
||
const shaders = shaderLibrary.source(this.state.project);
|
||
const texture = ref ? textureLibrary.get(ref, wornShader(props, shaders)?.edge ?? SHEET_EDGE) : null;
|
||
// The source sits in the middle of the panel; a fountain then has room above and below it.
|
||
const m = translation(this.w / 2, this.h / 2);
|
||
this.field.hold(this.held);
|
||
this.field.wear(props, shaders, texture);
|
||
this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, particleLibrary.now());
|
||
}
|
||
|
||
override destroy(): void {
|
||
this.field.destroy();
|
||
super.destroy();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A source as the asset alone describes it: the rows the asset carries, over the type's defaults for
|
||
* the ones it does not, with the graph's own parameters at their declared defaults underneath.
|
||
*/
|
||
function previewProps(
|
||
asset: ParticlesAsset,
|
||
params: readonly { key: string; default: number }[],
|
||
): PropertyBag {
|
||
const props = defaultsFor('particles');
|
||
for (const row of params) props[row.key] = row.default;
|
||
return { ...props, ...asset.props };
|
||
}
|