Shader graph: alpha input on Output, Texture alpha node (#70)
game-maker / suite (push) Successful in 7m55s
game-maker / suite (push) Successful in 7m55s
Output gains a number input `alpha`; unconnected it reads the texel's alpha, like `color` reads the texel. The epilogue premultiplies with the resolved alpha. New `Texture alpha` node exposes texel.a for maths. PortDef.inline=false keeps the alpha port without an inline field: every number port is inline by default, and a typed default of 0 would have made every shaded sprite invisible. The compiler's texel fallback is typed: colour ports get rgb, number ports get texel.a. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
b0c4b4ec5a
commit
3da0ef1be2
6 files changed
+125
-12
No files matched your search
@@ -37,7 +37,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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 699 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 703 checks (GM_STEPS=20 for one step)
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface PortDef {
|
|||||||
default?: PropertyValue;
|
default?: PropertyValue;
|
||||||
/** The property this port carries; its typed-in value is edited as that property (color, enum, …). */
|
/** The property this port carries; its typed-in value is edited as that property (color, enum, …). */
|
||||||
field?: PropertyDef;
|
field?: PropertyDef;
|
||||||
|
/** `false`: nothing to type in even for a number – unconnected, the node's own rule applies. */
|
||||||
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,6 +147,7 @@ export function inlineInputs(def: NodeDef): PortDef[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isInlinePort(port: PortDef): boolean {
|
function isInlinePort(port: PortDef): boolean {
|
||||||
|
if (port.inline === false) return false;
|
||||||
if (port.type === 'vec3') return port.field !== undefined;
|
if (port.type === 'vec3') return port.field !== undefined;
|
||||||
return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct';
|
return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
floatLiteral,
|
floatLiteral,
|
||||||
isShaderNodeDef,
|
isShaderNodeDef,
|
||||||
SHADER_OUTPUT,
|
SHADER_OUTPUT,
|
||||||
|
TEXEL_ALPHA,
|
||||||
TEXEL_COLOR,
|
TEXEL_COLOR,
|
||||||
uniformName,
|
uniformName,
|
||||||
vec3Literal,
|
vec3Literal,
|
||||||
@@ -89,7 +90,10 @@ export function compileShader(graph: Graph): ShaderBuild {
|
|||||||
return outputs;
|
return outputs;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** What feeds an input: the edge's source, the typed-in value, or – with nothing to type – the texel. */
|
/**
|
||||||
|
* What feeds an input: the edge's source, the typed-in value, or – with nothing to type – the
|
||||||
|
* texel: its colour for a colour port, its alpha for a number port.
|
||||||
|
*/
|
||||||
const input = (node: GraphNode, port: PortDef): Expr | null => {
|
const input = (node: GraphNode, port: PortDef): Expr | null => {
|
||||||
const edge = edgesInto(graph, node.id, port.key)[0];
|
const edge = edgesInto(graph, node.id, port.key)[0];
|
||||||
if (edge) {
|
if (edge) {
|
||||||
@@ -103,7 +107,7 @@ export function compileShader(graph: Graph): ShaderBuild {
|
|||||||
if (port.field?.kind === 'color')
|
if (port.field?.kind === 'color')
|
||||||
return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' };
|
return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' };
|
||||||
if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' };
|
if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' };
|
||||||
return TEXEL_COLOR;
|
return port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR;
|
||||||
};
|
};
|
||||||
|
|
||||||
const conform = (node: GraphNode, port: PortDef, expr: Expr): Expr | null => {
|
const conform = (node: GraphNode, port: PortDef, expr: Expr): Expr | null => {
|
||||||
@@ -119,9 +123,10 @@ export function compileShader(graph: Graph): ShaderBuild {
|
|||||||
if (!output) return { compiled: null, problems, reason: 'No Output node' };
|
if (!output) return { compiled: null, problems, reason: 'No Output node' };
|
||||||
const outputDef = nodeDef(SHADER_OUTPUT)!;
|
const outputDef = nodeDef(SHADER_OUTPUT)!;
|
||||||
const color = input(output, outputDef.inputs[0]!);
|
const color = input(output, outputDef.inputs[0]!);
|
||||||
|
const alpha = input(output, outputDef.inputs[1]!);
|
||||||
|
|
||||||
const first = graph.nodes.find((n) => n.id in problems);
|
const first = graph.nodes.find((n) => n.id in problems);
|
||||||
if (first || !color)
|
if (first || !color || !alpha)
|
||||||
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
|
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
|
||||||
|
|
||||||
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
|
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
|
||||||
@@ -135,7 +140,8 @@ export function compileShader(graph: Graph): ShaderBuild {
|
|||||||
' vec4 texel = texture(uTexture, vTextureCoord);',
|
' vec4 texel = texture(uTexture, vTextureCoord);',
|
||||||
` vec3 ${TEXEL_COLOR.code} = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;`,
|
` vec3 ${TEXEL_COLOR.code} = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;`,
|
||||||
...lines.map((line) => ` ${line}`),
|
...lines.map((line) => ` ${line}`),
|
||||||
` finalColor = vec4(clamp(${color.code}, 0.0, 1.0) * texel.a, texel.a);`,
|
` float alpha = clamp(${alpha.code}, 0.0, 1.0);`,
|
||||||
|
` finalColor = vec4(clamp(${color.code}, 0.0, 1.0) * alpha, alpha);`,
|
||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor';
|
export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor';
|
||||||
|
export const SHADER_TEXTURE_ALPHA = 'ShaderTextureAlpha';
|
||||||
export const SHADER_OUTPUT = 'ShaderOutput';
|
export const SHADER_OUTPUT = 'ShaderOutput';
|
||||||
export const SHADER_COLOR = 'ShaderColor';
|
export const SHADER_COLOR = 'ShaderColor';
|
||||||
|
|
||||||
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
|
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
|
||||||
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
|
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
|
||||||
|
export const TEXEL_ALPHA: Expr = { code: 'texel.a', type: 'float' };
|
||||||
|
|
||||||
const NUMBER_STEP = 0.05;
|
const NUMBER_STEP = 0.05;
|
||||||
|
|
||||||
@@ -98,13 +100,24 @@ export const ShaderTextureColor: ShaderNodeDef = {
|
|||||||
emit: () => ({ color: TEXEL_COLOR }),
|
emit: () => ({ color: TEXEL_COLOR }),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The one node the compiler starts from; an unconnected color leaves the pixel as it is. */
|
export const ShaderTextureAlpha: ShaderNodeDef = {
|
||||||
|
type: SHADER_TEXTURE_ALPHA,
|
||||||
|
label: 'Texture alpha',
|
||||||
|
category: 'shader',
|
||||||
|
description: "The sprite's own pixel's opacity: 0 transparent, 1 opaque.",
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||||||
|
params: [],
|
||||||
|
emit: () => ({ value: TEXEL_ALPHA }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The one node the compiler starts from; an unconnected input leaves that part of the pixel as it is. */
|
||||||
export const ShaderOutput: ShaderNodeDef = {
|
export const ShaderOutput: ShaderNodeDef = {
|
||||||
type: SHADER_OUTPUT,
|
type: SHADER_OUTPUT,
|
||||||
label: 'Output',
|
label: 'Output',
|
||||||
category: 'shader',
|
category: 'shader',
|
||||||
description: 'The colour the sprite is drawn with. Its alpha stays the texture’s.',
|
description: 'The colour and opacity the sprite is drawn with; unconnected, each is the texture’s.',
|
||||||
inputs: [vec3Port('color')],
|
inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }],
|
||||||
outputs: [],
|
outputs: [],
|
||||||
params: [],
|
params: [],
|
||||||
emit: () => ({}),
|
emit: () => ({}),
|
||||||
@@ -194,6 +207,7 @@ export const ShaderLuminance: ShaderNodeDef = {
|
|||||||
/** Every node a shader graph offers. Registered by `graph/nodes`, offered by no behavior palette. */
|
/** Every node a shader graph offers. Registered by `graph/nodes`, offered by no behavior palette. */
|
||||||
export const SHADER_NODES: readonly ShaderNodeDef[] = [
|
export const SHADER_NODES: readonly ShaderNodeDef[] = [
|
||||||
ShaderTextureColor,
|
ShaderTextureColor,
|
||||||
|
ShaderTextureAlpha,
|
||||||
ShaderOutput,
|
ShaderOutput,
|
||||||
ShaderProperty,
|
ShaderProperty,
|
||||||
ShaderColor,
|
ShaderColor,
|
||||||
|
|||||||
@@ -21,8 +21,15 @@ import {
|
|||||||
SHADER_PROPERTY,
|
SHADER_PROPERTY,
|
||||||
shaderParamDefs,
|
shaderParamDefs,
|
||||||
} from '../../src/core/properties/shaders';
|
} from '../../src/core/properties/shaders';
|
||||||
|
import { inlineInputs } from '../../src/core/graph/NodeRegistry';
|
||||||
import { compileShader } from '../../src/core/shader/compile';
|
import { compileShader } from '../../src/core/shader/compile';
|
||||||
import { SHADER_NODES, SHADER_OUTPUT, SHADER_TEXTURE_COLOR } from '../../src/core/shader/nodes';
|
import {
|
||||||
|
SHADER_NODES,
|
||||||
|
SHADER_OUTPUT,
|
||||||
|
SHADER_TEXTURE_ALPHA,
|
||||||
|
SHADER_TEXTURE_COLOR,
|
||||||
|
ShaderOutput,
|
||||||
|
} from '../../src/core/shader/nodes';
|
||||||
import { deserializeProject } from '../../src/core/store/serialize';
|
import { deserializeProject } from '../../src/core/store/serialize';
|
||||||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||||||
import { makeProject, makeSpriteScene, place, type ProjectFixture, roundTrip } from '../helpers';
|
import { makeProject, makeSpriteScene, place, type ProjectFixture, roundTrip } from '../helpers';
|
||||||
@@ -181,7 +188,8 @@ describe('compileShader', () => {
|
|||||||
expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_result - vec3\(0\.5\)\);/);
|
expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_result - vec3\(0\.5\)\);/);
|
||||||
expect(fragment).toMatch(/float n\d+_value = dot\(n\d+_result, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/);
|
expect(fragment).toMatch(/float n\d+_value = dot\(n\d+_result, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/);
|
||||||
expect(fragment).toMatch(/vec3 n\d+_result = mix\(vec3\(n\d+_value\), n\d+_result, n\d+_value\);/);
|
expect(fragment).toMatch(/vec3 n\d+_result = mix\(vec3\(n\d+_value\), n\d+_result, n\d+_value\);/);
|
||||||
expect(fragment).toMatch(/finalColor = vec4\(clamp\(n\d+_result, 0\.0, 1\.0\) \* texel\.a, texel\.a\);/);
|
expect(fragment).toContain('float alpha = clamp(texel.a, 0.0, 1.0);');
|
||||||
|
expect(fragment).toMatch(/finalColor = vec4\(clamp\(n\d+_result, 0\.0, 1\.0\) \* alpha, alpha\);/);
|
||||||
// Each local is declared before it is used.
|
// Each local is declared before it is used.
|
||||||
const declared = new Set<string>();
|
const declared = new Set<string>();
|
||||||
for (const line of fragment.split('\n')) {
|
for (const line of fragment.split('\n')) {
|
||||||
@@ -196,7 +204,7 @@ describe('compileShader', () => {
|
|||||||
const f = makeProject();
|
const f = makeProject();
|
||||||
const s = shaderEditor(f);
|
const s = shaderEditor(f);
|
||||||
s.add(SHADER_OUTPUT);
|
s.add(SHADER_OUTPUT);
|
||||||
expect(compileShader(s.asset().graph).compiled!.fragment).toContain('clamp(rgb, 0.0, 1.0) * texel.a');
|
expect(compileShader(s.asset().graph).compiled!.fragment).toContain('clamp(rgb, 0.0, 1.0) * alpha');
|
||||||
|
|
||||||
const g = shaderEditor(f, 'Scaled');
|
const g = shaderEditor(f, 'Scaled');
|
||||||
const times = g.add('ShaderMultiply', { a: 2, b: 0.75 });
|
const times = g.add('ShaderMultiply', { a: 2, b: 0.75 });
|
||||||
@@ -219,6 +227,41 @@ describe('compileShader', () => {
|
|||||||
expect(tinted).toMatch(/vec3 n1_result = \(vec3\(1\.0\) \* n0_color\);/);
|
expect(tinted).toMatch(/vec3 n1_result = \(vec3\(1\.0\) \* n0_color\);/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the texture’s alpha until the Output’s alpha is fed, then clamps what feeds it', () => {
|
||||||
|
const f = makeProject();
|
||||||
|
const s = shaderEditor(f);
|
||||||
|
const alpha = s.add(SHADER_TEXTURE_ALPHA);
|
||||||
|
const opacity = s.add(SHADER_PROPERTY, { name: 'opacity', default: 1, min: 0, max: 1 });
|
||||||
|
const times = s.add('ShaderMultiply');
|
||||||
|
const output = s.add(SHADER_OUTPUT);
|
||||||
|
// Nothing to type on the alpha port: a typed-in 0 would hide every sprite by default.
|
||||||
|
expect(inlineInputs(ShaderOutput).map((p) => p.key)).toEqual([]);
|
||||||
|
let { fragment } = compileShader(s.asset().graph).compiled!;
|
||||||
|
expect(fragment).toContain('float alpha = clamp(texel.a, 0.0, 1.0);');
|
||||||
|
expect(fragment).toContain('finalColor = vec4(clamp(rgb, 0.0, 1.0) * alpha, alpha);');
|
||||||
|
|
||||||
|
s.link(alpha, 'value', times, 'a');
|
||||||
|
s.link(opacity, 'value', times, 'b');
|
||||||
|
s.link(times, 'result', output, 'alpha');
|
||||||
|
({ fragment } = compileShader(s.asset().graph).compiled!);
|
||||||
|
expect(fragment).toMatch(/float n0_value = texel\.a;/);
|
||||||
|
expect(fragment).toMatch(/float n1_value = u_opacity;/);
|
||||||
|
expect(fragment).toMatch(/float n2_result = \(n0_value \* n1_value\);/);
|
||||||
|
expect(fragment).toContain('float alpha = clamp(n2_result, 0.0, 1.0);');
|
||||||
|
expect(fragment).toContain('finalColor = vec4(clamp(rgb, 0.0, 1.0) * alpha, alpha);');
|
||||||
|
|
||||||
|
// A colour reaching the alpha port through an `any` result is the Output's problem.
|
||||||
|
const wrong = shaderEditor(f, 'Wrong');
|
||||||
|
const texel = wrong.add(SHADER_TEXTURE_COLOR);
|
||||||
|
const doubled = wrong.add('ShaderMultiply', { b: 2 });
|
||||||
|
const out = wrong.add(SHADER_OUTPUT);
|
||||||
|
wrong.link(texel, 'color', doubled, 'a');
|
||||||
|
wrong.link(doubled, 'result', out, 'alpha');
|
||||||
|
const build = compileShader(wrong.asset().graph);
|
||||||
|
expect(build.compiled).toBeNull();
|
||||||
|
expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a colour' });
|
||||||
|
});
|
||||||
|
|
||||||
it('names the problem on the node, and compiles nothing while there is one', () => {
|
it('names the problem on the node, and compiles nothing while there is one', () => {
|
||||||
const f = makeProject();
|
const f = makeProject();
|
||||||
const s = shaderEditor(f);
|
const s = shaderEditor(f);
|
||||||
|
|||||||
+48
-1
@@ -276,7 +276,54 @@ export async function shader(t) {
|
|||||||
red,
|
red,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 28h. Back to None: no filter at all, the plain colour returns bit for bit.
|
// 28h. Alpha: Texture alpha times a Property into the Output's alpha blends the sprite with what
|
||||||
|
// is behind it; at 1 the pixel is the one from before, byte for byte.
|
||||||
|
await mutate(`
|
||||||
|
const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
|
||||||
|
const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput');
|
||||||
|
a.graph.nodes.push(
|
||||||
|
{ id: 'sh-alpha', type: 'ShaderTextureAlpha', x: 0, y: 300, params: {} },
|
||||||
|
{ id: 'sh-opacity', type: 'ShaderProperty', x: 0, y: 400, params: { name: 'opacity', default: 1, min: 0, max: 1 } },
|
||||||
|
{ id: 'sh-fade', type: 'ShaderMultiply', x: 200, y: 350, params: {} });
|
||||||
|
a.graph.edges.push(
|
||||||
|
{ id: 'sh-fade-a', from: { node: 'sh-alpha', port: 'value' }, to: { node: 'sh-fade', port: 'a' } },
|
||||||
|
{ id: 'sh-fade-b', from: { node: 'sh-opacity', port: 'value' }, to: { node: 'sh-fade', port: 'b' } },
|
||||||
|
{ id: 'sh-fade-out', from: { node: 'sh-fade', port: 'result' }, to: { node: out.id, port: 'alpha' } });`);
|
||||||
|
await sleep(400);
|
||||||
|
const opaque = await centre();
|
||||||
|
check(
|
||||||
|
'an opacity property at its default 1 changes nothing',
|
||||||
|
opaque === red,
|
||||||
|
JSON.stringify({ red, opaque, info: await info() }),
|
||||||
|
);
|
||||||
|
await assetProps({ opacity: 0 });
|
||||||
|
await sleep(300);
|
||||||
|
const behind = await centre();
|
||||||
|
check('opacity 0 shows what is behind the sprite', colourDistance(behind, red) > 60, { red, behind });
|
||||||
|
await assetProps({ opacity: 0.5 });
|
||||||
|
await sleep(300);
|
||||||
|
const half = await centre();
|
||||||
|
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
|
||||||
|
const expected =
|
||||||
|
'#' +
|
||||||
|
[1, 3, 5]
|
||||||
|
.map((i) =>
|
||||||
|
Math.round((channel(red, i) + channel(behind, i)) / 2)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0'),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
check('opacity 0.5 blends the sprite half with what is behind', colourDistance(half, expected) < 24, {
|
||||||
|
red,
|
||||||
|
behind,
|
||||||
|
half,
|
||||||
|
expected,
|
||||||
|
});
|
||||||
|
await assetProps({ opacity: 1 });
|
||||||
|
await sleep(300);
|
||||||
|
check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red);
|
||||||
|
|
||||||
|
// 28i. Back to None: no filter at all, the plain colour returns bit for bit.
|
||||||
await assetProps({ shader: '' });
|
await assetProps({ shader: '' });
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
const back = await centre();
|
const back = await centre();
|
||||||
|
|||||||
Reference in new issue
Block a user