Give a shader room to draw around the picture it wears (#69)
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>
This commit is contained in:
1 parent
46c620ae3b
commit
e933fad446
20 files changed
+644
-55
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
type Expr,
|
||||
floatLiteral,
|
||||
isShaderNodeDef,
|
||||
paddingOf,
|
||||
SHADER_OUTPUT,
|
||||
SHADER_SAMPLE,
|
||||
SHADER_TIME,
|
||||
@@ -19,7 +20,9 @@ import {
|
||||
SHEET_EDGE,
|
||||
STRAIGHT_FN,
|
||||
TEXEL_ALPHA,
|
||||
TEXEL_AT,
|
||||
TEXEL_COLOR,
|
||||
texelAtFn,
|
||||
TIME_UNIFORM,
|
||||
uniformName,
|
||||
vec3Literal,
|
||||
@@ -40,6 +43,8 @@ export interface CompiledShader {
|
||||
* past the border, and the plain sheet where it cannot.
|
||||
*/
|
||||
edge: EdgeMode;
|
||||
/** Pixels of the picture the wearer draws beyond it on every side (#69). */
|
||||
padding: number;
|
||||
}
|
||||
|
||||
export interface ShaderBuild {
|
||||
@@ -47,10 +52,14 @@ export interface ShaderBuild {
|
||||
compiled: CompiledShader | null;
|
||||
/** Per node id, what keeps the graph from compiling. */
|
||||
problems: Record<string, string>;
|
||||
/** Per node id, what the graph compiles with but its author will hardly have meant. */
|
||||
hints: Record<string, string>;
|
||||
/** One line for the tab: empty once compiled. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export const PADDING_CLAMP_HINT = 'Padding with Clamp smears the border';
|
||||
|
||||
/**
|
||||
* A shader graph to the fragment of the sprite's own draw: every node reachable from the Output
|
||||
* becomes one local per output, in dependency order, and every Property a float uniform. The texel
|
||||
@@ -101,14 +110,18 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
);
|
||||
|
||||
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
|
||||
if (!output) return { compiled: null, problems, reason: 'No Output node' };
|
||||
if (!output) return { compiled: null, problems, hints: {}, reason: 'No Output node' };
|
||||
const mode = edgeOf(output.params);
|
||||
const padding = paddingOf(output.params);
|
||||
const hints: Record<string, string> =
|
||||
padding > 0 && mode === 'clamp' ? { [output.id]: PADDING_CLAMP_HINT } : {};
|
||||
const outputDef = nodeDef(SHADER_OUTPUT)!;
|
||||
const color = walk.input(output, outputDef.inputs[0]!);
|
||||
const alpha = walk.input(output, outputDef.inputs[1]!);
|
||||
|
||||
const failed = walk.firstProblem();
|
||||
if (failed !== null || !color || !alpha)
|
||||
return { compiled: null, problems, reason: failed ?? 'Cannot compile' };
|
||||
return { compiled: null, problems, hints, reason: failed ?? 'Cannot compile' };
|
||||
|
||||
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
|
||||
const time = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n));
|
||||
@@ -126,9 +139,10 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
...uniforms.map((u) => `uniform float ${uniformName(u.name)};`),
|
||||
STRAIGHT_FN,
|
||||
DIVIDE_FN,
|
||||
texelAtFn(mode),
|
||||
'void main()',
|
||||
'{',
|
||||
' vec4 texel = texture(uTexture, vTextureCoord) * vColor;',
|
||||
` vec4 texel = ${TEXEL_AT}(vTextureCoord) * vColor;`,
|
||||
` vec3 ${TEXEL_COLOR.code} = straight(texel);`,
|
||||
...lines.map((line) => ` ${line}`),
|
||||
` float alpha = clamp(${alpha.code}, 0.0, 1.0);`,
|
||||
@@ -138,12 +152,14 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
].join('\n');
|
||||
// The edge only shows where a Sample reads somewhere other than its own pixel. A graph that never
|
||||
// shifts a UV stays on the plain sheet, so the common shader costs no second copy of the picture.
|
||||
// With padding every pixel of the margin reads past the border; Transparent is cut in the
|
||||
// fragment and takes the plain sheet as well.
|
||||
const shifted = graph.nodes.some(
|
||||
(n) =>
|
||||
n.type === SHADER_SAMPLE &&
|
||||
walk.reached(n) &&
|
||||
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
|
||||
);
|
||||
const edge = shifted ? edgeOf(output.params) : SHEET_EDGE;
|
||||
return { compiled: { fragment, uniforms, time, edge }, problems, reason: '' };
|
||||
const edge = (shifted || padding > 0) && mode !== 'transparent' ? mode : SHEET_EDGE;
|
||||
return { compiled: { fragment, uniforms, time, edge, padding }, problems, hints, reason: '' };
|
||||
}
|
||||
@@ -54,8 +54,14 @@ export const STRAIGHT_FN = 'vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c
|
||||
* How the picture continues past its edge when a `Sample texture` reads outside 0..1 – a setting
|
||||
* of the Output node, because the sampler state is one per draw.
|
||||
*/
|
||||
export type EdgeMode = 'repeat' | 'mirror' | 'clamp';
|
||||
export type EdgeMode = 'repeat' | 'mirror' | 'clamp' | 'transparent';
|
||||
export const EDGE_PARAM = 'edge';
|
||||
/**
|
||||
* Room around the picture the sprite draws into as well (#69), in pixels of the picture per side –
|
||||
* a setting of the Output node, so an outline or a glow has somewhere to go.
|
||||
*/
|
||||
export const PADDING_PARAM = 'padding';
|
||||
export const MAX_PADDING = 256;
|
||||
/**
|
||||
* The address mode the shared sheet carries anyway (`TextureLibrary`): within a texel it reads
|
||||
* like clamp, and no other mode can have it without a cut of its own. Asking for it costs nothing,
|
||||
@@ -68,6 +74,7 @@ const EDGE_OPTIONS: { value: EdgeMode; label: string }[] = [
|
||||
{ value: 'clamp', label: 'Clamp' },
|
||||
{ value: 'mirror', label: 'Mirror' },
|
||||
{ value: 'repeat', label: 'Repeat' },
|
||||
{ value: 'transparent', label: 'Transparent' },
|
||||
];
|
||||
|
||||
export function edgeOf(params: PropertyBag): EdgeMode {
|
||||
@@ -75,6 +82,22 @@ export function edgeOf(params: PropertyBag): EdgeMode {
|
||||
return EDGE_OPTIONS.some((o) => o.value === value) ? (value as EdgeMode) : DEFAULT_EDGE;
|
||||
}
|
||||
|
||||
export function paddingOf(params: PropertyBag): number {
|
||||
const value = Number(params[PADDING_PARAM] ?? 0);
|
||||
return Number.isFinite(value) ? Math.min(MAX_PADDING, Math.max(0, value)) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the picture at a UV. No sampler state makes the outside empty, so *Transparent* is cut
|
||||
* here, and with it needs no copy of the picture with an address mode of its own.
|
||||
*/
|
||||
export const TEXEL_AT = 'texelAt';
|
||||
export function texelAtFn(edge: EdgeMode): string {
|
||||
return edge === 'transparent'
|
||||
? `vec4 ${TEXEL_AT}(vec2 uv) { vec2 inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); return texture(uTexture, uv) * (inside.x * inside.y); }`
|
||||
: `vec4 ${TEXEL_AT}(vec2 uv) { return texture(uTexture, uv); }`;
|
||||
}
|
||||
|
||||
const NUMBER_STEP = 0.05;
|
||||
|
||||
const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' });
|
||||
@@ -182,10 +205,13 @@ export const ShaderOutput: ShaderNodeDef = {
|
||||
label: 'Output',
|
||||
category: 'shader',
|
||||
description:
|
||||
'The color and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.',
|
||||
'The color and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it. Padding is room around the picture to draw into, in pixels of the picture.',
|
||||
inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }],
|
||||
outputs: [],
|
||||
params: [{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS }],
|
||||
params: [
|
||||
{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS },
|
||||
{ key: PADDING_PARAM, label: 'Padding', kind: 'number', default: 0, min: 0, max: MAX_PADDING, step: 1 },
|
||||
],
|
||||
unfed: { color: TEXEL_COLOR, alpha: TEXEL_ALPHA },
|
||||
emit: () => ({}),
|
||||
};
|
||||
@@ -216,7 +242,7 @@ export const ShaderSample: ShaderNodeDef = {
|
||||
params: [],
|
||||
unfed: { u: OWN_U, v: OWN_V },
|
||||
emit: ({ u, v }) => {
|
||||
const sample = `texture(uTexture, vec2(${u!.code}, ${v!.code})) * vColor`;
|
||||
const sample = `${TEXEL_AT}(vec2(${u!.code}, ${v!.code})) * vColor`;
|
||||
return {
|
||||
color: { code: `straight(${sample})`, type: 'vec3' },
|
||||
alpha: { code: `(${sample}).a`, type: 'float' },
|
||||
|
||||
@@ -279,12 +279,12 @@ export class GraphCanvas extends Widget {
|
||||
const seen = new Set<string>();
|
||||
const lookup = graphLookup(this.state.project);
|
||||
// In a compiled graph a node's problem is the compiler's, not a call's.
|
||||
const compilerProblems =
|
||||
asset.type === 'shader'
|
||||
? shaderLibrary.buildFor(asset).problems
|
||||
: asset.type === 'particles'
|
||||
? particleLibrary.buildFor(asset).problems
|
||||
: null;
|
||||
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset) : null;
|
||||
const compilerProblems = shaderBuild
|
||||
? { ...shaderBuild.hints, ...shaderBuild.problems }
|
||||
: asset.type === 'particles'
|
||||
? particleLibrary.buildFor(asset).problems
|
||||
: null;
|
||||
for (const node of asset.graph.nodes) {
|
||||
const resolved = resolveDef(node, asset.graph, lookup);
|
||||
if (!resolved) continue;
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
@@ -89,11 +90,12 @@ export class ParticlePreview extends Widget {
|
||||
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 texture = ref ? textureLibrary.get(ref, SHEET_EDGE) : null;
|
||||
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, shaderLibrary.source(this.state.project));
|
||||
this.field.wear(props, shaders, texture);
|
||||
this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, particleLibrary.now());
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import { type EdgeMode, SHEET_EDGE } from '../../core/shader/nodes';
|
||||
import { crisp, hairline, type Size, Theme, Widget } from '../../ui';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { syncShader } from '../../viewport/shaders';
|
||||
import { disposeSpriteMesh, newSpriteMesh } from '../../viewport/symbols';
|
||||
import { disposeSpriteMesh, newSpriteMesh, syncQuadPadding } from '../../viewport/symbols';
|
||||
import { quadPadding } from '../../viewport/quadPadding';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import type { EditorState } from '../state';
|
||||
import { type PreviewPicture, TEST_IMAGE_SIZE, testImagePixels } from './previewPicture';
|
||||
@@ -92,17 +93,29 @@ export class ShaderPreview extends Widget {
|
||||
this.mesh.visible = this.shader !== null;
|
||||
if (!this.shader) return;
|
||||
const source = { ...shaderLibrary.source(this.state.project), now: this.time };
|
||||
const edge = source.compiled(this.shader.id)?.edge ?? SHEET_EDGE;
|
||||
const worn = source.compiled(this.shader.id);
|
||||
const edge = worn?.edge ?? SHEET_EDGE;
|
||||
const picture = this.picture;
|
||||
const w = picture?.w ?? TEST_IMAGE_SIZE;
|
||||
const h = picture?.h ?? TEST_IMAGE_SIZE;
|
||||
const zoom = Math.max(0, Math.min((this.w - 2 * MARGIN) / w, (this.h - 2 * MARGIN) / h, MAX_ZOOM));
|
||||
// A sprite whose picture is still loading shows its plain colour, like the scene does.
|
||||
const texture = picture
|
||||
? ((picture.texture && textureLibrary.get(picture.texture, edge)) ?? null)
|
||||
: testTexture(edge);
|
||||
// The shader's padding in pixels of the picture, as the scene counts it; the panel keeps room for it.
|
||||
const pad = quadPadding(worn?.padding ?? 0, texture?.width ?? w, texture?.height ?? h);
|
||||
const zoom = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
(this.w - 2 * MARGIN) / (w * (1 + 2 * (pad?.u ?? 0))),
|
||||
(this.h - 2 * MARGIN) / (h * (1 + 2 * (pad?.v ?? 0))),
|
||||
MAX_ZOOM,
|
||||
),
|
||||
);
|
||||
this.mesh.position.set(Math.round(this.w / 2), Math.round(this.h / 2));
|
||||
this.mesh.scale.set(w * zoom, h * zoom);
|
||||
// A sprite whose picture is still loading shows its plain colour, like the scene does.
|
||||
this.mesh.texture = picture
|
||||
? ((picture.texture && textureLibrary.get(picture.texture, edge)) ?? Texture.WHITE)
|
||||
: testTexture(edge);
|
||||
syncQuadPadding(this.mesh, worn?.padding ?? 0, texture?.width ?? w, texture?.height ?? h);
|
||||
this.mesh.texture = texture ?? Texture.WHITE;
|
||||
this.mesh.tint = picture?.tint ?? 0xffffff;
|
||||
syncShader(this.mesh, { ...this.values, [SHADER_KEY]: this.shader.id }, source);
|
||||
}
|
||||
@@ -114,7 +127,12 @@ export class ShaderPreview extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_ADDRESS = { clamp: 'clamp-to-edge', mirror: 'mirror-repeat', repeat: 'repeat' } as const;
|
||||
const TEST_ADDRESS = {
|
||||
clamp: 'clamp-to-edge',
|
||||
mirror: 'mirror-repeat',
|
||||
repeat: 'repeat',
|
||||
transparent: 'mirror-repeat',
|
||||
} as const;
|
||||
const testTextures = new Map<EdgeMode, Texture>();
|
||||
|
||||
/** The test image, one copy per edge mode: the sampler decides what a UV past the edge reads. */
|
||||
|
||||
@@ -21,6 +21,8 @@ export const ADDRESS_MODE: Record<EdgeMode, 'repeat' | 'mirror-repeat' | 'clamp-
|
||||
mirror: EDGE_MODE,
|
||||
repeat: 'repeat',
|
||||
clamp: 'clamp-to-edge',
|
||||
// Never asked for: the fragment cuts the outside away itself, on the plain sheet.
|
||||
transparent: EDGE_MODE,
|
||||
};
|
||||
|
||||
/** Keeps uploaded images on the GPU; callers never await and get a redraw when the picture lands. */
|
||||
|
||||
@@ -329,8 +329,10 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
||||
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
|
||||
// A switched-off object is not drawn at all, in the scene view as in the game; it is
|
||||
// selected and switched back on from the hierarchy panel. A source is never culled by its
|
||||
// own little square: its particles may be anywhere on screen while it is not.
|
||||
const onScreen = item.enabled && (item.type === 'particles' || rectsIntersect(extent, bounds));
|
||||
// own little square: its particles may be anywhere on screen while it is not. Nor is a wearer
|
||||
// of a padded shader, which draws past its rectangle (#69).
|
||||
const unbound = item.type === 'particles' || (wornShader(item.props, shaders)?.padding ?? 0) > 0;
|
||||
const onScreen = item.enabled && (unbound || rectsIntersect(extent, bounds));
|
||||
let node = this.nodes.get(item.id);
|
||||
if (!node) {
|
||||
node = new Graphics();
|
||||
@@ -342,7 +344,7 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
||||
}
|
||||
const hidden = this.hidesShape(item);
|
||||
node.visible = onScreen && !hidden;
|
||||
if (item.enabled && !hidden) culled.push({ node, extent: item.type === 'particles' ? null : extent });
|
||||
if (item.enabled && !hidden) culled.push({ node, extent: unbound ? null : extent });
|
||||
node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none';
|
||||
this.syncDecorations(item, onScreen, view.scale, extent);
|
||||
this.syncText(item, onScreen, view.scale, shaders);
|
||||
@@ -485,8 +487,9 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
|
||||
const build = particleLibrary.buildFor(item.particles);
|
||||
const compiled = build.compiled;
|
||||
const params = compiled ? compiled.params.map((row) => Number(item.props[row.key] ?? row.default)) : [];
|
||||
const texture = item.texture ? textureLibrary.get(item.texture, SHEET_EDGE) : null;
|
||||
field.wear(item.props, shaders);
|
||||
const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE;
|
||||
const texture = item.texture ? textureLibrary.get(item.texture, edge) : null;
|
||||
field.wear(item.props, shaders, texture);
|
||||
field.sync(
|
||||
compiled,
|
||||
item.props,
|
||||
|
||||
@@ -64,6 +64,23 @@ export function sliceSpans(
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ring of `padding` source pixels around the parts (#69): as wide as a border pixel is drawn,
|
||||
* and reading that far past the frame, so a shader finds the same room there as around a quad.
|
||||
*/
|
||||
export function paddedSpans(
|
||||
spans: Span[],
|
||||
target: number,
|
||||
source: number,
|
||||
padding: number,
|
||||
scale = 1,
|
||||
): Span[] {
|
||||
if (!(padding > 0) || spans.length === 0) return spans;
|
||||
const room = padding * scale;
|
||||
const t = padding / source;
|
||||
return [{ p0: -room, p1: 0, t0: -t, t1: 0 }, ...spans, { p0: target, p1: target + room, t0: 1, t1: 1 + t }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions in 0..1 of the sprite's box, as the unit quad they replace: the mesh carries the
|
||||
* sprite's size in its scale, so the geometry must not.
|
||||
@@ -72,10 +89,23 @@ export function sliceGeometry(
|
||||
slice: NineSlice,
|
||||
w: number,
|
||||
h: number,
|
||||
padding = 0,
|
||||
): { positions: Float32Array; uvs: Float32Array; indices: Uint32Array } {
|
||||
const { border, fill, scale } = slice;
|
||||
const columns = sliceSpans(w, slice.sourceW, border.left, border.right, fill, scale);
|
||||
const rows = sliceSpans(h, slice.sourceH, border.top, border.bottom, fill, scale);
|
||||
const columns = paddedSpans(
|
||||
sliceSpans(w, slice.sourceW, border.left, border.right, fill, scale),
|
||||
w,
|
||||
slice.sourceW,
|
||||
padding,
|
||||
scale,
|
||||
);
|
||||
const rows = paddedSpans(
|
||||
sliceSpans(h, slice.sourceH, border.top, border.bottom, fill, scale),
|
||||
h,
|
||||
slice.sourceH,
|
||||
padding,
|
||||
scale,
|
||||
);
|
||||
const positions: number[] = [];
|
||||
const uvs: number[] = [];
|
||||
const indices: number[] = [];
|
||||
|
||||
@@ -189,8 +189,11 @@ export class ParticleField {
|
||||
}
|
||||
|
||||
/** Puts the shader the source names on every particle of this field, or takes one off (#88). */
|
||||
wear(props: PropertyBag, shaders: ShaderSource): void {
|
||||
syncParticleShader(this.container, props, shaders);
|
||||
wear(props: PropertyBag, shaders: ShaderSource, texture: Texture | null): void {
|
||||
// Without a picture a particle is its plain square, and a pixel is one unit of its size.
|
||||
const size = Math.max(0.01, numberOf(props[PARTICLE_SIZE_KEY], 16));
|
||||
const picture = texture ? { w: texture.width, h: texture.height } : { w: size, h: size };
|
||||
syncParticleShader(this.container, props, shaders, picture);
|
||||
}
|
||||
|
||||
/** Particles are reused rather than rebuilt: a stream of 500 would otherwise allocate 500 a frame. */
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The room a shader's Padding adds around what wears it (#69): no Pixi and no project, so it stays
|
||||
* unit-testable. The quad grows and its UVs grow with it, so the picture keeps its place and size
|
||||
* and the margin reads past its border – what is there is the Output's Edge.
|
||||
*/
|
||||
|
||||
/** The margin per side as a fraction of the picture: what both the positions and the UVs grow by. */
|
||||
export interface QuadPadding {
|
||||
u: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `padding` pixels of a picture `w`×`h` pixels big. A wearer without a picture hands in its own
|
||||
* size, so one pixel is one of its units.
|
||||
*/
|
||||
export function quadPadding(padding: number, w: number, h: number): QuadPadding | null {
|
||||
if (!(padding > 0) || !(w > 0) || !(h > 0)) return null;
|
||||
return { u: padding / w, v: padding / h };
|
||||
}
|
||||
|
||||
/** The unit quad with that margin around it; 0..1 is still exactly the picture. */
|
||||
export function paddedQuad(pad: QuadPadding): {
|
||||
positions: Float32Array;
|
||||
uvs: Float32Array;
|
||||
indices: Uint32Array;
|
||||
} {
|
||||
const corners = [-pad.u, -pad.v, 1 + pad.u, -pad.v, 1 + pad.u, 1 + pad.v, -pad.u, 1 + pad.v];
|
||||
return {
|
||||
positions: new Float32Array(corners),
|
||||
uvs: new Float32Array(corners),
|
||||
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
|
||||
};
|
||||
}
|
||||
|
||||
export function paddingKey(pad: QuadPadding | null): string {
|
||||
return pad ? `${pad.u},${pad.v}` : '';
|
||||
}
|
||||
+29
-4
@@ -13,6 +13,7 @@ import type { PropertyBag } from '../core/model/Properties';
|
||||
import { shaderOf } from '../core/properties/shaders';
|
||||
import type { CompiledShader } from '../core/shader/compile';
|
||||
import { TIME_UNIFORM, uniformName } from '../core/shader/nodes';
|
||||
import { type QuadPadding, quadPadding } from './quadPadding';
|
||||
|
||||
/** Where a node's shader comes from: the compiled form of the asset the sprite names, if it compiles. */
|
||||
export interface ShaderSource {
|
||||
@@ -54,7 +55,9 @@ void main()
|
||||
/**
|
||||
* Pixi's own particle vertex stage in GLSL ES 3.00, which is what the compiled fragment is written
|
||||
* in: `attribute`/`varying` become `in`/`out` and `vUV` takes the name the fragment reads. The
|
||||
* rounding is kept so a particle sits on the same pixel shaded as unshaded.
|
||||
* rounding is kept so a particle sits on the same pixel shaded as unshaded. `uPad` is the shader's
|
||||
* padding as a fraction of the picture (#69): a particle is anchored at its centre, so its corners
|
||||
* and its UVs grow about it by the same factor.
|
||||
*/
|
||||
const PARTICLE_VERTEX = `
|
||||
in vec2 aVertex;
|
||||
@@ -68,6 +71,7 @@ uniform mat3 uTranslationMatrix;
|
||||
uniform float uRound;
|
||||
uniform vec2 uResolution;
|
||||
uniform vec4 uColor;
|
||||
uniform vec2 uPad;
|
||||
vec2 roundPixels(vec2 position, vec2 targetSize)
|
||||
{
|
||||
return (floor(((position * 0.5 + 0.5) * targetSize) + 0.5) / targetSize) * 2.0 - 1.0;
|
||||
@@ -76,10 +80,12 @@ void main(void)
|
||||
{
|
||||
float c = cos(aRotation);
|
||||
float s = sin(aRotation);
|
||||
vec2 v = vec2(aVertex.x * c - aVertex.y * s, aVertex.x * s + aVertex.y * c) + aPosition;
|
||||
vec2 grow = 1.0 + 2.0 * uPad;
|
||||
vec2 corner = aVertex * grow;
|
||||
vec2 v = vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c) + aPosition;
|
||||
gl_Position = vec4((uTranslationMatrix * vec3(v, 1.0)).xy, 0.0, 1.0);
|
||||
if (uRound == 1.0) gl_Position.xy = roundPixels(gl_Position.xy, uResolution);
|
||||
vTextureCoord = aUV;
|
||||
vTextureCoord = (aUV - 0.5) * grow + 0.5;
|
||||
vColor = vec4(aColor.rgb * aColor.a, aColor.a) * uColor;
|
||||
}
|
||||
`;
|
||||
@@ -217,6 +223,8 @@ export function syncShader(mesh: Mesh, props: PropertyBag, shaders: ShaderSource
|
||||
* draw call at all: five hundred shaded particles are the one call five hundred plain ones were.
|
||||
*/
|
||||
class ParticleGraphShader extends CompiledGraphShader {
|
||||
private readonly pad: Float32Array;
|
||||
|
||||
constructor(assetId: string, compiled: CompiledShader) {
|
||||
// `GlParticleContainerAdaptor` writes the field's texture and the pipe's own uniforms into
|
||||
// whichever shader the container names, so both slots have to exist, empty, for Pixi to fill.
|
||||
@@ -233,8 +241,18 @@ class ParticleGraphShader extends CompiledGraphShader {
|
||||
uRound: { value: 1, type: 'f32' },
|
||||
uResolution: { value: [0, 0], type: 'vec2<f32>' },
|
||||
},
|
||||
padding: new UniformGroup({ uPad: { value: new Float32Array(2), type: 'vec2<f32>' } }),
|
||||
},
|
||||
);
|
||||
this.pad = (
|
||||
this.resources.padding as UniformGroup<{ uPad: { value: Float32Array; type: 'vec2<f32>' } }>
|
||||
).uniforms.uPad;
|
||||
}
|
||||
|
||||
/** The margin around every particle, from the shader's padding and the picture it is counted in. */
|
||||
grow(pad: QuadPadding | null): void {
|
||||
this.pad[0] = pad?.u ?? 0;
|
||||
this.pad[1] = pad?.v ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,23 +260,30 @@ function wornByField(container: ParticleContainer): ParticleGraphShader | null {
|
||||
return container.shader instanceof ParticleGraphShader ? container.shader : null;
|
||||
}
|
||||
|
||||
/** The compiled shader a source's particles are drawn with, if it names one that compiles. */
|
||||
/**
|
||||
* The compiled shader a source's particles are drawn with, if it names one that compiles. `picture`
|
||||
* is what its padding is counted in: the particles' picture in pixels.
|
||||
*/
|
||||
export function syncParticleShader(
|
||||
container: ParticleContainer,
|
||||
props: PropertyBag,
|
||||
shaders: ShaderSource,
|
||||
picture: { w: number; h: number },
|
||||
): void {
|
||||
const id = shaderOf(props);
|
||||
const compiled = id ? shaders.compiled(id) : null;
|
||||
const pad = compiled ? quadPadding(compiled.padding, picture.w, picture.h) : null;
|
||||
const worn = wornByField(container);
|
||||
if (worn && compiled && worn.wears(id, compiled)) {
|
||||
worn.read(props, shaders.now);
|
||||
worn.grow(pad);
|
||||
return;
|
||||
}
|
||||
disposeParticleShader(container);
|
||||
if (compiled) {
|
||||
const shader = new ParticleGraphShader(id, compiled);
|
||||
shader.read(props, shaders.now);
|
||||
shader.grow(pad);
|
||||
container.shader = shader;
|
||||
}
|
||||
}
|
||||
|
||||
+40
-10
@@ -17,6 +17,7 @@ import { closestOnSegment } from '../core/util/vec';
|
||||
import { Theme } from '../ui';
|
||||
import type { DrawItem } from './items';
|
||||
import { sliceGeometry, sliceKey } from './nineSlice';
|
||||
import { paddedQuad, paddingKey, quadPadding } from './quadPadding';
|
||||
import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders';
|
||||
import type { TextureLibrary } from './TextureLibrary';
|
||||
|
||||
@@ -73,11 +74,17 @@ export function syncSpriteMesh(
|
||||
): void {
|
||||
// Null while the picture is still loading: the box shows its plain colour until then, and once
|
||||
// the picture is there the colour tints it.
|
||||
const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE;
|
||||
const texture = item.texture ? textures.get(item.texture, edge) : null;
|
||||
const worn = wornShader(item.props, shaders);
|
||||
const texture = item.texture ? textures.get(item.texture, worn?.edge ?? SHEET_EDGE) : null;
|
||||
placeNode(mesh, item.m, size.w, size.h);
|
||||
// Only once there is a picture: slicing a placeholder would cut up the plain colour for nothing.
|
||||
syncGeometry(mesh, texture ? (item.texture?.slice ?? null) : null, size);
|
||||
syncGeometry(
|
||||
mesh,
|
||||
texture ? (item.texture?.slice ?? null) : null,
|
||||
size,
|
||||
worn?.padding ?? 0,
|
||||
texture ? { w: texture.width, h: texture.height } : size,
|
||||
);
|
||||
mesh.texture = texture ?? Texture.WHITE;
|
||||
mesh.tint = Number(item.props.color ?? 0xffffff);
|
||||
syncShader(mesh, item.props, shaders);
|
||||
@@ -88,16 +95,39 @@ export function syncSpriteMesh(
|
||||
* the mesh so nothing of this leaks into Pixi's own object, and so a mesh that loses its slice
|
||||
* goes back to the shared quad rather than keeping a private copy of it.
|
||||
*/
|
||||
const sliceKeys = new WeakMap<Mesh, string>();
|
||||
const geometryKeys = new WeakMap<Mesh, string>();
|
||||
|
||||
function syncGeometry(mesh: Mesh, slice: NineSlice | null, size: { w: number; h: number }): void {
|
||||
const key = slice ? sliceKey(slice, size.w, size.h) : '';
|
||||
if ((sliceKeys.get(mesh) ?? '') === key) return;
|
||||
/**
|
||||
* `padding` is the worn shader's, in pixels of `picture` – which without a picture is the wearer's
|
||||
* own box, so a pixel is one of its units (#69).
|
||||
*/
|
||||
function syncGeometry(
|
||||
mesh: Mesh,
|
||||
slice: NineSlice | null,
|
||||
size: { w: number; h: number },
|
||||
padding: number,
|
||||
picture: { w: number; h: number },
|
||||
): void {
|
||||
const pad = slice ? null : quadPadding(padding, picture.w, picture.h);
|
||||
const key = slice ? `${sliceKey(slice, size.w, size.h)}|+${padding}` : paddingKey(pad);
|
||||
if ((geometryKeys.get(mesh) ?? '') === key) return;
|
||||
const previous = mesh.geometry;
|
||||
mesh.geometry = slice ? new MeshGeometry(sliceGeometry(slice, size.w, size.h)) : UNIT_QUAD;
|
||||
mesh.geometry = slice
|
||||
? new MeshGeometry(sliceGeometry(slice, size.w, size.h, padding))
|
||||
: pad
|
||||
? new MeshGeometry(paddedQuad(pad))
|
||||
: UNIT_QUAD;
|
||||
if (previous !== UNIT_QUAD) previous.destroy();
|
||||
if (key) sliceKeys.set(mesh, key);
|
||||
else sliceKeys.delete(mesh);
|
||||
if (key) geometryKeys.set(mesh, key);
|
||||
else geometryKeys.delete(mesh);
|
||||
}
|
||||
|
||||
/**
|
||||
* The room a shader's Padding asks for around a plain quad: what a text and the shader tab's
|
||||
* preview wear, which have no frame to slice.
|
||||
*/
|
||||
export function syncQuadPadding(mesh: Mesh, padding: number, w: number, h: number): void {
|
||||
syncGeometry(mesh, null, { w, h }, padding, { w, h });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Theme } from '../ui';
|
||||
import { fontLibrary } from './FontLibrary';
|
||||
import type { DrawItem } from './items';
|
||||
import { type ShaderSource, syncShader, wornShader, wornShaderId } from './shaders';
|
||||
import { disposeSpriteMesh, newSpriteMesh, placeNode } from './symbols';
|
||||
import { disposeSpriteMesh, newSpriteMesh, placeNode, syncQuadPadding } from './symbols';
|
||||
import { ADDRESS_MODE } from './TextureLibrary';
|
||||
|
||||
/**
|
||||
@@ -108,8 +108,11 @@ export class TextPicture {
|
||||
else this.rasterSharp(text, family, size, color);
|
||||
}
|
||||
const texture = this.texture!;
|
||||
this.sample(texture, item.fontSmooth, wornShader(item.props, shaders)?.edge ?? SHEET_EDGE);
|
||||
const worn = wornShader(item.props, shaders);
|
||||
this.sample(texture, item.fontSmooth, worn?.edge ?? SHEET_EDGE);
|
||||
placeNode(this.mesh, item.m, this.w, this.h);
|
||||
// A text's pixel is a world unit, whatever resolution the zoom had it rastered at.
|
||||
syncQuadPadding(this.mesh, worn?.padding ?? 0, this.w, this.h);
|
||||
this.mesh.pivot.set(textAnchor(item.props), 0.5);
|
||||
this.mesh.texture = texture;
|
||||
syncShader(this.mesh, item.props, shaders);
|
||||
|
||||
Reference in new issue
Block a user