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 + @@ -242,6 +271,29 @@ export function CodePreview() { {previewEnabled ? 'Preview Off' : 'Preview On'} + +
+
+
+
+ Preview Texture +
+
+ {baseTexture?.filename ?? 'Temporary shape texture'} +
+
+
+ {baseTexture && ( + + )} + +
+
+
); } diff --git a/src/pages/shader-graph/NodeInspector.tsx b/src/pages/shader-graph/NodeInspector.tsx index 16780ac0..87869cdf 100644 --- a/src/pages/shader-graph/NodeInspector.tsx +++ b/src/pages/shader-graph/NodeInspector.tsx @@ -1,11 +1,15 @@ +import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useShaderGraphStore } from '@/store/shader-graph'; import { NODE_DEFS } from './nodeDefs'; import type { NodeType, PortDef } from '@/types/shader-graph'; -import { Trash2Icon } from 'lucide-react'; +import { FolderOpenIcon, Trash2Icon, XIcon } from 'lucide-react'; import { useState } from 'react'; +import { shaderTextureUniformName } from './glslUtils'; +import { pickShaderTexture } from './textureUpload'; +import { toast } from 'sonner'; function clamp01(value: number) { if (!Number.isFinite(value)) return 0; @@ -72,7 +76,10 @@ export function NodeInspector() { edges, selectedNodeId, selectedEdgeId, + textureUploads, updateNodeData, + setTextureUpload, + clearTextureUpload, setShaderName, shaderName, removeNode, @@ -84,6 +91,7 @@ export function NodeInspector() { const def = selected ? NODE_DEFS[selected.data.nodeType as NodeType] : null; const selectedEdge = selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null; const selectedVec4 = selected ? vec4Value(selected.data.values?.val) : [0, 0, 0, 1] as [number, number, number, number]; + const selectedTextureUpload = selected ? textureUploads[selected.id] : null; function updateSelectedVec4(next: [number, number, number, number]) { if (!selected) return; @@ -95,6 +103,14 @@ export function NodeInspector() { updateNodeData(selected.id, { values: { ...selected.data.values, [port.id]: next } }); } + async function uploadSelectedTexture() { + if (!selected) return; + const texture = await pickShaderTexture(); + if (!texture) return; + setTextureUpload(selected.id, texture); + toast.success(`${selected.data.label || 'Texture'} loaded: ${texture.filename}`); + } + function renderPortValueEditor(port: PortDef) { if (!selected) return null; @@ -117,6 +133,14 @@ export function NodeInspector() { ); } + if (port.type === 'image') { + return ( +

+ Uses the main sprite texture unless a texture input is connected. +

+ ); + } + if (port.type === 'vec2' || port.type === 'vec3' || port.type === 'vec4') { const count = port.type === 'vec2' ? 2 : port.type === 'vec3' ? 3 : 4; const fallback = defaultPortValue(port) as number[]; @@ -340,6 +364,53 @@ export function NodeInspector() { )} + {(selected.data.nodeType === 'TextureInput' || selected.data.nodeType === 'TextureUniformColor') && ( +
+
+ + updateNodeData(selected.id, { uniformName: e.target.value })} + /> +
+
+
+
Texture File
+
+ {selectedTextureUpload?.filename ?? 'Fallback texture until a file is loaded'} +
+
+
+ {selectedTextureUpload && ( + + )} + +
+
+

+ Preview and runtime uploads bind this texture by name. Use one node for each extra texture. +

+
+ )} + {def.inputs.length > 0 && (
Inputs
diff --git a/src/pages/shader-graph/codegen.ts b/src/pages/shader-graph/codegen.ts index 6b98ffbc..a308510e 100644 --- a/src/pages/shader-graph/codegen.ts +++ b/src/pages/shader-graph/codegen.ts @@ -1,6 +1,6 @@ import type { ShaderNodeInstance, ShaderEdge, GeneratedGlsl, ShaderNodeData, PortDef } from '@/types/shader-graph'; import { NODE_DEFS } from './nodeDefs'; -import { glslFloat } from './glslUtils'; +import { glslFloat, shaderTextureUniformName } from './glslUtils'; export const PASSTHROUGH_PIXEL = [ 'vec4 effect(vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords) {', @@ -66,12 +66,33 @@ function defaultValue(port: PortDef, nodeData: ShaderNodeData): string { } case 'mat4': return 'mat4(1.0)'; + case 'image': + return 'tex'; default: return '0.0'; } } -function buildNodeBody(node: ShaderNodeInstance, edges: ShaderEdge[]): string[] { +function outputExpression(nodeMap: Map, nodeId: string, portId: string): string { + const node = nodeMap.get(nodeId); + if (node?.data.nodeType === 'TextureInput' && portId === 'texture') { + return shaderTextureUniformName(node.id, node.data.uniformName); + } + return varName(nodeId, portId); +} + +function collectTextureUniform(node: ShaderNodeInstance, textureMap: Map, externSet: Set) { + if (node.data.nodeType !== 'TextureInput' && node.data.nodeType !== 'TextureUniformColor') return; + const uniform = shaderTextureUniformName(node.id, node.data.uniformName); + externSet.add(`extern Image ${uniform};`); + textureMap.set(uniform, { + nodeId: node.id, + uniform, + label: String(node.data.label || NODE_DEFS[node.data.nodeType].label), + }); +} + +function buildNodeBody(node: ShaderNodeInstance, edges: ShaderEdge[], nodeMap: Map): string[] { const def = NODE_DEFS[node.data.nodeType]; if (!def?.emitGlsl) return []; @@ -79,7 +100,7 @@ function buildNodeBody(node: ShaderNodeInstance, edges: ShaderEdge[]): string[] for (const port of def.inputs) { const edge = edges.find((e) => e.target === node.id && e.targetHandle === port.id); inVars[port.id] = edge?.sourceHandle - ? varName(edge.source, edge.sourceHandle) + ? outputExpression(nodeMap, edge.source, edge.sourceHandle) : defaultValue(port, node.data); } @@ -88,7 +109,7 @@ function buildNodeBody(node: ShaderNodeInstance, edges: ShaderEdge[]): string[] outVars[port.id] = varName(node.id, port.id); } - const glsl = def.emitGlsl(inVars, outVars, node.data); + const glsl = def.emitGlsl(inVars, outVars, { ...node.data, __nodeId: node.id }); return glsl ? glsl.split('\n').map((s) => s.trim()).filter(Boolean) : []; } @@ -134,17 +155,20 @@ export function codegen(nodes: ShaderNodeInstance[], edges: ShaderEdge[]): Gener const vertOut = nodes.find((n) => n.data.nodeType === 'VertexOutput'); if (!nodes.length || !fragOut) { - return { pixel: PASSTHROUGH_PIXEL, vertex: null, hash: 'passthrough' }; + return { pixel: PASSTHROUGH_PIXEL, vertex: null, hash: 'passthrough', textures: [] }; } const reachable = collectReachable(fragOut.id, nodes, edges); + const nodeMap = new Map(nodes.map((n) => [n.id, n])); const externSet = new Set(); const helperKeys = new Set(); + const textureMap = new Map(); for (const node of reachable) { const def = NODE_DEFS[node.data.nodeType]; def?.externs?.forEach((e) => externSet.add(e)); if (def?.helperKey) helperKeys.add(def.helperKey); + collectTextureUniform(node, textureMap, externSet); } let vertReachable: ShaderNodeInstance[] = []; @@ -154,19 +178,20 @@ export function codegen(nodes: ShaderNodeInstance[], edges: ShaderEdge[]): Gener const def = NODE_DEFS[node.data.nodeType]; def?.externs?.forEach((e) => externSet.add(e)); if (def?.helperKey) helperKeys.add(def.helperKey); + collectTextureUniform(node, textureMap, externSet); } } const bodyLines: string[] = []; for (const node of reachable) { if (node.data.nodeType !== 'FragmentOutput') { - bodyLines.push(...buildNodeBody(node, edges)); + bodyLines.push(...buildNodeBody(node, edges, nodeMap)); } } const fragColorEdge = edges.find((e) => e.target === fragOut.id && e.targetHandle === 'color'); const returnExpr = fragColorEdge?.sourceHandle - ? varName(fragColorEdge.source, fragColorEdge.sourceHandle) + ? outputExpression(nodeMap, fragColorEdge.source, fragColorEdge.sourceHandle) : 'Texel(tex, texture_coords)'; const parts: string[] = []; @@ -182,17 +207,17 @@ export function codegen(nodes: ShaderNodeInstance[], edges: ShaderEdge[]): Gener const vertLines: string[] = []; for (const node of vertReachable) { if (node.data.nodeType !== 'VertexOutput') { - vertLines.push(...buildNodeBody(node, edges)); + vertLines.push(...buildNodeBody(node, edges, nodeMap)); } } const posEdge = edges.find((e) => e.target === vertOut.id && e.targetHandle === 'pos'); const vertReturn = posEdge?.sourceHandle - ? varName(posEdge.source, posEdge.sourceHandle) + ? outputExpression(nodeMap, posEdge.source, posEdge.sourceHandle) : 'vertex_position'; vertex = buildPosition(vertLines, vertReturn); } const hash = btoa(pixel.slice(0, 64)).slice(0, 16); - return { pixel, vertex, hash }; + return { pixel, vertex, hash, textures: [...textureMap.values()] }; } diff --git a/src/pages/shader-graph/glslUtils.ts b/src/pages/shader-graph/glslUtils.ts index 94cde164..0d389405 100644 --- a/src/pages/shader-graph/glslUtils.ts +++ b/src/pages/shader-graph/glslUtils.ts @@ -3,3 +3,13 @@ export function glslFloat(n: unknown): string { if (!Number.isFinite(value)) return '0.0'; return Number.isInteger(value) ? `${value}.0` : String(value); } + +export function sanitizeGlslIdentifier(value: unknown, fallback: string): string { + const raw = String(value ?? '').trim(); + const cleaned = raw.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[^a-zA-Z_]+/, ''); + return cleaned || fallback; +} + +export function shaderTextureUniformName(nodeId: string, configuredName?: unknown): string { + return sanitizeGlslIdentifier(configuredName, `u_sg_tex_${sanitizeGlslIdentifier(nodeId, 'texture')}`); +} diff --git a/src/pages/shader-graph/nodeDefs.ts b/src/pages/shader-graph/nodeDefs.ts index 8019964f..399b8f28 100644 --- a/src/pages/shader-graph/nodeDefs.ts +++ b/src/pages/shader-graph/nodeDefs.ts @@ -1,5 +1,5 @@ import type { GlslType, NodeDef, NodeType, ShaderNodeData } from '@/types/shader-graph'; -import { glslFloat } from './glslUtils'; +import { glslFloat, shaderTextureUniformName } from './glslUtils'; export const PORT_TYPE_COLORS: Record = { float: '#94a3b8', @@ -7,6 +7,7 @@ export const PORT_TYPE_COLORS: Record = { vec3: '#60a5fa', vec4: '#c084fc', mat4: '#fb923c', + image: '#38bdf8', }; type Emit = (inVars: Record, outVars: Record, data: ShaderNodeData) => string; @@ -85,6 +86,20 @@ export const NODE_DEFS: Record = { outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }], emitGlsl: (_, o) => `vec4 ${o.out} = Texel(tex, texture_coords);`, }, + TextureInput: { + category: 'Input', + label: 'Texture Input', + inputs: [], + outputs: [{ id: 'texture', label: 'Image', type: 'image' }], + emitGlsl: () => '', + }, + TextureUniformColor: { + category: 'Input', + label: 'Texture Uniform Color', + inputs: [{ id: 'uv', label: 'UV', type: 'vec2' }], + outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }], + emitGlsl: (i, o, d) => `vec4 ${o.out} = Texel(${shaderTextureUniformName(String(d.__nodeId ?? 'texture'), d.uniformName)}, ${i.uv});`, + }, TextureCoords: { category: 'Input', label: 'Texture Coords', @@ -612,9 +627,12 @@ export const NODE_DEFS: Record = { SampleTexture: { category: 'Effect', label: 'Sample Texture', - inputs: [{ id: 'uv', label: 'UV', type: 'vec2' }], + inputs: [ + { id: 'texture', label: 'Texture', type: 'image' }, + { id: 'uv', label: 'UV', type: 'vec2' }, + ], outputs: [{ id: 'out', label: 'RGBA', type: 'vec4' }], - emitGlsl: (i, o) => `vec4 ${o.out} = Texel(tex, ${i.uv});`, + emitGlsl: (i, o) => `vec4 ${o.out} = Texel(${i.texture}, ${i.uv});`, }, TextureStrength: { category: 'Effect', @@ -719,6 +737,32 @@ export const NODE_DEFS: Record = { emitGlsl: (i, o) => `vec2 ${o.out}_p = ${i.uv} * max(${i.scale}, 0.0001) + vec2(${i.time} * ${i.speed}, ${i.time} * ${i.speed} * 0.73);\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}_a = 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);\nfloat ${o.out}_b = mix(mix(feather_hash(${o.out}_cell + vec2(9.2, 3.4)), feather_hash(${o.out}_cell + vec2(10.2, 3.4)), ${o.out}_f.x), mix(feather_hash(${o.out}_cell + vec2(9.2, 4.4)), feather_hash(${o.out}_cell + vec2(10.2, 4.4)), ${o.out}_f.x), ${o.out}_f.y);\nvec2 ${o.out}_uv = ${i.uv} + (vec2(${o.out}_a, ${o.out}_b) * 2.0 - 1.0) * ${i.amp};\nvec4 ${o.out}_source = Texel(tex, ${o.out}_uv);\nfloat ${o.out}_mask = step(${i.maskThreshold}, min(${i.color}.a, ${o.out}_source.a));\nvec4 ${o.out} = mix(${i.color}, ${o.out}_source, ${o.out}_mask);`, }, + WaterNoiseUV: { + category: 'Effect', + label: 'Water Noise UV', + inputs: [ + { id: 'uv', label: 'UV', type: 'vec2' }, + { id: 'time', label: 'Time', type: 'float' }, + { id: 'noiseWidth', label: 'Noise W', type: 'float', defaultValue: 64, min: 1, max: 512, step: 1 }, + { id: 'spriteWidth', label: 'Sprite W', type: 'float', defaultValue: 65, min: 1, max: 512, step: 1 }, + { id: 'speed', label: 'Speed', type: 'float', defaultValue: 0.05, min: -1, max: 1, step: 0.001 }, + ], + outputs: [{ id: 'out', label: 'Noise UV', type: 'vec2' }], + emitGlsl: (i, o) => + `float ${o.out}_ratio = ${i.spriteWidth} / max(${i.noiseWidth}, 0.0001);\nfloat ${o.out}_speed = ${i.speed} * ${o.out}_ratio;\nvec2 ${o.out} = fract(${i.uv} * ${o.out}_ratio + vec2(${o.out}_speed * ${i.time}, ${o.out}_speed * ${i.time}));`, + }, + WaterDisplaceV2: { + category: 'Effect', + label: 'Water Displace V2', + inputs: [ + { id: 'uv', label: 'UV', type: 'vec2' }, + { id: 'noiseColor', label: 'Noise', type: 'vec4' }, + { id: 'amp', label: 'Amp', type: 'float', defaultValue: 0.05, min: 0, max: 0.25, step: 0.001 }, + ], + outputs: [{ id: 'out', label: 'UV', type: 'vec2' }], + emitGlsl: (i, o) => + `vec4 ${o.out}_noise = ${i.noiseColor};\nfloat ${o.out}_xy = ${o.out}_noise.b * 0.7071;\n${o.out}_noise.r -= ${o.out}_xy;\n${o.out}_noise.g -= ${o.out}_xy;\nvec2 ${o.out} = ${i.uv} + (((${i.amp} * 2.0) * vec2(${o.out}_noise)) - ${i.amp});`, + }, Dissolve2D: { category: 'Effect', label: 'Dissolve', @@ -2195,6 +2239,8 @@ export const CATEGORY_ORDER: Array<{ category: string; nodes: NodeType[] }> = [ category: 'Input', nodes: [ 'TextureColor', + 'TextureInput', + 'TextureUniformColor', 'TextureCoords', 'ScreenCoords', 'VertexColor', @@ -2287,6 +2333,8 @@ export const CATEGORY_ORDER: Array<{ category: string; nodes: NodeType[] }> = [ 'WaveDistort', 'WaterDisplace', 'MaskedWaterDisplace', + 'WaterNoiseUV', + 'WaterDisplaceV2', 'Dissolve2D', 'HitFlash', 'Vignette', diff --git a/src/pages/shader-graph/presets.ts b/src/pages/shader-graph/presets.ts index 500f295f..62e05787 100644 --- a/src/pages/shader-graph/presets.ts +++ b/src/pages/shader-graph/presets.ts @@ -9,6 +9,7 @@ type PresetNode = { y: number; label?: string; values?: Record; + uniformName?: string; min?: number; max?: number; step?: number; @@ -142,6 +143,11 @@ const PRESET_LABELS: Record = { 'vert-out': 'Final Vertex Position', wave: 'Wave Distortion', water: 'Water Offset', + 'water-amp': 'Water Amplitude', + 'water-noise': 'Simplex Noise Texture', + 'water-noise-uv': 'Scrolling Noise UV', + 'water-shift': 'Texture Water Displacement', + 'water-speed': 'Noise Scroll Speed', width: 'Rectangle Width', truchet: 'Build Truchet Mask', 'line-color': 'Line Color', @@ -159,6 +165,7 @@ const PRESET_TYPE_LABELS: Partial> = { SDFSample: 'Sample SDF Mask', TextureColor: 'Source Texture', TextureCoords: 'Source UVs', + TextureInput: 'Texture Input', }; const PRESET_LABELS_BY_KEY: Partial> = { @@ -170,7 +177,7 @@ function presetLabel(id: string, type: NodeType) { return PRESET_LABELS_BY_KEY[`${type}:${id}`] ?? PRESET_LABELS[id] ?? PRESET_LABELS_BY_ID[id] ?? PRESET_TYPE_LABELS[type] ?? NODE_DEFS[type].label; } -function node({ id, type, x, y, label, values, min, max, step }: PresetNode): ShaderNodeInstance { +function node({ id, type, x, y, label, values, uniformName, min, max, step }: PresetNode): ShaderNodeInstance { return { id, type: 'shaderNode', @@ -179,6 +186,7 @@ function node({ id, type, x, y, label, values, min, max, step }: PresetNode): Sh label: label ?? presetLabel(id, type), nodeType: type, ...(values ? { values } : {}), + ...(uniformName ? { uniformName } : {}), ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}), ...(step !== undefined ? { step } : {}), @@ -1095,4 +1103,38 @@ export const SHADER_GRAPH_PRESETS: ShaderGraphPreset[] = [ { from: 'water', out: 'out', to: 'out', in: 'color' }, ], ), + preset( + 'texture-noise-water', + 'Texture Noise Water', + 'Recreates a texture-noise water displacement shader with an uploaded color simplex noise texture.', + 'texture-noise-water', + [ + { id: 'uv', type: 'TextureCoords', x: 0, y: 0, label: 'Water Tile UVs' }, + { id: 'time', type: 'Time', x: 0, y: 140, label: 'Animated Time' }, + { id: 'noise-width', type: 'FloatConstant', x: 0, y: 280, label: 'Noise Width', values: { val: 64 }, min: 1, max: 512, step: 1 }, + { id: 'sprite-width', type: 'FloatConstant', x: 0, y: 420, label: 'Sprite Width', values: { val: 65 }, min: 1, max: 512, step: 1 }, + { id: 'water-speed', type: 'FloatConstant', x: 0, y: 560, values: { val: 0.05 }, min: -1, max: 1, step: 0.001 }, + { id: 'water-amp', type: 'FloatConstant', x: 0, y: 700, values: { val: 0.05 }, min: 0, max: 0.25, step: 0.001 }, + { id: 'water-noise-uv', type: 'WaterNoiseUV', x: 360, y: 210 }, + { id: 'water-noise', type: 'TextureInput', x: 720, y: 210, uniformName: 'simplex' }, + { id: 'noise-sample', type: 'SampleTexture', x: 970, y: 245, label: 'Sample Simplex Noise' }, + { id: 'water-shift', type: 'WaterDisplaceV2', x: 1260, y: 260 }, + { id: 'sample', type: 'SampleTexture', x: 1610, y: 300 }, + { id: 'out', type: 'FragmentOutput', x: 1890, y: 300 }, + ], + [ + { from: 'uv', out: 'out', to: 'water-noise-uv', in: 'uv' }, + { from: 'time', out: 'out', to: 'water-noise-uv', in: 'time' }, + { from: 'noise-width', out: 'out', to: 'water-noise-uv', in: 'noiseWidth' }, + { from: 'sprite-width', out: 'out', to: 'water-noise-uv', in: 'spriteWidth' }, + { from: 'water-speed', out: 'out', to: 'water-noise-uv', in: 'speed' }, + { from: 'water-noise', out: 'texture', to: 'noise-sample', in: 'texture' }, + { from: 'water-noise-uv', out: 'out', to: 'noise-sample', in: 'uv' }, + { from: 'uv', out: 'out', to: 'water-shift', in: 'uv' }, + { from: 'noise-sample', out: 'out', to: 'water-shift', in: 'noiseColor' }, + { from: 'water-amp', out: 'out', to: 'water-shift', in: 'amp' }, + { from: 'water-shift', out: 'out', to: 'sample', in: 'uv' }, + { from: 'sample', out: 'out', to: 'out', in: 'color' }, + ], + ), ]; diff --git a/src/pages/shader-graph/textureUpload.ts b/src/pages/shader-graph/textureUpload.ts new file mode 100644 index 00000000..b7086520 --- /dev/null +++ b/src/pages/shader-graph/textureUpload.ts @@ -0,0 +1,34 @@ +import { open as openFileDialog } from '@tauri-apps/plugin-dialog'; +import { readFile } from '@tauri-apps/plugin-fs'; +import { toast } from 'sonner'; +import type { ShaderTextureUpload } from '@/types/shader-graph'; +import { isWeb } from '@/utils/platform'; + +function bytesToBase64(bytes: Uint8Array) { + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); +} + +function filename(path: string) { + return path.split(/[\\/]/).pop() || 'texture.png'; +} + +export async function pickShaderTexture(): Promise { + if (isWeb()) { + toast.error('Texture upload is available in the desktop app'); + return null; + } + + const path = await openFileDialog({ + multiple: false, + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'bmp', 'tga'] }], + }); + if (!path || typeof path !== 'string') return null; + + const bytes = await readFile(path); + return { filename: filename(path), dataBase64: bytesToBase64(bytes) }; +} diff --git a/src/store/shader-graph.ts b/src/store/shader-graph.ts index 5bc2e390..1e6ea941 100644 --- a/src/store/shader-graph.ts +++ b/src/store/shader-graph.ts @@ -1,7 +1,7 @@ import type { Node, Edge } from '@xyflow/react'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import type { ShaderNodeData, PlaygroundTarget, GeneratedGlsl } from '@/types/shader-graph'; +import type { ShaderNodeData, PlaygroundTarget, GeneratedGlsl, ShaderTextureUpload } from '@/types/shader-graph'; type ValidationStatus = 'idle' | 'validating' | 'ok' | 'error'; @@ -19,6 +19,7 @@ type ShaderGraphStore = { selectedEdgeId: string | null; shaderName: string; playgroundTarget: PlaygroundTarget | null; + textureUploads: Record; lastGeneratedGlsl: GeneratedGlsl | null; validationStatus: ValidationStatus; validationErrors: { pixelError?: string; vertexError?: string }; @@ -34,6 +35,8 @@ type ShaderGraphStore = { selectEdge: (id: string | null) => void; setShaderName: (name: string) => void; setPlaygroundTarget: (target: PlaygroundTarget | null) => void; + setTextureUpload: (nodeId: string, upload: ShaderTextureUpload) => void; + clearTextureUpload: (nodeId: string) => void; setLastGlsl: (glsl: GeneratedGlsl | null) => void; setValidationStatus: (status: ValidationStatus) => void; setValidationErrors: (errors: ShaderGraphStore['validationErrors']) => void; @@ -57,7 +60,7 @@ function sameGraph(a: GraphSnapshot, b: GraphSnapshot): boolean { function withHistory( state: ShaderGraphStore, - patch: Partial>, + patch: Partial>, ) { const before = snapshot(state); const after = { @@ -90,6 +93,7 @@ export const useShaderGraphStore = create()( selectedEdgeId: null, shaderName: 'my-shader', playgroundTarget: null, + textureUploads: {}, lastGeneratedGlsl: null, validationStatus: 'idle', validationErrors: {}, @@ -104,6 +108,7 @@ export const useShaderGraphStore = create()( nodes: s.nodes.filter((n) => n.id !== id), edges: s.edges.filter((e) => e.source !== id && e.target !== id), selectedNodeId: s.selectedNodeId === id ? null : s.selectedNodeId, + textureUploads: Object.fromEntries(Object.entries(s.textureUploads).filter(([nodeId]) => nodeId !== id)), }), ), removeEdge: (id) => @@ -123,6 +128,19 @@ export const useShaderGraphStore = create()( selectEdge: (selectedEdgeId) => set({ selectedEdgeId, selectedNodeId: null }), setShaderName: (shaderName) => set({ shaderName }), setPlaygroundTarget: (playgroundTarget) => set({ playgroundTarget }), + setTextureUpload: (nodeId, upload) => + set((s) => ({ + textureUploads: { + ...s.textureUploads, + [nodeId]: upload, + }, + })), + clearTextureUpload: (nodeId) => + set((s) => { + const next = { ...s.textureUploads }; + delete next[nodeId]; + return { textureUploads: next }; + }), setLastGlsl: (lastGeneratedGlsl) => set({ lastGeneratedGlsl }), setValidationStatus: (validationStatus) => set({ validationStatus }), setValidationErrors: (validationErrors) => set({ validationErrors }), @@ -135,6 +153,7 @@ export const useShaderGraphStore = create()( playgroundTarget: graph.playgroundTarget ?? null, selectedNodeId: null, selectedEdgeId: null, + textureUploads: {}, undoStack: [], redoStack: [], lastGeneratedGlsl: null, diff --git a/src/types/shader-graph.ts b/src/types/shader-graph.ts index 9819da2b..a9f1278e 100644 --- a/src/types/shader-graph.ts +++ b/src/types/shader-graph.ts @@ -1,6 +1,6 @@ import type { Node, Edge } from '@xyflow/react'; -export type GlslType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'mat4'; +export type GlslType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'mat4' | 'image'; export type PortDef = { id: string; @@ -14,6 +14,8 @@ export type PortDef = { export const NODE_TYPES = [ 'TextureColor', + 'TextureInput', + 'TextureUniformColor', 'TextureCoords', 'ScreenCoords', 'VertexColor', @@ -78,6 +80,8 @@ export const NODE_TYPES = [ 'WaveDistort', 'WaterDisplace', 'MaskedWaterDisplace', + 'WaterNoiseUV', + 'WaterDisplaceV2', 'Dissolve2D', 'HitFlash', 'Vignette', @@ -211,8 +215,18 @@ export type PlaygroundTarget = { systemIndex: number; }; +export type ShaderTextureUpload = { + filename: string; + dataBase64: string; +}; + export type GeneratedGlsl = { pixel: string; vertex: string | null; hash: string; + textures?: Array<{ + nodeId: string; + uniform: string; + label: string; + }>; }; From 2388312860ab7516bab8c419e8e259fafc7b6233 Mon Sep 17 00:00:00 2001 From: Roberto Amarante Date: Fri, 22 May 2026 01:30:22 -0400 Subject: [PATCH 18/38] feather: add custom function node --- src-lua/plugins/shader-graph/README.md | 30 ++- src/components/ui/glsl-code-input.tsx | 101 ++++++++ src/pages/shader-graph/NodeInspector.tsx | 148 ++++++++++- src/pages/shader-graph/ShaderCanvas.tsx | 20 +- src/pages/shader-graph/codegen.ts | 20 +- src/pages/shader-graph/customNode.ts | 303 +++++++++++++++++++++++ src/pages/shader-graph/nodeDefs.ts | 17 ++ src/pages/shader-graph/nodes/index.tsx | 6 +- src/types/shader-graph.ts | 3 +- 9 files changed, 628 insertions(+), 20 deletions(-) create mode 100644 src/components/ui/glsl-code-input.tsx create mode 100644 src/pages/shader-graph/customNode.ts diff --git a/src-lua/plugins/shader-graph/README.md b/src-lua/plugins/shader-graph/README.md index ee2ceb66..523738c8 100644 --- a/src-lua/plugins/shader-graph/README.md +++ b/src-lua/plugins/shader-graph/README.md @@ -15,15 +15,37 @@ Several higher-level nodes and presets are also inspired by common VFX Shader Gr 3. Drag nodes from the palette onto the canvas. 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. Upload a preview texture when the shader should run against a real sprite instead of a generated shape. While preview is enabled, graph edits, shape changes, preview color changes, and uploaded texture 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. +6. Use **Custom Function** when a graph needs a small hand-written GLSL function. Function parameters become input ports; the return value and `out` parameters become output ports. +7. Use **Validate** to compile in the running LÖVE game. +8. 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. Upload a preview texture when the shader should run against a real sprite instead of a generated shape. While preview is enabled, graph edits, shape changes, preview color changes, and uploaded texture changes re-apply automatically. +9. Use **Apply** to send the generated shader to the selected Particle System Playground emitter. +10. 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. ## Node Types +### Custom + +Use **Custom Function** for small GLSL snippets that are easier to express as code than as node chains. + +```glsl +vec4 custom_tint(vec4 color, float strength) { + return vec4(color.rgb * strength, color.a); +} +``` + +The editor parses the function signature in the node modal. Regular parameters become inputs, a non-`void` return value becomes a `Result` output, and `out` parameters become additional outputs: + +```glsl +void custom_mask(vec2 uv, out float mask, out vec4 color) { + mask = smoothstep(0.45, 0.5, length(uv - vec2(0.5))); + color = vec4(vec3(mask), 1.0); +} +``` + +Keep custom functions self-contained and pass values in through ports. The node validates the signature and braces before saving, then the final shader should still be compiled with **Validate** against the connected LÖVE runtime. + ### Input Use input nodes as the raw data source for a shader. diff --git a/src/components/ui/glsl-code-input.tsx b/src/components/ui/glsl-code-input.tsx new file mode 100644 index 00000000..4df976f3 --- /dev/null +++ b/src/components/ui/glsl-code-input.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, type CSSProperties } from 'react'; +import SyntaxHighlighter from 'react-syntax-highlighter'; +import { useTheme } from '@/hooks/use-theme'; +import oneLight from '@/assets/theme/light'; +import onDark from '@/assets/theme/dark'; +import { cn } from '@/utils/styles'; + +interface GlslCodeInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + autoFocus?: boolean; + className?: string; + maxHeight?: number; +} + +const sharedStyle: CSSProperties = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.75rem', + lineHeight: '1.55', + padding: '10px 12px', +}; + +export function GlslCodeInput({ + value, + onChange, + placeholder, + autoFocus, + className, + maxHeight = 420, +}: GlslCodeInputProps) { + const textareaRef = useRef(null); + const theme = useTheme(); + const highlightTheme = theme === 'dark' ? onDark : oneLight; + + const resize = () => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px'; + }; + + useEffect(() => { + resize(); + }, [value, maxHeight]); + + return ( +
+
+ ).hljs, + background: 'transparent', + padding: 0, + }, + }} + customStyle={{ + ...sharedStyle, + background: 'transparent', + margin: 0, + minHeight: 180, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overflow: 'visible', + }} + showLineNumbers={false} + > + {value + ' '} + +
+