From 5959152fbeb57d68e63cdfbc98554127ed703fd1 Mon Sep 17 00:00:00 2001
From: Roberto Amarante
Date: Thu, 21 May 2026 05:37:04 -0400
Subject: [PATCH 01/38] feather: add more shade graph nodes
---
src/pages/shader-graph/nodeDefs.ts | 272 ++++++++++++++++++++++++++++-
src/types/shader-graph.ts | 32 +++-
2 files changed, 300 insertions(+), 4 deletions(-)
diff --git a/src/pages/shader-graph/nodeDefs.ts b/src/pages/shader-graph/nodeDefs.ts
index b50ac18b..57b9ad6f 100644
--- a/src/pages/shader-graph/nodeDefs.ts
+++ b/src/pages/shader-graph/nodeDefs.ts
@@ -823,6 +823,258 @@ export const NODE_DEFS: Record = {
outputs: [],
emitGlsl: () => '',
},
+
+ // ─── Math (extended) ─────────────────────────────────────────────────────────
+ Sqrt: {
+ category: 'Math',
+ label: 'Sqrt',
+ inputs: [{ id: 'in0', label: 'X', type: 'float' }],
+ outputs: [{ id: 'out', label: 'Out', type: 'float' }],
+ emitGlsl: unary('sqrt'),
+ },
+ Ceil: {
+ category: 'Math',
+ label: 'Ceil',
+ inputs: [{ id: 'in0', label: 'X', type: 'float' }],
+ outputs: [{ id: 'out', label: 'Out', type: 'float' }],
+ emitGlsl: unary('ceil'),
+ },
+ Round: {
+ category: 'Math',
+ label: 'Round',
+ inputs: [{ id: 'in0', label: 'X', type: 'float' }],
+ outputs: [{ id: 'out', label: 'Out', type: 'float' }],
+ emitGlsl: unary('round'),
+ },
+ Sign: {
+ category: 'Math',
+ label: 'Sign',
+ inputs: [{ id: 'in0', label: 'X', type: 'float' }],
+ outputs: [{ id: 'out', label: 'Out', type: 'float' }],
+ emitGlsl: unary('sign'),
+ },
+ Tan: {
+ category: 'Math',
+ label: 'Tan',
+ inputs: [{ id: 'in0', label: 'X', type: 'float' }],
+ outputs: [{ id: 'out', label: 'Out', type: 'float' }],
+ emitGlsl: unary('tan'),
+ },
+ Log: {
+ category: 'Math',
+ label: 'Log',
+ inputs: [{ id: 'in0', label: 'X', type: 'float' }],
+ outputs: [{ id: 'out', label: 'Out', type: 'float' }],
+ emitGlsl: (i, o) => `float ${o.out} = log(max(${i.in0}, 0.0001));`,
+ },
+ Exp: {
+ category: 'Math',
+ label: 'Exp',
+ inputs: [{ id: 'in0', label: 'X', type: 'float' }],
+ outputs: [{ id: 'out', label: 'Out', type: 'float' }],
+ emitGlsl: unary('exp'),
+ },
+ Atan2: {
+ category: 'Math',
+ label: 'Atan2',
+ inputs: [
+ { id: 'y', label: 'Y', type: 'float' },
+ { id: 'x', label: 'X', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'Angle', type: 'float' }],
+ emitGlsl: (i, o) => `float ${o.out} = atan(${i.y}, ${i.x});`,
+ },
+
+ // ─── Vector (extended) ───────────────────────────────────────────────────────
+ CrossVec3: {
+ category: 'Vector',
+ label: 'Cross Vec3',
+ inputs: [
+ { id: 'a', label: 'A', type: 'vec3' },
+ { id: 'b', label: 'B', type: 'vec3' },
+ ],
+ outputs: [{ id: 'out', label: 'XYZ', type: 'vec3' }],
+ emitGlsl: (i, o) => `vec3 ${o.out} = cross(${i.a}, ${i.b});`,
+ },
+ LerpVec4: {
+ category: 'Vector',
+ label: 'Lerp Vec4',
+ inputs: [
+ { id: 'a', label: 'A', type: 'vec4' },
+ { id: 'b', label: 'B', type: 'vec4' },
+ { id: 't', label: 'T', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }],
+ emitGlsl: (i, o) => `vec4 ${o.out} = mix(${i.a}, ${i.b}, clamp(${i.t}, 0.0, 1.0));`,
+ },
+ ScaleVec2: {
+ category: 'Vector',
+ label: 'Scale Vec2',
+ inputs: [
+ { id: 'vec', label: 'XY', type: 'vec2' },
+ { id: 'scale', label: 'Scale', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'XY', type: 'vec2' }],
+ emitGlsl: (i, o) => `vec2 ${o.out} = ${i.vec} * ${i.scale};`,
+ },
+ ScaleVec4: {
+ category: 'Vector',
+ label: 'Scale Vec4',
+ inputs: [
+ { id: 'vec', label: 'RGBA', type: 'vec4' },
+ { id: 'scale', label: 'Scale', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }],
+ emitGlsl: (i, o) => `vec4 ${o.out} = ${i.vec} * ${i.scale};`,
+ },
+
+ // ─── Color (extended) ────────────────────────────────────────────────────────
+ BlendAdd: {
+ category: 'Color',
+ label: 'Blend Add',
+ inputs: [
+ { id: 'a', label: 'A', type: 'vec4' },
+ { id: 'b', label: 'B', type: 'vec4' },
+ ],
+ outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }],
+ emitGlsl: (i, o) => `vec4 ${o.out} = vec4(clamp(${i.a}.rgb + ${i.b}.rgb, 0.0, 1.0), clamp(${i.a}.a + ${i.b}.a, 0.0, 1.0));`,
+ },
+ BlendScreen: {
+ category: 'Color',
+ label: 'Blend Screen',
+ inputs: [
+ { id: 'a', label: 'A', type: 'vec4' },
+ { id: 'b', label: 'B', type: 'vec4' },
+ ],
+ outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }],
+ emitGlsl: (i, o) => `vec4 ${o.out} = vec4(1.0 - (1.0 - ${i.a}.rgb) * (1.0 - ${i.b}.rgb), max(${i.a}.a, ${i.b}.a));`,
+ },
+ BlendOverlay: {
+ category: 'Color',
+ label: 'Blend Overlay',
+ inputs: [
+ { id: 'a', label: 'A', type: 'vec4' },
+ { id: 'b', label: 'B', type: 'vec4' },
+ ],
+ outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }],
+ emitGlsl: (i, o) =>
+ `vec3 ${o.out}_dark = 2.0 * ${i.a}.rgb * ${i.b}.rgb;\nvec3 ${o.out}_light = 1.0 - 2.0 * (1.0 - ${i.a}.rgb) * (1.0 - ${i.b}.rgb);\nvec4 ${o.out} = vec4(mix(${o.out}_dark, ${o.out}_light, step(vec3(0.5), ${i.a}.rgb)), max(${i.a}.a, ${i.b}.a));`,
+ },
+ Brightness: {
+ category: 'Color',
+ label: 'Brightness',
+ inputs: [
+ { id: 'color', label: 'Color', type: 'vec4' },
+ { id: 'amount', label: 'Amount', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }],
+ emitGlsl: (i, o) => `vec4 ${o.out} = vec4(clamp(${i.color}.rgb * max(${i.amount}, 0.0), 0.0, 1.0), ${i.color}.a);`,
+ },
+ GammaCorrect: {
+ category: 'Color',
+ label: 'Gamma Correct',
+ inputs: [
+ { id: 'color', label: 'Color', type: 'vec4' },
+ { id: 'gamma', label: 'Gamma', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }],
+ emitGlsl: (i, o) => `vec4 ${o.out} = vec4(pow(clamp(${i.color}.rgb, 0.0001, 1.0), vec3(1.0 / max(${i.gamma}, 0.0001))), ${i.color}.a);`,
+ },
+
+ // ─── Noise (extended) ────────────────────────────────────────────────────────
+ GradientNoise: {
+ category: 'Noise',
+ label: 'Gradient Noise',
+ inputs: [
+ { id: 'uv', label: 'UV', type: 'vec2' },
+ { id: 'scale', label: 'Scale', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'Noise', type: 'float' }],
+ helperKey: 'noise',
+ emitGlsl: (i, o) =>
+ `vec2 ${o.out}_p = ${i.uv} * max(${i.scale}, 0.0001);\nvec2 ${o.out}_cell = floor(${o.out}_p);\nvec2 ${o.out}_f = smoothstep(vec2(0.0), vec2(1.0), fract(${o.out}_p));\nfloat ${o.out} = mix(mix(feather_hash(${o.out}_cell), feather_hash(${o.out}_cell + vec2(1.0, 0.0)), ${o.out}_f.x), mix(feather_hash(${o.out}_cell + vec2(0.0, 1.0)), feather_hash(${o.out}_cell + vec2(1.0, 1.0)), ${o.out}_f.x), ${o.out}_f.y);`,
+ },
+ FBMNoise: {
+ category: 'Noise',
+ label: 'FBM Noise',
+ inputs: [
+ { id: 'uv', label: 'UV', type: 'vec2' },
+ { id: 'scale', label: 'Scale', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'Noise', type: 'float' }],
+ helperKey: 'noise',
+ emitGlsl: (i, o) => {
+ const p = `${o.out}_p`;
+ const oct = (n: number, mult: number) =>
+ `vec2 ${o.out}_p${n} = ${p} * ${glslFloat(mult)};\nvec2 ${o.out}_c${n} = floor(${o.out}_p${n});\nvec2 ${o.out}_f${n} = smoothstep(vec2(0.0), vec2(1.0), fract(${o.out}_p${n}));\nfloat ${o.out}_n${n} = mix(mix(feather_hash(${o.out}_c${n}), feather_hash(${o.out}_c${n} + vec2(1.0, 0.0)), ${o.out}_f${n}.x), mix(feather_hash(${o.out}_c${n} + vec2(0.0, 1.0)), feather_hash(${o.out}_c${n} + vec2(1.0, 1.0)), ${o.out}_f${n}.x), ${o.out}_f${n}.y);`;
+ return [
+ `vec2 ${p} = ${i.uv} * max(${i.scale}, 0.0001);`,
+ oct(0, 1), oct(1, 2), oct(2, 4), oct(3, 8),
+ `float ${o.out} = (${o.out}_n0 * 0.5 + ${o.out}_n1 * 0.25 + ${o.out}_n2 * 0.125 + ${o.out}_n3 * 0.0625) / 0.9375;`,
+ ].join('\n');
+ },
+ },
+
+ // ─── UV (extended) ───────────────────────────────────────────────────────────
+ ZoomUV: {
+ category: 'UV',
+ label: 'Zoom UV',
+ inputs: [
+ { id: 'uv', label: 'UV', type: 'vec2' },
+ { id: 'zoom', label: 'Zoom', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'UV', type: 'vec2' }],
+ emitGlsl: (i, o) => `vec2 ${o.out} = (${i.uv} - vec2(0.5)) * max(${i.zoom}, 0.0001) + vec2(0.5);`,
+ },
+ FlipUV: {
+ category: 'UV',
+ label: 'Flip UV',
+ inputs: [{ id: 'uv', label: 'UV', type: 'vec2' }],
+ outputs: [
+ { id: 'flipX', label: 'Flip X', type: 'vec2' },
+ { id: 'flipY', label: 'Flip Y', type: 'vec2' },
+ { id: 'flipXY', label: 'Flip XY', type: 'vec2' },
+ ],
+ emitGlsl: (i, o) =>
+ `vec2 ${o.flipX} = vec2(1.0 - ${i.uv}.x, ${i.uv}.y);\nvec2 ${o.flipY} = vec2(${i.uv}.x, 1.0 - ${i.uv}.y);\nvec2 ${o.flipXY} = vec2(1.0 - ${i.uv}.x, 1.0 - ${i.uv}.y);`,
+ },
+
+ // ─── SDF ─────────────────────────────────────────────────────────────────────
+ SDFCircle: {
+ category: 'SDF',
+ label: 'SDF Circle',
+ inputs: [
+ { id: 'uv', label: 'UV', type: 'vec2' },
+ { id: 'center', label: 'Center', type: 'vec2' },
+ { id: 'radius', label: 'Radius', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'SDF', type: 'float' }],
+ emitGlsl: (i, o) => `float ${o.out} = length(${i.uv} - ${i.center}) - ${i.radius};`,
+ },
+ SDFRect: {
+ category: 'SDF',
+ label: 'SDF Rect',
+ inputs: [
+ { id: 'uv', label: 'UV', type: 'vec2' },
+ { id: 'center', label: 'Center', type: 'vec2' },
+ { id: 'size', label: 'Size', type: 'vec2' },
+ { id: 'corner', label: 'Corner R', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'SDF', type: 'float' }],
+ emitGlsl: (i, o) =>
+ `vec2 ${o.out}_d = abs(${i.uv} - ${i.center}) - ${i.size} * 0.5;\nfloat ${o.out} = length(max(${o.out}_d, 0.0)) + min(max(${o.out}_d.x, ${o.out}_d.y), 0.0) - ${i.corner};`,
+ },
+ SDFSample: {
+ category: 'SDF',
+ label: 'SDF Sample',
+ inputs: [
+ { id: 'sdf', label: 'SDF', type: 'float' },
+ { id: 'offset', label: 'Offset', type: 'float' },
+ { id: 'softness', label: 'Soft', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'Mask', type: 'float' }],
+ emitGlsl: (i, o) => `float ${o.out} = 1.0 - smoothstep(-max(${i.softness}, 0.0001), 0.0, ${i.sdf} - ${i.offset});`,
+ },
};
export const CATEGORY_COLORS: Record = {
@@ -835,6 +1087,7 @@ export const CATEGORY_COLORS: Record = {
Effect: 'border-l-cyan-500',
Output: 'border-l-red-500',
Vertex: 'border-l-yellow-500',
+ SDF: 'border-l-teal-500',
};
export const CATEGORY_ORDER: Array<{ category: string; nodes: NodeType[] }> = [
@@ -867,15 +1120,23 @@ export const CATEGORY_ORDER: Array<{ category: string; nodes: NodeType[] }> = [
'Smoothstep',
'Sin',
'Cos',
+ 'Tan',
'Abs',
'Fract',
'Floor',
+ 'Ceil',
+ 'Round',
+ 'Sign',
'Min',
'Max',
'Modulo',
'Negate',
'Saturate',
'Remap',
+ 'Sqrt',
+ 'Log',
+ 'Exp',
+ 'Atan2',
],
},
{
@@ -897,11 +1158,15 @@ export const CATEGORY_ORDER: Array<{ category: string; nodes: NodeType[] }> = [
'LengthVec2',
'NormalizeVec2',
'DotVec2',
+ 'CrossVec3',
+ 'LerpVec4',
+ 'ScaleVec2',
+ 'ScaleVec4',
],
},
- { category: 'Color', nodes: ['Desaturate', 'OneMinus', 'HueShift', 'InvertColor', 'Contrast', 'PosterizeColor', 'MultiplyColor'] },
- { category: 'Noise', nodes: ['SimpleNoise', 'Ripple', 'VoronoiCells', 'Checkerboard'] },
- { category: 'UV', nodes: ['TilingOffset', 'RotateUV', 'TwirlUV', 'PolarCoordinates'] },
+ { category: 'Color', nodes: ['Desaturate', 'OneMinus', 'HueShift', 'InvertColor', 'Contrast', 'PosterizeColor', 'MultiplyColor', 'BlendAdd', 'BlendScreen', 'BlendOverlay', 'Brightness', 'GammaCorrect'] },
+ { category: 'Noise', nodes: ['SimpleNoise', 'GradientNoise', 'FBMNoise', 'Ripple', 'VoronoiCells', 'Checkerboard'] },
+ { category: 'UV', nodes: ['TilingOffset', 'RotateUV', 'TwirlUV', 'PolarCoordinates', 'ZoomUV', 'FlipUV'] },
{
category: 'Effect',
nodes: [
@@ -923,4 +1188,5 @@ export const CATEGORY_ORDER: Array<{ category: string; nodes: NodeType[] }> = [
},
{ category: 'Output', nodes: ['FragmentOutput'] },
{ category: 'Vertex', nodes: ['VertexPosition', 'VertexWave2D', 'TransformMatrix', 'MatVecMul', 'VertexOutput'] },
+ { category: 'SDF', nodes: ['SDFCircle', 'SDFRect', 'SDFSample'] },
];
diff --git a/src/types/shader-graph.ts b/src/types/shader-graph.ts
index 237236be..5e50abdb 100644
--- a/src/types/shader-graph.ts
+++ b/src/types/shader-graph.ts
@@ -86,11 +86,41 @@ export const NODE_TYPES = [
'SplitVec3',
'Combine2',
'Combine3',
+ // Math
+ 'Sqrt',
+ 'Ceil',
+ 'Round',
+ 'Sign',
+ 'Tan',
+ 'Log',
+ 'Exp',
+ 'Atan2',
+ // Vector
+ 'CrossVec3',
+ 'LerpVec4',
+ 'ScaleVec2',
+ 'ScaleVec4',
+ // Color
+ 'BlendAdd',
+ 'BlendScreen',
+ 'BlendOverlay',
+ 'Brightness',
+ 'GammaCorrect',
+ // Noise
+ 'GradientNoise',
+ 'FBMNoise',
+ // UV
+ 'ZoomUV',
+ 'FlipUV',
+ // SDF
+ 'SDFCircle',
+ 'SDFRect',
+ 'SDFSample',
] as const;
export type NodeType = (typeof NODE_TYPES)[number];
-export type NodeCategory = 'Input' | 'Math' | 'Vector' | 'Color' | 'Noise' | 'UV' | 'Effect' | 'Output' | 'Vertex';
+export type NodeCategory = 'Input' | 'Math' | 'Vector' | 'Color' | 'Noise' | 'UV' | 'Effect' | 'Output' | 'Vertex' | 'SDF';
export type ShaderNodeData = {
label: string;
From d1dc8d83064cc7dd77095af5cf883ff72ea2ab1e Mon Sep 17 00:00:00 2001
From: Roberto Amarante
Date: Thu, 21 May 2026 05:47:41 -0400
Subject: [PATCH 02/38] feather: add more shade graph nodes
---
src/pages/shader-graph/nodeDefs.ts | 109 ++++++++++++++++++++++++--
src/pages/shader-graph/presets.ts | 119 +++++++++++++++++++++++++++++
src/types/shader-graph.ts | 5 ++
3 files changed, 225 insertions(+), 8 deletions(-)
diff --git a/src/pages/shader-graph/nodeDefs.ts b/src/pages/shader-graph/nodeDefs.ts
index 57b9ad6f..9ed4b885 100644
--- a/src/pages/shader-graph/nodeDefs.ts
+++ b/src/pages/shader-graph/nodeDefs.ts
@@ -1040,29 +1040,63 @@ export const NODE_DEFS: Record = {
},
// ─── SDF ─────────────────────────────────────────────────────────────────────
+ SDFLine: {
+ category: 'SDF',
+ label: 'SDF Line',
+ inputs: [
+ { id: 'uv', label: 'UV', type: 'vec2' },
+ { id: 'position', label: 'Position', type: 'float' },
+ { id: 'width', label: 'Width', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'SDF', type: 'float' }],
+ emitGlsl: (i, o) => `float ${o.out} = abs(${i.uv}.x - ${i.position}) - 0.5 * ${i.width};`,
+ },
SDFCircle: {
category: 'SDF',
label: 'SDF Circle',
inputs: [
{ id: 'uv', label: 'UV', type: 'vec2' },
- { id: 'center', label: 'Center', type: 'vec2' },
+ { id: 'position', label: 'Position', type: 'vec2' },
{ id: 'radius', label: 'Radius', type: 'float' },
],
outputs: [{ id: 'out', label: 'SDF', type: 'float' }],
- emitGlsl: (i, o) => `float ${o.out} = length(${i.uv} - ${i.center}) - ${i.radius};`,
+ emitGlsl: (i, o) => `float ${o.out} = length(${i.uv} - ${i.position}) - ${i.radius};`,
},
SDFRect: {
category: 'SDF',
label: 'SDF Rect',
inputs: [
{ id: 'uv', label: 'UV', type: 'vec2' },
- { id: 'center', label: 'Center', type: 'vec2' },
- { id: 'size', label: 'Size', type: 'vec2' },
+ { id: 'position', label: 'Position', type: 'vec2' },
+ { id: 'width', label: 'Width', type: 'float' },
+ { id: 'height', label: 'Height', type: 'float' },
{ id: 'corner', label: 'Corner R', type: 'float' },
],
outputs: [{ id: 'out', label: 'SDF', type: 'float' }],
emitGlsl: (i, o) =>
- `vec2 ${o.out}_d = abs(${i.uv} - ${i.center}) - ${i.size} * 0.5;\nfloat ${o.out} = length(max(${o.out}_d, 0.0)) + min(max(${o.out}_d.x, ${o.out}_d.y), 0.0) - ${i.corner};`,
+ `vec2 ${o.out}_d = abs(${i.uv} - ${i.position}) - 0.5 * vec2(${i.width}, ${i.height});\nfloat ${o.out} = min(max(${o.out}_d.x, ${o.out}_d.y), 0.0) + length(max(${o.out}_d, vec2(0.0))) - ${i.corner};`,
+ },
+ SDFPolygon: {
+ category: 'SDF',
+ label: 'SDF Polygon',
+ inputs: [
+ { id: 'uv', label: 'UV', type: 'vec2' },
+ { id: 'position', label: 'Position', type: 'vec2' },
+ { id: 'radius', label: 'Radius', type: 'float' },
+ { id: 'sides', label: 'Sides', type: 'float' },
+ { id: 'corner', label: 'Corner R', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'SDF', type: 'float' }],
+ emitGlsl: (i, o) => [
+ `vec2 ${o.out}_f = ${i.uv} - ${i.position};`,
+ `float ${o.out}_angle = 6.2831853071 / max(${i.sides}, 3.0);`,
+ `float ${o.out}_snap = round(atan(${o.out}_f.y, ${o.out}_f.x) / ${o.out}_angle) * ${o.out}_angle;`,
+ `vec2 ${o.out}_n = vec2(cos(${o.out}_snap), sin(${o.out}_snap));`,
+ `vec2 ${o.out}_d = vec2(sin(${o.out}_snap), -cos(${o.out}_snap));`,
+ `float ${o.out}_sl = ${i.radius} * tan(0.5 * ${o.out}_angle);`,
+ `float ${o.out}_t = dot(${o.out}_d, ${o.out}_f);`,
+ `float ${o.out} = (abs(${o.out}_t) < ${o.out}_sl ? dot(${o.out}_f, ${o.out}_n) - ${i.radius} : length(${o.out}_f - (${i.radius} * ${o.out}_n + ${o.out}_d * clamp(${o.out}_t, -${o.out}_sl, ${o.out}_sl)))) - ${i.corner};`,
+ ].join('\n'),
},
SDFSample: {
category: 'SDF',
@@ -1070,10 +1104,69 @@ export const NODE_DEFS: Record = {
inputs: [
{ id: 'sdf', label: 'SDF', type: 'float' },
{ id: 'offset', label: 'Offset', type: 'float' },
- { id: 'softness', label: 'Soft', type: 'float' },
],
outputs: [{ id: 'out', label: 'Mask', type: 'float' }],
- emitGlsl: (i, o) => `float ${o.out} = 1.0 - smoothstep(-max(${i.softness}, 0.0001), 0.0, ${i.sdf} - ${i.offset});`,
+ emitGlsl: (i, o) => [
+ `float ${o.out}_d = ${i.sdf} - ${i.offset};`,
+ `float ${o.out} = clamp(-${o.out}_d / max(fwidth(${o.out}_d), 0.0001), 0.0, 1.0);`,
+ ].join('\n'),
+ },
+ SDFSampleStrip: {
+ category: 'SDF',
+ label: 'SDF Sample Strip',
+ inputs: [
+ { id: 'sdf', label: 'SDF', type: 'float' },
+ { id: 'offsetMin', label: 'Min', type: 'float' },
+ { id: 'offsetMax', label: 'Max', type: 'float' },
+ ],
+ outputs: [{ id: 'out', label: 'Mask', type: 'float' }],
+ emitGlsl: (i, o) => [
+ `float ${o.out}_d = max(-(${i.sdf} - ${i.offsetMin}), ${i.sdf} - ${i.offsetMax});`,
+ `float ${o.out} = clamp(-${o.out}_d / max(fwidth(${o.out}_d), 0.0001), 0.0, 1.0);`,
+ ].join('\n'),
+ },
+ SDFBoolean: {
+ category: 'SDF',
+ label: 'SDF Boolean',
+ inputs: [
+ { id: 'a', label: 'A', type: 'float' },
+ { id: 'b', label: 'B', type: 'float' },
+ ],
+ outputs: [
+ { id: 'union', label: 'Union', type: 'float' },
+ { id: 'intersect', label: 'Intersect', type: 'float' },
+ { id: 'diff', label: 'Diff A-B', type: 'float' },
+ ],
+ emitGlsl: (i, o) => [
+ `float ${o.union} = min(${i.a}, ${i.b});`,
+ `float ${o.intersect} = max(${i.a}, ${i.b});`,
+ `float ${o.diff} = max(${i.a}, -${i.b});`,
+ ].join('\n'),
+ },
+ SDFSoftBoolean: {
+ category: 'SDF',
+ label: 'SDF Soft Boolean',
+ inputs: [
+ { id: 'a', label: 'A', type: 'float' },
+ { id: 'b', label: 'B', type: 'float' },
+ { id: 'smoothing', label: 'Smooth', type: 'float' },
+ ],
+ outputs: [
+ { id: 'union', label: 'Union', type: 'float' },
+ { id: 'intersect', label: 'Intersect', type: 'float' },
+ { id: 'diff', label: 'Diff A-B', type: 'float' },
+ ],
+ emitGlsl: (i, o) => {
+ const s = `max(${i.smoothing}, 0.0001)`;
+ return [
+ `float ${o.union}_t = clamp(0.5 * (1.0 + (${i.b} - ${i.a}) / ${s}), 0.0, 1.0);`,
+ `float ${o.union} = mix(${i.b}, ${i.a}, ${o.union}_t) - ${s} * ${o.union}_t * (1.0 - ${o.union}_t);`,
+ `float ${o.intersect}_t = clamp(0.5 * (1.0 + (${i.a} - ${i.b}) / ${s}), 0.0, 1.0);`,
+ `float ${o.intersect} = -(mix(-${i.b}, -${i.a}, ${o.intersect}_t) - ${s} * ${o.intersect}_t * (1.0 - ${o.intersect}_t));`,
+ `float ${o.diff}_t = clamp(0.5 * (1.0 + (${i.a} + ${i.b}) / ${s}), 0.0, 1.0);`,
+ `float ${o.diff} = -(mix(${i.b}, -${i.a}, ${o.diff}_t) - ${s} * ${o.diff}_t * (1.0 - ${o.diff}_t));`,
+ ].join('\n');
+ },
},
};
@@ -1188,5 +1281,5 @@ export const CATEGORY_ORDER: Array<{ category: string; nodes: NodeType[] }> = [
},
{ category: 'Output', nodes: ['FragmentOutput'] },
{ category: 'Vertex', nodes: ['VertexPosition', 'VertexWave2D', 'TransformMatrix', 'MatVecMul', 'VertexOutput'] },
- { category: 'SDF', nodes: ['SDFCircle', 'SDFRect', 'SDFSample'] },
+ { category: 'SDF', nodes: ['SDFLine', 'SDFCircle', 'SDFRect', 'SDFPolygon', 'SDFSample', 'SDFSampleStrip', 'SDFBoolean', 'SDFSoftBoolean'] },
];
diff --git a/src/pages/shader-graph/presets.ts b/src/pages/shader-graph/presets.ts
index 09bd3668..337fb349 100644
--- a/src/pages/shader-graph/presets.ts
+++ b/src/pages/shader-graph/presets.ts
@@ -438,6 +438,125 @@ export const SHADER_GRAPH_PRESETS: ShaderGraphPreset[] = [
{ from: 'sample', out: 'out', to: 'out', in: 'color' },
],
),
+ preset(
+ 'sdf-circle-mask',
+ 'SDF Circle Mask',
+ 'Clips the texture to a soft anti-aliased circle using a signed distance field.',
+ 'sdf-circle-mask',
+ [
+ { id: 'uv', type: 'TextureCoords', x: 0, y: 0 },
+ { id: 'tex', type: 'TextureColor', x: 0, y: 130 },
+ { id: 'center', type: 'Vec2Constant', x: 0, y: 260, values: { val: [0.5, 0.5] } },
+ { id: 'radius', type: 'FloatConstant', x: 0, y: 400, values: { val: 0.42 } },
+ { id: 'sdf', type: 'SDFCircle', x: 340, y: 200 },
+ { id: 'sample', type: 'SDFSample', x: 630, y: 230 },
+ { id: 'opacity', type: 'Opacity2D', x: 900, y: 150 },
+ { id: 'out', type: 'FragmentOutput', x: 1180, y: 175 },
+ ],
+ [
+ { from: 'uv', out: 'out', to: 'sdf', in: 'uv' },
+ { from: 'center', out: 'out', to: 'sdf', in: 'position' },
+ { from: 'radius', out: 'out', to: 'sdf', in: 'radius' },
+ { from: 'sdf', out: 'out', to: 'sample', in: 'sdf' },
+ { from: 'tex', out: 'out', to: 'opacity', in: 'color' },
+ { from: 'sample', out: 'out', to: 'opacity', in: 'opacity' },
+ { from: 'opacity', out: 'out', to: 'out', in: 'color' },
+ ],
+ ),
+ preset(
+ 'sdf-ring-glow',
+ 'SDF Ring Glow',
+ 'Samples the strip outline of a circle SDF to overlay a glowing ring on the texture.',
+ 'sdf-ring-glow',
+ [
+ { id: 'uv', type: 'TextureCoords', x: 0, y: 0 },
+ { id: 'tex', type: 'TextureColor', x: 0, y: 130 },
+ { id: 'center', type: 'Vec2Constant', x: 0, y: 260, values: { val: [0.5, 0.5] } },
+ { id: 'radius', type: 'FloatConstant', x: 0, y: 400, values: { val: 0.38 } },
+ { id: 'ring-min', type: 'FloatConstant', x: 0, y: 520, values: { val: -0.04 } },
+ { id: 'ring-max', type: 'FloatConstant', x: 0, y: 640, values: { val: 0.04 } },
+ { id: 'glow-color', type: 'Vec4Constant', x: 0, y: 760, values: { val: [0.2, 0.9, 1, 1] } },
+ { id: 'sdf', type: 'SDFCircle', x: 340, y: 300 },
+ { id: 'strip', type: 'SDFSampleStrip', x: 640, y: 330 },
+ { id: 'flash', type: 'HitFlash', x: 930, y: 230 },
+ { id: 'out', type: 'FragmentOutput', x: 1210, y: 255 },
+ ],
+ [
+ { from: 'uv', out: 'out', to: 'sdf', in: 'uv' },
+ { from: 'center', out: 'out', to: 'sdf', in: 'position' },
+ { from: 'radius', out: 'out', to: 'sdf', in: 'radius' },
+ { from: 'sdf', out: 'out', to: 'strip', in: 'sdf' },
+ { from: 'ring-min', out: 'out', to: 'strip', in: 'offsetMin' },
+ { from: 'ring-max', out: 'out', to: 'strip', in: 'offsetMax' },
+ { from: 'tex', out: 'out', to: 'flash', in: 'color' },
+ { from: 'glow-color', out: 'out', to: 'flash', in: 'flashColor' },
+ { from: 'strip', out: 'out', to: 'flash', in: 'amount' },
+ { from: 'flash', out: 'out', to: 'out', in: 'color' },
+ ],
+ ),
+ preset(
+ 'sdf-hex-badge',
+ 'SDF Hex Badge',
+ 'Clips the texture to a hexagonal shape with rounded corners using SDFPolygon.',
+ 'sdf-hex-badge',
+ [
+ { id: 'uv', type: 'TextureCoords', x: 0, y: 0 },
+ { id: 'tex', type: 'TextureColor', x: 0, y: 130 },
+ { id: 'center', type: 'Vec2Constant', x: 0, y: 260, values: { val: [0.5, 0.5] } },
+ { id: 'radius', type: 'FloatConstant', x: 0, y: 400, values: { val: 0.42 } },
+ { id: 'sides', type: 'FloatConstant', x: 0, y: 520, values: { val: 6 } },
+ { id: 'corner', type: 'FloatConstant', x: 0, y: 640, values: { val: 0.02 } },
+ { id: 'sdf', type: 'SDFPolygon', x: 340, y: 290 },
+ { id: 'sample', type: 'SDFSample', x: 650, y: 320 },
+ { id: 'opacity', type: 'Opacity2D', x: 920, y: 230 },
+ { id: 'out', type: 'FragmentOutput', x: 1200, y: 255 },
+ ],
+ [
+ { from: 'uv', out: 'out', to: 'sdf', in: 'uv' },
+ { from: 'center', out: 'out', to: 'sdf', in: 'position' },
+ { from: 'radius', out: 'out', to: 'sdf', in: 'radius' },
+ { from: 'sides', out: 'out', to: 'sdf', in: 'sides' },
+ { from: 'corner', out: 'out', to: 'sdf', in: 'corner' },
+ { from: 'sdf', out: 'out', to: 'sample', in: 'sdf' },
+ { from: 'tex', out: 'out', to: 'opacity', in: 'color' },
+ { from: 'sample', out: 'out', to: 'opacity', in: 'opacity' },
+ { from: 'opacity', out: 'out', to: 'out', in: 'color' },
+ ],
+ ),
+ preset(
+ 'sdf-crescent',
+ 'SDF Crescent',
+ 'Subtracts one circle SDF from another using SDFBoolean Diff to produce a crescent shape.',
+ 'sdf-crescent',
+ [
+ { id: 'uv', type: 'TextureCoords', x: 0, y: 0 },
+ { id: 'center-a', type: 'Vec2Constant', x: 0, y: 130, values: { val: [0.45, 0.5] } },
+ { id: 'center-b', type: 'Vec2Constant', x: 0, y: 270, values: { val: [0.6, 0.5] } },
+ { id: 'radius-a', type: 'FloatConstant', x: 0, y: 400, values: { val: 0.38 } },
+ { id: 'radius-b', type: 'FloatConstant', x: 0, y: 520, values: { val: 0.3 } },
+ { id: 'fill-color', type: 'Vec4Constant', x: 0, y: 640, values: { val: [0.9, 0.8, 0.2, 1] } },
+ { id: 'sdf-a', type: 'SDFCircle', x: 340, y: 90 },
+ { id: 'sdf-b', type: 'SDFCircle', x: 340, y: 340 },
+ { id: 'boolean', type: 'SDFBoolean', x: 660, y: 195 },
+ { id: 'sample', type: 'SDFSample', x: 950, y: 225 },
+ { id: 'opacity', type: 'Opacity2D', x: 1220, y: 145 },
+ { id: 'out', type: 'FragmentOutput', x: 1500, y: 170 },
+ ],
+ [
+ { from: 'uv', out: 'out', to: 'sdf-a', in: 'uv' },
+ { from: 'center-a', out: 'out', to: 'sdf-a', in: 'position' },
+ { from: 'radius-a', out: 'out', to: 'sdf-a', in: 'radius' },
+ { from: 'uv', out: 'out', to: 'sdf-b', in: 'uv' },
+ { from: 'center-b', out: 'out', to: 'sdf-b', in: 'position' },
+ { from: 'radius-b', out: 'out', to: 'sdf-b', in: 'radius' },
+ { from: 'sdf-a', out: 'out', to: 'boolean', in: 'a' },
+ { from: 'sdf-b', out: 'out', to: 'boolean', in: 'b' },
+ { from: 'boolean', out: 'diff', to: 'sample', in: 'sdf' },
+ { from: 'fill-color', out: 'out', to: 'opacity', in: 'color' },
+ { from: 'sample', out: 'out', to: 'opacity', in: 'opacity' },
+ { from: 'opacity', out: 'out', to: 'out', in: 'color' },
+ ],
+ ),
preset(
'masked-water-shimmer',
'Masked Water Shimmer',
diff --git a/src/types/shader-graph.ts b/src/types/shader-graph.ts
index 5e50abdb..7f43d60f 100644
--- a/src/types/shader-graph.ts
+++ b/src/types/shader-graph.ts
@@ -113,9 +113,14 @@ export const NODE_TYPES = [
'ZoomUV',
'FlipUV',
// SDF
+ 'SDFLine',
'SDFCircle',
'SDFRect',
+ 'SDFPolygon',
'SDFSample',
+ 'SDFSampleStrip',
+ 'SDFBoolean',
+ 'SDFSoftBoolean',
] as const;
export type NodeType = (typeof NODE_TYPES)[number];
From fb159515f8350b0043bf73c647d50ec103af02cd Mon Sep 17 00:00:00 2001
From: Roberto Amarante
Date: Fri, 22 May 2026 01:28:01 -0400
Subject: [PATCH 03/38] feather: add shader preview
---
package-lock.json | 430 -------------------------
src-lua/plugins/shader-graph/README.md | 41 ++-
src-lua/plugins/shader-graph/init.lua | 180 ++++++++++-
src/hooks/use-shader-graph.ts | 28 +-
src/pages/shader-graph/CodePreview.tsx | 89 ++++-
src/pages/shader-graph/nodeDefs.ts | 4 +-
src/pages/shader-graph/presets.ts | 2 +-
7 files changed, 329 insertions(+), 445 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 8b5f3779..3dd26b79 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -785,422 +785,6 @@
"tslib": "^2.4.0"
}
},
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
- "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
- "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
- "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
- "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
- "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
- "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
- "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
- "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
- "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
- "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
- "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
- "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
- "cpu": [
- "loong64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
- "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
- "cpu": [
- "mips64el"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
- "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
- "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
- "cpu": [
- "riscv64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
- "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
- "cpu": [
- "s390x"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
- "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
- "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
- "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
- "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
- "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
- "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
- "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
- "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -12284,20 +11868,6 @@
"@esbuild/win32-x64": "0.27.7"
}
},
- "node_modules/tsx/node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
"node_modules/tunnel": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
diff --git a/src-lua/plugins/shader-graph/README.md b/src-lua/plugins/shader-graph/README.md
index 9f09c0d4..8c6457fe 100644
--- a/src-lua/plugins/shader-graph/README.md
+++ b/src-lua/plugins/shader-graph/README.md
@@ -16,8 +16,9 @@ Several higher-level nodes and presets are also inspired by common VFX Shader Gr
4. Connect compatible ports by type.
5. Connect the final `vec4` color into **Fragment Output**.
6. Use **Validate** to compile in the running LÖVE game.
-7. Use **Apply** to send the generated shader to the selected Particle System Playground emitter.
-8. Export/import `.feathershgh` files when you want to save or share editable graph projects.
+7. Toggle **Preview On** to draw the shader on a temporary circle, line, or rectangle in the center of the running game when you are not ready to apply it to particles. While preview is enabled, graph edits re-apply to the preview automatically.
+8. Use **Apply** to send the generated shader to the selected Particle System Playground emitter.
+9. Export/import `.feathershgh` files when you want to save or share editable graph projects.
Select a node and edit **Node Name** in the inspector when a graph needs more descriptive labels. Renaming a node changes the canvas label only; the original node type stays visible in the inspector and code generation is unchanged.
@@ -105,7 +106,7 @@ Effect nodes are higher-level building blocks for common 2D game shaders.
| Opacity | Multiplies texture alpha by a scalar fade value |
| Centered UV | Converts UV into `uv - 0.5` and distance from center |
| Fresnel / Rim 2D | Radial edge mask for glow/rim effects on sprites and particles |
-| Alpha Outline | Samples neighboring alpha to create sprite outlines |
+| Sprite Outline | Samples neighboring alpha around the current sprite to create a configurable outline color |
| Wave Distort | Animated UV wave for water, heat, magic, flags |
| Water Displace | Procedural animated noise displacement for water, heat shimmer, and magic surfaces |
| Masked Water | Water displacement constrained by texture alpha, useful when only opaque regions should move |
@@ -200,9 +201,9 @@ Use this for portals, rotating particles, scrolling texture strips, and tiled ma
### Outline
-`Texture Color + Texture Coords + Float thickness + Vec4 outline color -> Alpha Outline -> Fragment Output`
+`Texture Color + Texture Coords + Float thickness + Vec4 outline color -> Sprite Outline -> Fragment Output`
-Good for sprites, interactable objects, and particle silhouettes.
+Good for sprites, interactable objects, and particle silhouettes. The outline node paints transparent pixels adjacent to sprite alpha, so the original sprite remains unchanged while the outline color fills the silhouette edge.
### Dissolve
@@ -269,9 +270,37 @@ Attempts to compile the provided GLSL source using `love.graphics.newShader`. Re
`pixelError` / `vertexError` are `nil` when that stage compiled successfully.
+### `preview-shader`
+
+Compiles the provided GLSL source, creates a temporary padded shape texture, and draws it in-game with the shader until preview is cleared. This is useful for sprite shaders, especially outline graphs, before applying the shader to a Particle System Playground emitter.
+
+**Params**
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `pixelSource` | `string` | GLSL pixel (fragment) shader source |
+| `vertexSource` | `string` | GLSL vertex shader source (optional) |
+| `shape` | `string` | `circle`, `line`, or `rectangle`; defaults to `circle` |
+| `size` | `number` | Temporary texture size in pixels; defaults to `128` |
+
+**Response**
+
+```lua
+-- success
+{ status = "ok", shape = "circle" }
+
+-- failure
+{ status = "error", pixelError = "...", vertexError = "..." }
+```
+
+### `clear-preview`
+
+Clears the active temporary shader preview.
+
## Notes
- Validation runs on the game process — a live LÖVE session must be connected.
- The plugin uses `pcall` so a bad shader never crashes the game.
-- No draw calls are made; the shader object is discarded immediately after compilation.
+- Validation discards shader objects immediately; previews keep the shader and temporary shape canvas only for the preview window.
- When vertex source is provided, validation compiles the combined pixel + vertex source because Feather applies shader graph output as a single LÖVE shader source.
+- Runtime preview is drawn by the Shader Graph plugin itself, so it does not require a Particle System Playground target.
diff --git a/src-lua/plugins/shader-graph/init.lua b/src-lua/plugins/shader-graph/init.lua
index 4b4fb1f6..c7df56a8 100644
--- a/src-lua/plugins/shader-graph/init.lua
+++ b/src-lua/plugins/shader-graph/init.lua
@@ -3,8 +3,88 @@ local Base = require(FEATHER_PATH .. ".core.base")
local ShaderGraphPlugin = Class({ __includes = Base })
-function ShaderGraphPlugin:initialize(feather)
- Base.initialize(self, feather)
+local PREVIEW_SHAPES = {
+ circle = true,
+ line = true,
+ rectangle = true,
+}
+
+local DEFAULT_PREVIEW_SIZE = 128
+
+local function restoreCanvas(canvas)
+ if canvas then
+ love.graphics.setCanvas(canvas)
+ else
+ love.graphics.setCanvas()
+ end
+end
+
+local function buildShader(pixelSource, vertexSource)
+ pixelSource = pixelSource or ""
+ vertexSource = vertexSource or ""
+
+ local pixelOk, pixelShaderOrErr = pcall(function()
+ return love.graphics.newShader(pixelSource)
+ end)
+
+ if not pixelOk then
+ return nil, tostring(pixelShaderOrErr), nil
+ end
+
+ if vertexSource ~= "" then
+ local combinedOk, combinedShaderOrErr = pcall(function()
+ return love.graphics.newShader(pixelSource .. "\n" .. vertexSource)
+ end)
+ if not combinedOk then
+ return nil, nil, tostring(combinedShaderOrErr)
+ end
+ return combinedShaderOrErr
+ end
+
+ return pixelShaderOrErr
+end
+
+local function makePreviewCanvas(shape, size)
+ size = tonumber(size) or DEFAULT_PREVIEW_SIZE
+ if size < 32 then
+ size = 32
+ elseif size > 512 then
+ size = 512
+ end
+
+ local canvas = love.graphics.newCanvas(size, size)
+ local previousCanvas = love.graphics.getCanvas()
+ local previousShader = love.graphics.getShader()
+ local previousBlend, previousAlphaMode = love.graphics.getBlendMode()
+ local previousLineWidth = love.graphics.getLineWidth()
+ local r, g, b, a = love.graphics.getColor()
+ local pad = size * 0.22
+
+ love.graphics.push()
+ love.graphics.setCanvas(canvas)
+ love.graphics.origin()
+ love.graphics.clear(0, 0, 0, 0)
+ love.graphics.setShader()
+ love.graphics.setBlendMode("alpha")
+ love.graphics.setColor(1, 1, 1, 1)
+
+ if shape == "rectangle" then
+ love.graphics.rectangle("fill", pad, pad, size - pad * 2, size - pad * 2)
+ elseif shape == "line" then
+ love.graphics.setLineWidth(math.max(6, size * 0.12))
+ love.graphics.line(pad, size - pad, size - pad, pad)
+ else
+ love.graphics.circle("fill", size / 2, size / 2, size * 0.3)
+ end
+
+ restoreCanvas(previousCanvas)
+ love.graphics.pop()
+ love.graphics.setShader(previousShader)
+ love.graphics.setBlendMode(previousBlend, previousAlphaMode)
+ love.graphics.setLineWidth(previousLineWidth)
+ love.graphics.setColor(r, g, b, a)
+
+ return canvas
end
local function compileShader(params)
@@ -37,6 +117,89 @@ local function compileShader(params)
return { status = "ok" }
end
+function ShaderGraphPlugin:init(config)
+ Base.init(self, config)
+ self.preview = nil
+end
+
+function ShaderGraphPlugin:_previewShader(params)
+ if not love or not love.graphics then
+ return { status = "error", pixelError = "Shader preview requires love.graphics." }
+ end
+
+ local shape = tostring(params.shape or "circle")
+ if not PREVIEW_SHAPES[shape] then
+ shape = "circle"
+ end
+
+ local shader, pixelError, vertexError = buildShader(params.pixelSource, params.vertexSource)
+ if not shader then
+ return { status = "error", pixelError = pixelError, vertexError = vertexError }
+ end
+
+ local okCanvas, canvasOrErr = pcall(makePreviewCanvas, shape, params.size)
+ if not okCanvas then
+ return { status = "error", pixelError = tostring(canvasOrErr) }
+ end
+
+ self.preview = {
+ shader = shader,
+ canvas = canvasOrErr,
+ shape = shape,
+ updatedAt = love.timer and love.timer.getTime() or os.clock(),
+ }
+
+ return { status = "ok", shape = shape }
+end
+
+function ShaderGraphPlugin:onDraw()
+ if not self.preview or not love or not love.graphics then
+ return
+ end
+
+ local canvas = self.preview.canvas
+ local shader = self.preview.shader
+ if not canvas or not shader then
+ return
+ end
+
+ local previousBlend, previousAlphaMode = love.graphics.getBlendMode()
+ local previousShader = love.graphics.getShader()
+ local r, g, b, a = love.graphics.getColor()
+ local previousLineWidth = love.graphics.getLineWidth()
+ local width = love.graphics.getWidth()
+ local height = love.graphics.getHeight()
+ local previewSize = math.min(280, math.max(128, math.min(width, height) * 0.42))
+ local scale = previewSize / canvas:getWidth()
+ local x = (width - previewSize) / 2
+ local y = (height - previewSize) / 2
+
+ love.graphics.push()
+ love.graphics.origin()
+ love.graphics.setShader()
+ love.graphics.setBlendMode("alpha")
+ love.graphics.setColor(0.04, 0.05, 0.07, 0.74)
+ love.graphics.rectangle("fill", x - 10, y - 10, previewSize + 20, previewSize + 42, 6, 6)
+ love.graphics.setColor(1, 1, 1, 0.22)
+ love.graphics.setLineWidth(1)
+ love.graphics.rectangle("line", x - 10, y - 10, previewSize + 20, previewSize + 42, 6, 6)
+ love.graphics.setColor(1, 1, 1, 0.72)
+ love.graphics.print("Shader Preview: " .. self.preview.shape, x - 4, y + previewSize + 12)
+
+ if shader.send and love.timer then
+ pcall(shader.send, shader, "u_time", love.timer.getTime())
+ end
+ love.graphics.setShader(shader)
+ love.graphics.setColor(1, 1, 1, 1)
+ love.graphics.draw(canvas, x, y, 0, scale, scale)
+
+ love.graphics.setBlendMode(previousBlend, previousAlphaMode)
+ love.graphics.setShader(previousShader)
+ love.graphics.setColor(r, g, b, a)
+ love.graphics.setLineWidth(previousLineWidth)
+ love.graphics.pop()
+end
+
function ShaderGraphPlugin:handleActionRequest(request)
local params = request.params or {}
local action = params.action
@@ -45,6 +208,15 @@ function ShaderGraphPlugin:handleActionRequest(request)
return compileShader(params)
end
+ if action == "preview-shader" then
+ return self:_previewShader(params)
+ end
+
+ if action == "clear-preview" then
+ self.preview = nil
+ return { status = "ok" }
+ end
+
return { status = "error", pixelError = "Unknown shader graph action: " .. tostring(action) }
end
@@ -52,6 +224,10 @@ function ShaderGraphPlugin:getConfig()
return {
type = "shader-graph",
icon = "blend",
+ preview = self.preview and {
+ shape = self.preview.shape,
+ active = true,
+ } or nil,
}
end
diff --git a/src/hooks/use-shader-graph.ts b/src/hooks/use-shader-graph.ts
index d50c2b99..ed895215 100644
--- a/src/hooks/use-shader-graph.ts
+++ b/src/hooks/use-shader-graph.ts
@@ -9,6 +9,8 @@ import { codegen } from '@/pages/shader-graph/codegen';
const PLUGIN_ID = 'particle-system-playground';
const SHADER_GRAPH_PLUGIN = 'shader-graph';
+export type ShaderPreviewShape = 'circle' | 'line' | 'rectangle';
+
type CompilePayload = { status: 'ok' | 'error'; pixelError?: string; vertexError?: string };
type CompileResponse = {
status?: 'success' | 'error' | 'ok';
@@ -70,6 +72,30 @@ export function useShaderGraph() {
});
}, [generateAndStore, sessionId, store]);
+ const previewShader = useCallback(
+ async (shape: ShaderPreviewShape) => {
+ if (!sessionId) return;
+ const glsl = generateAndStore();
+ await sendCommand(sessionId, {
+ type: 'cmd:plugin:action',
+ plugin: SHADER_GRAPH_PLUGIN,
+ action: 'preview-shader',
+ params: { pixelSource: glsl.pixel, vertexSource: glsl.vertex ?? '', shape },
+ });
+ },
+ [generateAndStore, sessionId],
+ );
+
+ const clearPreview = useCallback(async () => {
+ if (!sessionId) return;
+ await sendCommand(sessionId, {
+ type: 'cmd:plugin:action',
+ plugin: SHADER_GRAPH_PLUGIN,
+ action: 'clear-preview',
+ params: {},
+ });
+ }, [sessionId]);
+
const compileResult = compileQuery.data;
useEffect(() => {
@@ -95,5 +121,5 @@ export function useShaderGraph() {
});
}, [compileResult, store]);
- return { ...store, generateAndStore, validateShader, applyToPlayground };
+ return { ...store, generateAndStore, validateShader, applyToPlayground, previewShader, clearPreview };
}
diff --git a/src/pages/shader-graph/CodePreview.tsx b/src/pages/shader-graph/CodePreview.tsx
index 4f0e4069..cf4475b6 100644
--- a/src/pages/shader-graph/CodePreview.tsx
+++ b/src/pages/shader-graph/CodePreview.tsx
@@ -1,15 +1,30 @@
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import SyntaxHighlighter from 'react-syntax-highlighter';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
-import { useShaderGraph } from '@/hooks/use-shader-graph';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { type ShaderPreviewShape, useShaderGraph } from '@/hooks/use-shader-graph';
import { useSessionStore } from '@/store/session';
import { useTheme } from '@/hooks/use-theme';
import oneLight from '@/assets/theme/light';
import onDark from '@/assets/theme/dark';
import { cn } from '@/utils/styles';
-import { CheckIcon, CopyIcon } from 'lucide-react';
+import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from 'lucide-react';
+
+function useDebouncedValue(value: T, delayMs: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timeout = window.setTimeout(() => {
+ setDebouncedValue(value);
+ }, delayMs);
+
+ return () => window.clearTimeout(timeout);
+ }, [delayMs, value]);
+
+ return debouncedValue;
+}
export function CodePreview() {
const sessionId = useSessionStore((s) => s.sessionId);
@@ -17,14 +32,24 @@ export function CodePreview() {
lastGeneratedGlsl,
validationStatus,
validationErrors,
+ nodes,
+ edges,
generateAndStore,
validateShader,
applyToPlayground,
+ previewShader,
+ clearPreview,
playgroundTarget,
} = useShaderGraph();
const theme = useTheme();
const hlTheme = theme === 'dark' ? onDark : oneLight;
const [copied, setCopied] = useState(false);
+ const [previewShape, setPreviewShape] = useState('circle');
+ const [previewEnabled, setPreviewEnabled] = useState(false);
+ const previewShaderRef = useRef(previewShader);
+ const debouncedNodes = useDebouncedValue(nodes, 180);
+ const debouncedEdges = useDebouncedValue(edges, 180);
+ const debouncedPreviewShape = useDebouncedValue(previewShape, 180);
const glsl = lastGeneratedGlsl ?? generateAndStore();
const fullSource = glsl.vertex
@@ -38,6 +63,36 @@ export function CodePreview() {
});
}
+ async function handlePreviewToggle() {
+ if (!sessionId) return;
+ if (previewEnabled) {
+ await clearPreview();
+ setPreviewEnabled(false);
+ return;
+ }
+ setPreviewEnabled(true);
+ }
+
+ function handlePreviewShapeChange(value: string) {
+ const nextShape = value as ShaderPreviewShape;
+ setPreviewShape(nextShape);
+ }
+
+ useEffect(() => {
+ previewShaderRef.current = previewShader;
+ }, [previewShader]);
+
+ useEffect(() => {
+ if (!sessionId) {
+ setPreviewEnabled(false);
+ }
+ }, [sessionId]);
+
+ useEffect(() => {
+ if (!sessionId || !previewEnabled) return;
+ void previewShaderRef.current(debouncedPreviewShape);
+ }, [debouncedEdges, debouncedNodes, debouncedPreviewShape, previewEnabled, sessionId]);
+
const statusColor = {
idle: 'bg-muted text-muted-foreground',
validating: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400',
@@ -133,6 +188,34 @@ export function CodePreview() {
Apply
+
+
+
+
+
);
}
diff --git a/src/pages/shader-graph/nodeDefs.ts b/src/pages/shader-graph/nodeDefs.ts
index 9ed4b885..dcf87acc 100644
--- a/src/pages/shader-graph/nodeDefs.ts
+++ b/src/pages/shader-graph/nodeDefs.ts
@@ -605,7 +605,7 @@ export const NODE_DEFS: Record = {
},
Outline2D: {
category: 'Effect',
- label: 'Alpha Outline',
+ label: 'Sprite Outline',
inputs: [
{ id: 'color', label: 'Color', type: 'vec4' },
{ id: 'uv', label: 'UV', type: 'vec2' },
@@ -614,7 +614,7 @@ export const NODE_DEFS: Record = {
],
outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }],
emitGlsl: (i, o) =>
- `vec2 ${o.out}_px = ${i.thickness} / love_ScreenSize.xy;\nfloat ${o.out}_a = max(max(Texel(tex, ${i.uv} + vec2(${o.out}_px.x, 0.0)).a, Texel(tex, ${i.uv} - vec2(${o.out}_px.x, 0.0)).a), max(Texel(tex, ${i.uv} + vec2(0.0, ${o.out}_px.y)).a, Texel(tex, ${i.uv} - vec2(0.0, ${o.out}_px.y)).a));\nvec4 ${o.out} = mix(vec4(${i.outlineColor}.rgb, ${o.out}_a * ${i.outlineColor}.a), ${i.color}, ${i.color}.a);`,
+ `vec2 ${o.out}_uv_step = max(fwidth(${i.uv}), vec2(0.000001)) * max(${i.thickness}, 0.0);\nfloat ${o.out}_source_alpha = clamp(${i.color}.a, 0.0, 1.0);\nfloat ${o.out}_neighbor_alpha = 0.0;\n${o.out}_neighbor_alpha = max(${o.out}_neighbor_alpha, Texel(tex, ${i.uv} + vec2(${o.out}_uv_step.x, 0.0)).a);\n${o.out}_neighbor_alpha = max(${o.out}_neighbor_alpha, Texel(tex, ${i.uv} - vec2(${o.out}_uv_step.x, 0.0)).a);\n${o.out}_neighbor_alpha = max(${o.out}_neighbor_alpha, Texel(tex, ${i.uv} + vec2(0.0, ${o.out}_uv_step.y)).a);\n${o.out}_neighbor_alpha = max(${o.out}_neighbor_alpha, Texel(tex, ${i.uv} - vec2(0.0, ${o.out}_uv_step.y)).a);\n${o.out}_neighbor_alpha = max(${o.out}_neighbor_alpha, Texel(tex, ${i.uv} + ${o.out}_uv_step).a);\n${o.out}_neighbor_alpha = max(${o.out}_neighbor_alpha, Texel(tex, ${i.uv} - ${o.out}_uv_step).a);\n${o.out}_neighbor_alpha = max(${o.out}_neighbor_alpha, Texel(tex, ${i.uv} + vec2(${o.out}_uv_step.x, -${o.out}_uv_step.y)).a);\n${o.out}_neighbor_alpha = max(${o.out}_neighbor_alpha, Texel(tex, ${i.uv} + vec2(-${o.out}_uv_step.x, ${o.out}_uv_step.y)).a);\nfloat ${o.out}_outline_alpha = ${i.outlineColor}.a * smoothstep(0.001, 0.5, ${o.out}_neighbor_alpha);\nfloat ${o.out}_alpha = ${o.out}_source_alpha + ${o.out}_outline_alpha * (1.0 - ${o.out}_source_alpha);\nfloat ${o.out}_source_coverage = smoothstep(0.18, 0.92, ${o.out}_source_alpha);\nvec3 ${o.out}_rgb = mix(${i.outlineColor}.rgb, ${i.color}.rgb, ${o.out}_source_coverage);\nvec4 ${o.out} = vec4(${o.out}_rgb, ${o.out}_alpha);`,
},
WaveDistort: {
category: 'Effect',
diff --git a/src/pages/shader-graph/presets.ts b/src/pages/shader-graph/presets.ts
index 337fb349..e05f58cb 100644
--- a/src/pages/shader-graph/presets.ts
+++ b/src/pages/shader-graph/presets.ts
@@ -83,7 +83,7 @@ export const SHADER_GRAPH_PRESETS: ShaderGraphPreset[] = [
preset(
'outline',
'Outline',
- 'Alpha-based sprite outline with editable color and thickness.',
+ 'Sprite-alpha outline with editable color and screen-space thickness.',
'outline',
[
{ id: 'tex', type: 'TextureColor', x: 0, y: 0 },
From 7a0bedbae1237568f6379ac573c9c226af5733a1 Mon Sep 17 00:00:00 2001
From: Roberto Amarante
Date: Fri, 22 May 2026 01:28:07 -0400
Subject: [PATCH 04/38] feather: add color input for vec4
---
src-lua/plugins/shader-graph/README.md | 15 ++-
src-lua/plugins/shader-graph/init.lua | 37 ++++++-
src/hooks/use-shader-graph.ts | 5 +-
src/pages/shader-graph/CodePreview.tsx | 32 +++++-
src/pages/shader-graph/NodeInspector.tsx | 127 +++++++++++++++++++----
src/pages/shader-graph/nodeDefs.ts | 3 +-
src/pages/shader-graph/presets.ts | 88 +++++++++++++++-
7 files changed, 271 insertions(+), 36 deletions(-)
diff --git a/src-lua/plugins/shader-graph/README.md b/src-lua/plugins/shader-graph/README.md
index 8c6457fe..ae33b504 100644
--- a/src-lua/plugins/shader-graph/README.md
+++ b/src-lua/plugins/shader-graph/README.md
@@ -16,7 +16,7 @@ Several higher-level nodes and presets are also inspired by common VFX Shader Gr
4. Connect compatible ports by type.
5. Connect the final `vec4` color into **Fragment Output**.
6. Use **Validate** to compile in the running LÖVE game.
-7. Toggle **Preview On** to draw the shader on a temporary circle, line, or rectangle in the center of the running game when you are not ready to apply it to particles. While preview is enabled, graph edits re-apply to the preview automatically.
+7. Toggle **Preview On** to draw the shader on a temporary circle, line, or rectangle in the center of the running game when you are not ready to apply it to particles. While preview is enabled, graph edits, shape changes, and preview color changes re-apply automatically.
8. Use **Apply** to send the generated shader to the selected Particle System Playground emitter.
9. Export/import `.feathershgh` files when you want to save or share editable graph projects.
@@ -114,7 +114,7 @@ Effect nodes are higher-level building blocks for common 2D game shaders.
| Hit Flash | Mixes texture color toward a flash color |
| Vignette | Darkens or fades toward UV edges |
| Pixelate | Snaps UVs to a lower-resolution grid |
-| Chromatic Aberration | Splits red/blue samples away from center |
+| Chromatic Aberration | Splits red/blue samples away from an adjustable center offset |
### Output
@@ -141,7 +141,7 @@ The Shader Graph page includes complete preset graphs:
- **Hit Flash**: damage/selection flash.
- **Rim Glow**: 2D fresnel-style edge glow.
- **Pixelate**: retro low-resolution sampling.
-- **Chromatic Aberration**: RGB channel split.
+- **Chromatic Aberration**: RGB channel split with an editable center offset.
- **Posterize**: toon/retro color bands.
- **Twirl Portal**: centered UV swirl.
- **Rotating Texture**: time-driven UV rotation.
@@ -241,6 +241,12 @@ Feed texture color and a glow color into `Hit Flash`.
Feed texture color and a highlight color into `Hit Flash`. This is a quick way to prototype shield, scan, grid, glitch, or energy effects.
+### Chromatic Aberration
+
+`Texture Coords + amount + center offset -> Chromatic Aberration -> Fragment Output`
+
+The offset input shifts the split center from `vec2(0.5)`. Use small values such as `vec2(0.08, 0.0)` to bias the red/blue separation toward one side of a sprite or screen effect.
+
### About Depth Fade
Depth fade is a useful Unity particle technique for softening intersections against scene geometry. Feather does not include a depth fade node yet because LÖVE's standard 2D shader path does not provide the same camera depth texture. For now, approximate soft edges with texture alpha, `Opacity`, `Vignette`, dissolve masks, or custom game-provided shader uniforms.
@@ -281,13 +287,14 @@ Compiles the provided GLSL source, creates a temporary padded shape texture, and
| `pixelSource` | `string` | GLSL pixel (fragment) shader source |
| `vertexSource` | `string` | GLSL vertex shader source (optional) |
| `shape` | `string` | `circle`, `line`, or `rectangle`; defaults to `circle` |
+| `color` | `number[]` | Preview element RGBA color, normalized `0..1`; defaults to white |
| `size` | `number` | Temporary texture size in pixels; defaults to `128` |
**Response**
```lua
-- success
-{ status = "ok", shape = "circle" }
+{ status = "ok", shape = "circle", color = { 1, 1, 1, 1 } }
-- failure
{ status = "error", pixelError = "...", vertexError = "..." }
diff --git a/src-lua/plugins/shader-graph/init.lua b/src-lua/plugins/shader-graph/init.lua
index c7df56a8..f39894d7 100644
--- a/src-lua/plugins/shader-graph/init.lua
+++ b/src-lua/plugins/shader-graph/init.lua
@@ -19,6 +19,32 @@ local function restoreCanvas(canvas)
end
end
+local function clamp01(value, fallback)
+ value = tonumber(value)
+ if value == nil then
+ return fallback
+ end
+ if value < 0 then
+ return 0
+ end
+ if value > 1 then
+ return 1
+ end
+ return value
+end
+
+local function previewColor(value)
+ if type(value) ~= "table" then
+ return { 1, 1, 1, 1 }
+ end
+ return {
+ clamp01(value[1] or value.r, 1),
+ clamp01(value[2] or value.g, 1),
+ clamp01(value[3] or value.b, 1),
+ clamp01(value[4] or value.a, 1),
+ }
+end
+
local function buildShader(pixelSource, vertexSource)
pixelSource = pixelSource or ""
vertexSource = vertexSource or ""
@@ -44,7 +70,7 @@ local function buildShader(pixelSource, vertexSource)
return pixelShaderOrErr
end
-local function makePreviewCanvas(shape, size)
+local function makePreviewCanvas(shape, size, color)
size = tonumber(size) or DEFAULT_PREVIEW_SIZE
if size < 32 then
size = 32
@@ -66,7 +92,7 @@ local function makePreviewCanvas(shape, size)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setShader()
love.graphics.setBlendMode("alpha")
- love.graphics.setColor(1, 1, 1, 1)
+ love.graphics.setColor(color[1], color[2], color[3], color[4])
if shape == "rectangle" then
love.graphics.rectangle("fill", pad, pad, size - pad * 2, size - pad * 2)
@@ -137,7 +163,8 @@ function ShaderGraphPlugin:_previewShader(params)
return { status = "error", pixelError = pixelError, vertexError = vertexError }
end
- local okCanvas, canvasOrErr = pcall(makePreviewCanvas, shape, params.size)
+ local color = previewColor(params.color)
+ local okCanvas, canvasOrErr = pcall(makePreviewCanvas, shape, params.size, color)
if not okCanvas then
return { status = "error", pixelError = tostring(canvasOrErr) }
end
@@ -146,10 +173,11 @@ function ShaderGraphPlugin:_previewShader(params)
shader = shader,
canvas = canvasOrErr,
shape = shape,
+ color = color,
updatedAt = love.timer and love.timer.getTime() or os.clock(),
}
- return { status = "ok", shape = shape }
+ return { status = "ok", shape = shape, color = color }
end
function ShaderGraphPlugin:onDraw()
@@ -226,6 +254,7 @@ function ShaderGraphPlugin:getConfig()
icon = "blend",
preview = self.preview and {
shape = self.preview.shape,
+ color = self.preview.color,
active = true,
} or nil,
}
diff --git a/src/hooks/use-shader-graph.ts b/src/hooks/use-shader-graph.ts
index ed895215..861da0eb 100644
--- a/src/hooks/use-shader-graph.ts
+++ b/src/hooks/use-shader-graph.ts
@@ -10,6 +10,7 @@ const PLUGIN_ID = 'particle-system-playground';
const SHADER_GRAPH_PLUGIN = 'shader-graph';
export type ShaderPreviewShape = 'circle' | 'line' | 'rectangle';
+export type ShaderPreviewColor = [number, number, number, number];
type CompilePayload = { status: 'ok' | 'error'; pixelError?: string; vertexError?: string };
type CompileResponse = {
@@ -73,14 +74,14 @@ export function useShaderGraph() {
}, [generateAndStore, sessionId, store]);
const previewShader = useCallback(
- async (shape: ShaderPreviewShape) => {
+ async (shape: ShaderPreviewShape, color: ShaderPreviewColor) => {
if (!sessionId) return;
const glsl = generateAndStore();
await sendCommand(sessionId, {
type: 'cmd:plugin:action',
plugin: SHADER_GRAPH_PLUGIN,
action: 'preview-shader',
- params: { pixelSource: glsl.pixel, vertexSource: glsl.vertex ?? '', shape },
+ params: { pixelSource: glsl.pixel, vertexSource: glsl.vertex ?? '', shape, color },
});
},
[generateAndStore, sessionId],
diff --git a/src/pages/shader-graph/CodePreview.tsx b/src/pages/shader-graph/CodePreview.tsx
index cf4475b6..ed0b9bcd 100644
--- a/src/pages/shader-graph/CodePreview.tsx
+++ b/src/pages/shader-graph/CodePreview.tsx
@@ -4,7 +4,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import { type ShaderPreviewShape, useShaderGraph } from '@/hooks/use-shader-graph';
+import { type ShaderPreviewColor, type ShaderPreviewShape, useShaderGraph } from '@/hooks/use-shader-graph';
import { useSessionStore } from '@/store/session';
import { useTheme } from '@/hooks/use-theme';
import oneLight from '@/assets/theme/light';
@@ -26,6 +26,13 @@ function useDebouncedValue(value: T, delayMs: number): T {
return debouncedValue;
}
+function colorFromHex(value: string): ShaderPreviewColor {
+ const match = value.match(/^#?([0-9a-f]{6})$/i);
+ if (!match) return [1, 1, 1, 1];
+ const int = Number.parseInt(match[1], 16);
+ return [((int >> 16) & 255) / 255, ((int >> 8) & 255) / 255, (int & 255) / 255, 1];
+}
+
export function CodePreview() {
const sessionId = useSessionStore((s) => s.sessionId);
const {
@@ -45,11 +52,13 @@ export function CodePreview() {
const hlTheme = theme === 'dark' ? onDark : oneLight;
const [copied, setCopied] = useState(false);
const [previewShape, setPreviewShape] = useState('circle');
+ const [previewColor, setPreviewColor] = useState('#ffffff');
const [previewEnabled, setPreviewEnabled] = useState(false);
const previewShaderRef = useRef(previewShader);
const debouncedNodes = useDebouncedValue(nodes, 180);
const debouncedEdges = useDebouncedValue(edges, 180);
const debouncedPreviewShape = useDebouncedValue(previewShape, 180);
+ const debouncedPreviewColor = useDebouncedValue(previewColor, 180);
const glsl = lastGeneratedGlsl ?? generateAndStore();
const fullSource = glsl.vertex
@@ -90,8 +99,8 @@ export function CodePreview() {
useEffect(() => {
if (!sessionId || !previewEnabled) return;
- void previewShaderRef.current(debouncedPreviewShape);
- }, [debouncedEdges, debouncedNodes, debouncedPreviewShape, previewEnabled, sessionId]);
+ void previewShaderRef.current(debouncedPreviewShape, colorFromHex(debouncedPreviewColor));
+ }, [debouncedEdges, debouncedNodes, debouncedPreviewColor, debouncedPreviewShape, previewEnabled, sessionId]);
const statusColor = {
idle: 'bg-muted text-muted-foreground',
@@ -204,6 +213,23 @@ export function CodePreview() {
Rectangle
+
+
+
+ `);
+ }
+ await writeFile(indexPath, next);
+}
diff --git a/showcase.html b/showcase.html
new file mode 100644
index 00000000..eeed532c
--- /dev/null
+++ b/showcase.html
@@ -0,0 +1,12 @@
+
+
+