diff --git a/README.md b/README.md index 59710ec8..fc7e0724 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1680 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1687 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 5b227b24..c3b05832 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -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; + /** Per node id, what the graph compiles with but its author will hardly have meant. */ + hints: Record; /** 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 = + 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: '' }; } diff --git a/src/core/shader/nodes.ts b/src/core/shader/nodes.ts index 2ee5ee4b..4c9d48f4 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -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' }, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index d36f12ee..e910149d 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -279,12 +279,12 @@ export class GraphCanvas extends Widget { const seen = new Set(); 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; diff --git a/src/editor/particles/ParticlePreview.ts b/src/editor/particles/ParticlePreview.ts index 69392c34..53fcea90 100644 --- a/src/editor/particles/ParticlePreview.ts +++ b/src/editor/particles/ParticlePreview.ts @@ -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()); } diff --git a/src/editor/shader/ShaderPreview.ts b/src/editor/shader/ShaderPreview.ts index da0e68a1..e6e0074f 100644 --- a/src/editor/shader/ShaderPreview.ts +++ b/src/editor/shader/ShaderPreview.ts @@ -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(); /** The test image, one copy per edge mode: the sampler decides what a UV past the edge reads. */ diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index 8dee0aac..9b3e2796 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -21,6 +21,8 @@ export const ADDRESS_MODE: Record 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 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 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, diff --git a/src/viewport/nineSlice.ts b/src/viewport/nineSlice.ts index 0461335b..55a7148d 100644 --- a/src/viewport/nineSlice.ts +++ b/src/viewport/nineSlice.ts @@ -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[] = []; diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts index 9b8c4f09..42ec99a2 100644 --- a/src/viewport/particles.ts +++ b/src/viewport/particles.ts @@ -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. */ diff --git a/src/viewport/quadPadding.ts b/src/viewport/quadPadding.ts new file mode 100644 index 00000000..4989ce41 --- /dev/null +++ b/src/viewport/quadPadding.ts @@ -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}` : ''; +} diff --git a/src/viewport/shaders.ts b/src/viewport/shaders.ts index bc75f65c..b1f684e4 100644 --- a/src/viewport/shaders.ts +++ b/src/viewport/shaders.ts @@ -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' }, }, + padding: new UniformGroup({ uPad: { value: new Float32Array(2), type: 'vec2' } }), }, ); + this.pad = ( + this.resources.padding as UniformGroup<{ uPad: { value: Float32Array; type: 'vec2' } }> + ).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; } } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index f645b780..1142a931 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -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(); +const geometryKeys = new WeakMap(); -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 }); } /** diff --git a/src/viewport/textPicture.ts b/src/viewport/textPicture.ts index 2b192fd0..254ae93d 100644 --- a/src/viewport/textPicture.ts +++ b/src/viewport/textPicture.ts @@ -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); diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index ce7db9f1..f92bd26a 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -35,10 +35,13 @@ import { } from '../../src/core/properties/shaders'; import { inlineInputs } from '../../src/core/graph/NodeRegistry'; import { graphFingerprint } from '../../src/core/graph/Graph'; -import { compileShader } from '../../src/core/shader/compile'; +import { compileShader, PADDING_CLAMP_HINT } from '../../src/core/shader/compile'; import { DEFAULT_EDGE, EDGE_PARAM, + MAX_PADDING, + PADDING_PARAM, + paddingOf, SHADER_NODES, SHADER_NUMBER, SHADER_OUTPUT, @@ -220,7 +223,8 @@ describe('compileShader', () => { for (const name of ['brightness', 'contrast', 'saturation']) expect(fragment).toContain(`uniform float u_${name};`); // Drawn in the sprite's own pass: the tint goes in before the graph, premultiplied like the texel. - expect(fragment).toContain('vec4 texel = texture(uTexture, vTextureCoord) * vColor;'); + expect(fragment).toContain('vec4 texelAt(vec2 uv) { return texture(uTexture, uv); }'); + expect(fragment).toContain('vec4 texel = texelAt(vTextureCoord) * vColor;'); expect(fragment).toContain('vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c.a : c.rgb; }'); expect(fragment).toContain('vec3 rgb = straight(texel);'); expect(compiled).toMatchObject({ time: false, edge: 'mirror' }); @@ -470,10 +474,10 @@ describe('compileShader', () => { expect(fragment).toMatch(/float n\d+_u = vTextureCoord\.x;/); expect(fragment).toMatch(/float n\d+_result = \(1\.0 - n\d+_u\);/); expect(fragment).toMatch( - /vec3 n\d+_color = straight\(texture\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\);/, + /vec3 n\d+_color = straight\(texelAt\(vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\);/, ); expect(fragment).toMatch( - /float n\d+_alpha = \(texture\(uTexture, vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)\.a;/, + /float n\d+_alpha = \(texelAt\(vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)\.a;/, ); expect(fragment).toMatch(/float n\d+_width = float\(textureSize\(uTexture, 0\)\.x\);/); expect(fragment).toMatch(/float n\d+_value = uTime;/); @@ -510,6 +514,107 @@ describe('compileShader', () => { expect(compileShader(s.asset().graph).compiled!.edge).toBe('clamp'); }); + describe('padding and the Transparent edge (#69)', () => { + const plain = (params: Record = {}) => { + const s = shaderEditor(makeProject(), 'Glow'); + const out = s.add(SHADER_OUTPUT, params); + return { s, out, build: () => compileShader(s.asset().graph) }; + }; + + it('has no padding on a new Output, and hands on what is set there', () => { + const fresh = plain(); + const params = fresh.s.asset().graph.nodes.find((n) => n.id === fresh.out)!.params; + expect(params[PADDING_PARAM]).toBe(0); + expect(fresh.build().compiled!.padding).toBe(0); + expect(plain({ padding: 3 }).build().compiled!.padding).toBe(3); + // A graph from before the setting existed has no value and draws as it did. + delete params[PADDING_PARAM]; + expect(fresh.build().compiled!.padding).toBe(0); + }); + + it('reads a padding nobody could mean as none, and stops at the limit', () => { + expect(paddingOf({ padding: -4 })).toBe(0); + expect(paddingOf({ padding: 'wide' })).toBe(0); + expect(paddingOf({ padding: Number.POSITIVE_INFINITY })).toBe(0); + expect(paddingOf({ padding: MAX_PADDING + 1 })).toBe(MAX_PADDING); + expect(paddingOf({ padding: 1.5 })).toBe(1.5); + }); + + it('shows the Edge in the margin: with padding the mode counts without any shifted Sample', () => { + expect(plain({ edge: 'repeat' }).build().compiled!.edge).toBe(SHEET_EDGE); + expect(plain({ edge: 'repeat', padding: 2 }).build().compiled!.edge).toBe('repeat'); + expect(plain({ edge: 'clamp', padding: 2 }).build().compiled!.edge).toBe('clamp'); + }); + + it('cuts Transparent in the fragment, on the plain sheet', () => { + const { compiled } = plain({ edge: 'transparent', padding: 2 }).build(); + // No copy of the picture with a sampler of its own: the fragment empties the outside itself. + expect(compiled!.edge).toBe(SHEET_EDGE); + expect(compiled!.fragment).toContain( + 'vec4 texelAt(vec2 uv) { vec2 inside = step(vec2(0.0), uv) * step(uv, vec2(1.0)); return texture(uTexture, uv) * (inside.x * inside.y); }', + ); + // The sprite's own pixel goes through the same cut, so the margin itself is empty. + expect(compiled!.fragment).toContain('vec4 texel = texelAt(vTextureCoord) * vColor;'); + for (const edge of ['clamp', 'mirror', 'repeat']) + expect(plain({ edge, padding: 2 }).build().compiled!.fragment).toContain( + 'vec4 texelAt(vec2 uv) { return texture(uTexture, uv); }', + ); + }); + + it('reads a shifted Sample through the same cut', () => { + const { s, out, build } = plain({ edge: 'transparent' }); + const uv = s.add(SHADER_UV); + const shift = s.add('ShaderAdd', { b: 0.5 }); + const sample = s.add(SHADER_SAMPLE); + s.link(uv, 'u', shift, 'a'); + s.link(shift, 'result', sample, 'u'); + s.link(sample, 'color', out, 'color'); + const { compiled } = build(); + expect(compiled!.edge).toBe(SHEET_EDGE); + expect(compiled!.fragment).toMatch( + /straight\(texelAt\(vec2\(n\d+_result, vTextureCoord\.y\)\) \* vColor\)/, + ); + expect(compiled!.fragment).not.toMatch(/straight\(texture\(/); + }); + + it('says at the Output that padding with Clamp smears, and still compiles', () => { + const smeared = plain({ edge: 'clamp', padding: 2 }); + const build = smeared.build(); + expect(build.hints).toEqual({ [smeared.out]: PADDING_CLAMP_HINT }); + expect(build.problems).toEqual({}); + expect(build.reason).toBe(''); + expect(build.compiled).not.toBeNull(); + expect(plain({ edge: 'clamp' }).build().hints).toEqual({}); + for (const edge of ['transparent', 'mirror', 'repeat']) + expect(plain({ edge, padding: 2 }).build().hints).toEqual({}); + }); + + it('offers Transparent and Padding on the Output', () => { + const [edge, padding] = ShaderOutput.params; + expect(edge).toMatchObject({ key: EDGE_PARAM, kind: 'enum' }); + expect((edge as { options: { value: string }[] }).options.map((o) => o.value)).toEqual([ + 'clamp', + 'mirror', + 'repeat', + 'transparent', + ]); + expect(padding).toMatchObject({ + key: PADDING_PARAM, + kind: 'number', + default: 0, + min: 0, + max: MAX_PADDING, + }); + }); + + it('compiles anew when the padding changes, so every wearer hears of it', () => { + const { s, out } = plain({ padding: 1 }); + const before = graphFingerprint(s.asset().graph); + s.asset().graph.nodes.find((n) => n.id === out)!.params[PADDING_PARAM] = 2; + expect(graphFingerprint(s.asset().graph)).not.toBe(before); + }); + }); + it('writes Fract, Step and Floor as the GLSL calls, per channel for a colour', () => { const f = makeProject(); const s = shaderEditor(f, 'Lines'); diff --git a/tests/viewport/quad-padding.test.ts b/tests/viewport/quad-padding.test.ts new file mode 100644 index 00000000..34f15ef3 --- /dev/null +++ b/tests/viewport/quad-padding.test.ts @@ -0,0 +1,94 @@ +/** + * A shader's Padding as geometry (#69): the quad grows by so many pixels of the picture and its + * UVs grow with it, so the picture stays where it was and the margin reads past its border. + */ +import { describe, expect, it } from 'vitest'; +import type { NineSlice } from '../../src/core/model/texture'; +import { paddedSpans, sliceGeometry, sliceKey, sliceSpans } from '../../src/viewport/nineSlice'; +import { paddedQuad, paddingKey, quadPadding } from '../../src/viewport/quadPadding'; + +const slice = (scale = 1): NineSlice => ({ + border: { left: 8, top: 4, right: 8, bottom: 4 }, + fill: 'stretch', + sourceW: 24, + sourceH: 16, + scale, +}); + +describe('quadPadding', () => { + it('counts in pixels of the picture, per axis', () => { + expect(quadPadding(2, 16, 32)).toEqual({ u: 0.125, v: 0.0625 }); + }); + + it('is nothing without padding, and without a picture to count in', () => { + expect(quadPadding(0, 16, 16)).toBeNull(); + expect(quadPadding(-1, 16, 16)).toBeNull(); + expect(quadPadding(2, 0, 16)).toBeNull(); + expect(quadPadding(2, 16, 0)).toBeNull(); + expect(quadPadding(Number.NaN, 16, 16)).toBeNull(); + }); + + it('keys two margins apart, and no margin as the shared quad', () => { + expect(paddingKey(null)).toBe(''); + expect(paddingKey(quadPadding(2, 16, 16))).not.toBe(paddingKey(quadPadding(2, 16, 32))); + expect(paddingKey(quadPadding(2, 16, 16))).toBe(paddingKey(quadPadding(4, 32, 32))); + }); +}); + +describe('paddedQuad', () => { + it('grows on every side and reads past the border by the same amount', () => { + const { positions, uvs, indices } = paddedQuad({ u: 0.125, v: 0.25 }); + expect([...positions]).toEqual([-0.125, -0.25, 1.125, -0.25, 1.125, 1.25, -0.125, 1.25]); + // Same numbers: 0..1 of the box is still 0..1 of the picture, so the picture does not move. + expect([...uvs]).toEqual([...positions]); + expect([...indices]).toEqual([0, 1, 2, 0, 2, 3]); + }); +}); + +describe('a sliced sprite with padding', () => { + it('gets a ring as wide as that many border pixels, reading that far past the frame', () => { + const spans = paddedSpans(sliceSpans(100, 24, 8, 8, 'stretch'), 100, 24, 3); + expect(spans.map((s) => [s.p0, s.p1])).toEqual([ + [-3, 0], + [0, 8], + [8, 92], + [92, 100], + [100, 103], + ]); + expect(spans[0]).toMatchObject({ t0: -3 / 24, t1: 0 }); + expect(spans.at(-1)).toMatchObject({ t0: 1, t1: 1 + 3 / 24 }); + }); + + it('draws the ring at the scale the borders are drawn at', () => { + const spans = paddedSpans(sliceSpans(100, 24, 8, 8, 'stretch', 2), 100, 24, 3, 2); + expect([spans[0]!.p0, spans.at(-1)!.p1]).toEqual([-6, 106]); + // Twice as wide on screen, the same three pixels of the picture. + expect(spans[0]!.t0).toBe(-3 / 24); + }); + + it('leaves the parts alone without padding, and adds nothing to a sprite of no size', () => { + const plain = sliceSpans(100, 24, 8, 8, 'stretch'); + expect(paddedSpans(plain, 100, 24, 0)).toBe(plain); + expect(paddedSpans([], 0, 24, 3)).toEqual([]); + }); + + it('builds the ring into the geometry: sixteen more quads around the nine', () => { + const plain = sliceGeometry(slice(), 100, 50); + const padded = sliceGeometry(slice(), 100, 50, 2); + expect(plain.indices.length / 6).toBe(9); + expect(padded.indices.length / 6).toBe(25); + const xs = [...padded.positions].filter((_, i) => i % 2 === 0); + const ys = [...padded.positions].filter((_, i) => i % 2 === 1); + expect([Math.min(...xs), Math.max(...xs)]).toEqual([-2 / 100, 1 + 2 / 100].map(Math.fround)); + expect([Math.min(...ys), Math.max(...ys)]).toEqual([-2 / 50, 1 + 2 / 50].map(Math.fround)); + const us = [...padded.uvs].filter((_, i) => i % 2 === 0); + const vs = [...padded.uvs].filter((_, i) => i % 2 === 1); + expect([Math.min(...us), Math.max(...us)]).toEqual([-2 / 24, 1 + 2 / 24].map(Math.fround)); + expect([Math.min(...vs), Math.max(...vs)]).toEqual([-2 / 16, 1 + 2 / 16].map(Math.fround)); + }); + + it('keeps the key of an unpadded slice as it was', () => { + expect(sliceKey(slice(), 100, 50)).toBe(sliceKey(slice(), 100, 50)); + expect(sliceGeometry(slice(), 100, 50, 0).positions).toEqual(sliceGeometry(slice(), 100, 50).positions); + }); +}); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index ef1bf19b..091072c6 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -770,6 +770,90 @@ export async function shader(t) { { wide, wider }, ); + // 28l-iii. Padding is room around the picture the sprite draws into as well (#69), counted in + // pixels of the picture: 8 of this 32-px one is a quarter of the sprite per side. The + // picture itself stays where it is, and what the margin reads is the Output's Edge. + const texelOnly = (outParams) => + graph( + [node('texel', 'ShaderTextureColor'), node('out', 'ShaderOutput', outParams, 780, 0)], + [edge('texel', 'color', 'out', 'color')], + ); + const around = async () => ({ + left: await pixel(-0.3, -0.3), + right: await pixel(0.3, -0.3), + marginLeft: await pixel(-0.62, -0.3), + marginRight: await pixel(0.62, -0.3), + beyond: await pixel(0.9, -0.3), + }); + await texelOnly({ edge: 'transparent' }); + const bare = await around(); + await texelOnly({ edge: 'transparent', padding: 8 }); + const roomy = await around(); + check( + 'Padding with Edge Transparent leaves the picture where it is and the margin empty', + colourDistance(roomy.left, LEFT) < 12 && + colourDistance(roomy.right, RIGHT) < 12 && + roomy.marginLeft === bare.marginLeft && + roomy.marginRight === bare.marginRight, + { bare, roomy }, + ); + // A sprite far off screen is not drawn at all; one whose shader has padding is, because its + // margin may reach in while its rectangle is still outside. + const awayBy = (dx) => + mutate(`__gm.state.activeScene.objects.find((o) => o.id === 'oshade').position.x += ${dx};`); + await awayBy(100000); + const farPadded = await json('__gm.shaderCount()'); + await texelOnly({ edge: 'transparent' }); + const farPlain = await json('__gm.shaderCount()'); + await awayBy(-100000); + check( + 'off screen a shaded sprite is left out, a padded one is still drawn for its margin', + farPadded === farPlain + 1, + { farPadded, farPlain }, + ); + const shiftedRight = async (padding) => { + await sampleAt( + [node('shift', 'ShaderAdd', { b: -0.25 }, 260, 0)], + // The sample's own opacity: in the margin the pixel's own is none, with Edge Transparent. + [ + edge('uv', 'u', 'shift', 'a'), + edge('shift', 'result', 'sample', 'u'), + edge('sample', 'alpha', 'out', 'alpha'), + ], + { edge: 'transparent', padding }, + ); + return around(); + }; + const clipped = await shiftedRight(0); + const drawn = await shiftedRight(8); + check( + 'a picture shifted right is cut at the sprite’s rectangle without padding, and drawn past it with', + clipped.marginRight === bare.marginRight && + colourDistance(drawn.marginRight, RIGHT) < 12 && + drawn.marginLeft === bare.marginLeft && + drawn.beyond === bare.beyond, + { bare, clipped, drawn }, + ); + await texelOnly({ padding: 8 }); + const smeared = await around(); + await showTab('shader'); + await clickText('Fit'); + await sleep(300); + const hint = await json("__gm.shader.nodeProblem('out')"); + const readout = await info(); + await shot('28-shader-padding-hint'); + await showTab('scene'); + check( + 'with Edge Clamp the border pixel smears into the margin, and the Output says so while the shader still compiles', + colourDistance(smeared.marginLeft, LEFT) < 12 && + colourDistance(smeared.marginRight, RIGHT) < 12 && + smeared.beyond === bare.beyond && + hint === 'Padding with Clamp smears the border' && + readout !== hint, + { smeared, hint, readout }, + ); + await shot('28-shader-padding-scene'); + // 28l-ii. The sprite wears a picture and a shader: both open from the object's menu, from the // asset's menu and from the inspector, whose Texture and Shader labels are links (#221). const shadedProps = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props"); diff --git a/tools/e2e/30-font.mjs b/tools/e2e/30-font.mjs index 283a9cdc..7c427be5 100644 --- a/tools/e2e/30-font.mjs +++ b/tools/e2e/30-font.mjs @@ -284,6 +284,49 @@ export async function font(t) { JSON.stringify({ longer, redLonger }), ); + // 30i-ii. A shader's Padding gives a text room around its rectangle (#69), in the text's own + // units. The graph fills the whole quad here, so the margin shows as red left of the text. + const shaderGraph = `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(RED_SHADER.id)}).graph`; + const filled = (padding) => + mutate(` + const graph = ${shaderGraph}; + graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full'); + graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2'); + graph.nodes.push(${JSON.stringify(node('ft-red-full', 'ShaderNumber', 0, 160, { value: 1 }))}); + graph.edges.push(${JSON.stringify(edge('ft-red-e2', 'ft-red-full', 'value', 'ft-red-out', 'alpha'))}); + Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'transparent', padding: ${padding} });`); + // The red run on the row through the text: where the filled quad begins and ends on screen. + const redSpan = async () => { + const r = await json(`__gm.objectRect(${JSON.stringify(TEXT_OBJECT)})`); + const from = Math.max(0, Math.round(r.x) - 600); + const row = await json(`__gm.pixelRow(${from}, ${Math.round(r.y)}, 1200)`); + const red = row.map(reddish); + return { + first: from + red.indexOf(true), + last: from + red.lastIndexOf(true), + unit: r.w / (await info()).width, + }; + }; + await evaluate('__gm.state.select(null)'); + await filled(0); + const textTight = await redSpan(); + await filled(6); + const textRoomy = await redSpan(); + await shot('30-font-text-padding'); + const reach = 6 * textTight.unit; + check( + 'a padded shader draws six units past the text’s rectangle on either side, and none further', + textTight.last > textTight.first && + Math.abs(textTight.first - textRoomy.first - reach) <= 2 && + Math.abs(textRoomy.last - textTight.last - reach) <= 2, + JSON.stringify({ textTight, textRoomy, reach }), + ); + await mutate(` + const graph = ${shaderGraph}; + graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full'); + graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2'); + Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'clamp', padding: 0 });`); + // 30j. The game draws a text through the same picture, shader included. In front of everything: // in the chained run, earlier steps leave pictures of their own at the camera. await place('{ x: cam.x, y: cam.y, z: 1000 }'); diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index 3a3723d8..0f46509d 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -608,6 +608,30 @@ export async function particles(t) { JSON.stringify({ dimmed }), ); + // 31t-ii. (#69) The shader's Padding grows every particle about its own centre. Without a + // picture a pixel is a unit of the particle's size, so 10 on a size of 40 is a quarter + // per side. Counted along the row through the source, in the grey from 31t. + const greyAcross = async () => { + await at(0.1); + const row = await json(`__gm.pixelRow(${Math.round(spotOn.x) - 240}, ${Math.round(spotOn.y)}, 480)`); + return row.filter((hex) => colourDistance(hex, '#404040') < 16).length; + }; + const setPadding = (padding) => + mutate(` + const shader = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(SHADER)}); + shader.graph.nodes.find((n) => n.id === 'lv-out').params.padding = ${padding};`); + const plainRun = await greyAcross(); + await setPadding(10); + const paddedRun = await greyAcross(); + await shot('31-particles-padded'); + await setPadding(0); + const backRun = await greyAcross(); + check( + 'a shader’s padding grows every particle by a quarter of its size per side, and 0 takes it back', + plainRun > 0 && paddedRun > plainRun + 8 && paddedRun <= plainRun * 1.5 + 4 && backRun === plainRun, + JSON.stringify({ plainRun, paddedRun, backRun }), + ); + // 31u. (#88) A source draws rows from two graphs, so one name can arrive twice. The source's own // row wins and the uniform keeps its default – two rows called Rate would be one row with two // meanings, which is #63 in another coat. diff --git a/tools/e2e/37-nine-slice.mjs b/tools/e2e/37-nine-slice.mjs index fcbe5731..f81ed7dd 100644 --- a/tools/e2e/37-nine-slice.mjs +++ b/tools/e2e/37-nine-slice.mjs @@ -281,6 +281,49 @@ export async function nineSlice(t) { await select({ kind: 'asset', assetId: texture.id }); check("the scale is a row of the texture's inspector", await has('Nine-slice scale')); + // 37g. A shader's Padding on a sliced sprite (#69): a ring around the nine parts, as wide as that + // many border pixels. With Edge Clamp the frame's red rim goes on through it, so the ring + // shows as red left of the box – and the border inside keeps its 8 pixels. + await mutate(` + __gm.state.project.assets.push({ + id: 'ns-shader', type: 'shader', name: 'Rim', props: {}, + graph: { nodes: [ + { id: 'ns-texel', type: 'ShaderTextureColor', x: 0, y: 0, params: {} }, + { id: 'ns-out', type: 'ShaderOutput', x: 260, y: 0, params: { edge: 'clamp', padding: 0 } }, + ], edges: [ + { id: 'ns-e1', from: { node: 'ns-texel', port: 'color' }, to: { node: 'ns-out', port: 'color' } }, + ] }, + }); + __gm.state.project.assets.find((a) => a.id === 'ns-sprite').props.shader = 'ns-shader'; + __gm.state.select(null)`); + const rim = async (padding) => { + await mutate(` + __gm.state.project.assets.find((a) => a.id === 'ns-shader') + .graph.nodes.find((n) => n.id === 'ns-out').params.padding = ${padding};`); + await sleep(400); + // From twelve units left of the box to twelve units into it. + const reach = Math.round(12 * scale); + const row = await json(`__gm.pixelRow(${Math.round(box.x - box.w / 2) - reach}, ${rowY}, ${2 * reach})`); + const outside = row.slice(0, reach).filter((c) => colourDistance(c, RED) < 60).length; + const inside = row.slice(reach).filter((c) => colourDistance(c, RED) < 60).length; + return { outside, inside }; + }; + const tight = await rim(0); + const ringed = await rim(4); + await shot('41-nine-slice-padding'); + check( + 'a shader’s padding puts a ring of four border pixels around a sliced sprite and leaves its parts alone', + tight.outside === 0 && + Math.abs(ringed.outside - 4 * scale) <= 2 && + Math.abs(tight.inside - 8 * scale) <= 2 && + Math.abs(ringed.inside - 8 * scale) <= 2, + JSON.stringify({ tight, ringed, expected: [4 * scale, 8 * scale] }), + ); + await mutate(` + const project = __gm.state.project; + delete project.assets.find((a) => a.id === 'ns-sprite').props.shader; + project.assets = project.assets.filter((a) => a.id !== 'ns-shader');`); + // The scene back as it was, without the sprite this step brought: later readers count what is in it. await mutate(` __gm.state.activeScene.objects = window.__nsOthers ?? [];