Skip to main content

cvkg_render_gpu/
material.rs

1//! Material graph — composable shader generation.
2//!
3//! Replaces the mode-based `if/else` dispatch in shapes.wgsl with
4//! composable material graphs that compile to WGSL at startup.
5//!
6//! # Architecture
7//!
8//! - `MaterialGraph` is a DAG of `MaterialNode`s connected by typed sockets.
9//! - `MaterialCompiler` topologically sorts nodes and emits a WGSL fragment function.
10//! - Built-in materials (rounded rect, glass, text, etc.) are pre-compiled at renderer init.
11//! - User materials compile on first use and are cached by hash.
12
13use std::collections::HashMap;
14
15/// A socket type on a material node.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum MaterialSocket {
18    Color, // vec4<f32>
19    Float, // f32
20    Vec2,  // vec2<f32>
21    Vec3,  // vec3<f32>
22    Mask,  // f32 (0..1 coverage)
23}
24
25/// An operation node in the material graph.
26#[derive(Debug, Clone)]
27pub enum MaterialOp {
28    /// Input: base color from vertex.
29    /// Output: Color
30    InputColor,
31
32    /// Output: constant color from uniform.
33    /// Parameters: rgba
34    /// Output: Color
35    ConstantColor {
36        r: f32,
37        g: f32,
38        b: f32,
39        a: f32,
40    },
41
42    /// Input: UV from vertex.
43    /// Output: sample result Color
44    SampleTexture {
45        tex_index: u32,
46    },
47
48    /// Premultiplied alpha blend (for font atlas).
49    /// Inputs: color (Color), alpha (Float from texture)
50    /// Output: Color
51    PremultipliedBlend,
52
53    /// SDF rounded rectangle mask.
54    /// Inputs: none (reads vertex logical, size, radius)
55    /// Output: Mask
56    SDFRoundRect,
57
58    /// SDF ellipse mask.
59    /// Output: Mask
60    SDFEllipse,
61
62    /// Linear gradient between two colors.
63    /// Input: t (Float, typically UV-based)
64    /// Output: Color
65    LinearGradient {
66        start: [f32; 4],
67        end: [f32; 4],
68    },
69
70    /// Radial gradient.
71    /// Input: dist (Float)
72    /// Output: Color
73    RadialGradient {
74        start: [f32; 4],
75        end: [f32; 4],
76    },
77
78    /// Neon glow effect.
79    /// Input: dist (Float), color (Color)
80    /// Output: Color
81    NeonGlow {
82        radius: f32,
83        intensity: f32,
84    },
85
86    /// Glass fresnel refraction.
87    /// Inputs: uv (Vec2), blur_mip (Float)
88    /// Output: Color
89    GlassBlur,
90
91    /// Layer two inputs with a blend mode.
92    /// Inputs: bottom (Color), top (Color), opacity (Float)
93    /// Output: Color
94    LayerBlend {
95        mode: BlendMode,
96    },
97
98    /// PBR lighting.
99    /// Input: normal (Vec3), metallic (Float), roughness (Float), opacity (Float)
100    /// Output: Color
101    PBRLighting,
102
103    /// Drop shadow.
104    /// Inputs: uv (Vec2), size (Vec2), radius (Float)
105    /// Output: Mask
106    DropShadow,
107
108    /// 9-slice UV remapping.
109    /// Input: uv (Vec2)
110    /// Output: Vec2
111    NineSlice,
112
113    /// Heatmap palette lookup.
114    /// Input: value (Float)
115    /// Output: Color
116    Heatmap,
117
118    /// Raymarched SDF shape.
119    /// Output: Color
120    Raymarch {
121        shape: RaymarchShape,
122    },
123
124    Lightning,
125    RuneGlow,
126    RaymarchReflections,
127    Stroke,
128    DashedStroke,
129}
130
131#[derive(Debug, Clone, Copy)]
132pub enum BlendMode {
133    Add,
134    Screen,
135    Multiply,
136    Overlay,
137}
138
139#[derive(Debug, Clone, Copy)]
140pub enum RaymarchShape {
141    Sphere,
142    Box,
143}
144
145/// Connection between two nodes.
146#[derive(Debug, Clone)]
147pub struct MaterialEdge {
148    pub from_node: u32,
149    pub from_socket: MaterialSocket,
150    pub to_node: u32,
151    pub to_socket: MaterialSocket,
152}
153
154/// Index into the material graph's node list.
155pub type MatNodeId = u32;
156
157/// A directed acyclic graph of material operations.
158#[derive(Debug, Clone)]
159pub struct MaterialGraph {
160    pub nodes: Vec<(MatNodeId, MaterialOp)>,
161    pub edges: Vec<MaterialEdge>,
162    pub output: Option<MatNodeId>,
163}
164
165impl MaterialGraph {
166    pub fn new() -> Self {
167        Self {
168            nodes: Vec::new(),
169            edges: Vec::new(),
170            output: None,
171        }
172    }
173
174    pub fn add_node(&mut self, op: MaterialOp) -> MatNodeId {
175        let id = self.nodes.len() as MatNodeId;
176        self.nodes.push((id, op));
177        id
178    }
179
180    pub fn connect(
181        &mut self,
182        from: MatNodeId,
183        from_socket: MaterialSocket,
184        to: MatNodeId,
185        to_socket: MaterialSocket,
186    ) {
187        self.edges.push(MaterialEdge {
188            from_node: from,
189            from_socket,
190            to_node: to,
191            to_socket,
192        });
193    }
194
195    pub fn set_output(&mut self, node: MatNodeId) {
196        self.output = Some(node);
197    }
198
199    /// Validate the graph using default (unrestricted) config.
200    pub fn validate(&self) -> Result<(), MaterialError> {
201        self.validate_with_config(&MaterialValidationConfig::default())
202    }
203
204    /// Validate the graph with strict limitations (e.g. for AI-generated graphs).
205    pub fn validate_with_config(
206        &self,
207        config: &MaterialValidationConfig,
208    ) -> Result<(), MaterialError> {
209        if self.output.is_none() {
210            return Err(MaterialError::NoOutput);
211        }
212        if self.nodes.len() > config.max_nodes {
213            return Err(MaterialError::TooManyNodes(
214                self.nodes.len(),
215                config.max_nodes,
216            ));
217        }
218        // Cycle detection via DFS
219        let mut visited = vec![false; self.nodes.len()];
220        let mut in_stack = vec![false; self.nodes.len()];
221
222        for &(id, _) in &self.nodes {
223            if !visited[id as usize] {
224                self.dfs_check(id, &mut visited, &mut in_stack)?;
225            }
226        }
227        Ok(())
228    }
229
230    fn dfs_check(
231        &self,
232        node: MatNodeId,
233        visited: &mut [bool],
234        in_stack: &mut [bool],
235    ) -> Result<(), MaterialError> {
236        let idx = node as usize;
237        if in_stack[idx] {
238            return Err(MaterialError::Cycle);
239        }
240        if visited[idx] {
241            return Ok(());
242        }
243        visited[idx] = true;
244        in_stack[idx] = true;
245
246        // Find all edges where this node is the consumer (to_node)
247        for edge in &self.edges {
248            if edge.to_node == node {
249                self.dfs_check(edge.from_node, visited, in_stack)?;
250            }
251        }
252
253        in_stack[idx] = false;
254        Ok(())
255    }
256}
257
258impl Default for MaterialGraph {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264#[derive(Debug)]
265pub enum MaterialError {
266    NoOutput,
267    Cycle,
268    DisconnectedInput {
269        node: MatNodeId,
270        socket: MaterialSocket,
271    },
272    TypeMismatch {
273        from: MaterialSocket,
274        to: MaterialSocket,
275    },
276    CompileError(String),
277    TooManyNodes(usize, usize),
278    UnsupportedNodeType(String),
279}
280
281pub struct MaterialValidationConfig {
282    pub max_nodes: usize,
283}
284
285impl Default for MaterialValidationConfig {
286    fn default() -> Self {
287        Self { max_nodes: 1024 } // arbitrary large number for internal graphs
288    }
289}
290
291impl std::error::Error for MaterialError {}
292
293impl std::fmt::Display for MaterialError {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        match self {
296            Self::NoOutput => write!(f, "material graph has no output node"),
297            Self::Cycle => write!(f, "material graph contains a cycle"),
298            Self::DisconnectedInput { node, socket } => {
299                write!(f, "node {:?} missing input {:?}", node, socket)
300            }
301            Self::TypeMismatch { from, to } => {
302                write!(f, "type mismatch: {:?} -> {:?}", from, to)
303            }
304            Self::CompileError(msg) => write!(f, "WGSL compilation error: {}", msg),
305            Self::TooManyNodes(count, max) => write!(f, "too many nodes: {} (max {})", count, max),
306            Self::UnsupportedNodeType(kind) => write!(f, "unsupported node type: {}", kind),
307        }
308    }
309}
310
311/// Compiled material — a WGSL function that can be included in the main shader.
312#[derive(Debug, Clone)]
313pub struct CompiledMaterial {
314    /// The WGSL function body (everything between the `{` and `}` of the fragment function).
315    pub wgsl_fn: String,
316    /// The function name (unique per material).
317    pub fn_name: String,
318}
319
320impl CompiledMaterial {
321    pub fn hash_code(&self) -> u64 {
322        use std::hash::{Hash, Hasher};
323        let mut hasher = std::collections::hash_map::DefaultHasher::new();
324        self.wgsl_fn.hash(&mut hasher);
325        hasher.finish()
326    }
327}
328
329/// Compiles MaterialGraph → WGSL fragment function.
330pub struct MaterialCompiler;
331
332impl MaterialCompiler {
333    /// Compile a material graph into a WGSL function.
334    ///
335    /// The emitted function has the signature:
336    ///
337    /// ```text
338    /// fn material_<id>(in: VertexOutput, col: vec4<f32>) -> vec4<f32>
339    /// ```
340    ///
341    /// where `in` provides UV/position/size/etc. from the vertex output,
342    /// and `col` is the base vertex color.
343    pub fn compile(graph: &MaterialGraph) -> Result<CompiledMaterial, MaterialError> {
344        graph.validate()?;
345
346        // Topological sort
347        let order = Self::topo_sort(graph)?;
348
349        // Generate WGSL for each node in order
350        let mut lines: Vec<String> = Vec::new();
351        let mut var_names: HashMap<(MatNodeId, MaterialSocket), String> = HashMap::new();
352        let mut next_var = 0;
353
354        let mut mk_var = |prefix: &str| -> String {
355            let v = format!("{}_{}", prefix, next_var);
356            next_var += 1;
357            v
358        };
359
360        for &node_id in &order {
361            let (_, op) = &graph.nodes[node_id as usize];
362            let result_var = mk_var("v");
363
364            let expr = match op {
365                MaterialOp::InputColor => {
366                    "col".to_string()
367                }
368                MaterialOp::ConstantColor { r, g, b, a } => {
369                    format!("vec4<f32>({:.6}, {:.6}, {:.6}, {:.6})", r, g, b, a)
370                }
371                MaterialOp::SampleTexture { tex_index } => {
372                    format!(
373                        "textureSample(t_diffuse[{}u], s_diffuse, in.uv)",
374                        tex_index
375                    )
376                }
377                MaterialOp::PremultipliedBlend => {
378                    let color_var = Self::find_input(&var_names, node_id, MaterialSocket::Color, graph)
379                        .unwrap_or_else(|| "col".to_string());
380                    // Read alpha from a separate texture sample — for fonts this is the single channel
381                    let alpha_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
382                        .unwrap_or_else(|| "1.0".to_string());
383                    format!(
384                        "vec4<f32>(({}).rgb, ({}).a * ({}))",
385                        color_var, color_var, alpha_var
386                    )
387                }
388                MaterialOp::SDFRoundRect => {
389                    let half = "in.size * 0.5";
390                    format!(
391                        r#"
392    let _d = sd_round_rect(in.logical - {0}, {0} - in.radius, in.radius);
393    let _aa = fwidth(_d);
394    __RESULT__ = vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(0.0, _aa, _d)));"#,
395                        half
396                    ).trim().to_string()
397                }
398                MaterialOp::SDFEllipse => {
399                    let half = "in.size * 0.5";
400                    format!(
401                        r#"
402    let _sh = max({0}, vec2<f32>(0.001));
403    let _d = length((in.logical - {0}) / _sh) - 1.0;
404    let _aa = fwidth(_d);
405    __RESULT__ = vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(0.0, _aa, _d)));"#,
406                        half
407                    ).trim().to_string()
408                }
409                MaterialOp::LinearGradient { start, end } => {
410                    let t_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
411                        .unwrap_or_else(|| "in.uv.x".to_string());
412                    format!(
413                        "mix(vec4<f32>({:.6},{:.6},{:.6},{:.6}), vec4<f32>({:.6},{:.6},{:.6},{:.6}), clamp({}, 0.0, 1.0))",
414                        start[0], start[1], start[2], start[3],
415                        end[0], end[1], end[2], end[3],
416                        t_var
417                    )
418                }
419                MaterialOp::RadialGradient { start, end } => {
420                    format!(
421                        r#"
422    let _dist = length(in.uv - 0.5) * 2.0;
423    __RESULT__ = mix(vec4<f32>({:.6},{:.6},{:.6},{:.6}), vec4<f32>({:.6},{:.6},{:.6},{:.6}), clamp(_dist, 0.0, 1.0));"#,
424                        start[0], start[1], start[2], start[3],
425                        end[0], end[1], end[2], end[3],
426                    ).trim().to_string()
427                }
428                MaterialOp::NeonGlow { radius, intensity } => {
429                    let dist_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
430                        .unwrap_or_else(|| "length(in.logical - in.size * 0.5) / max(in.size.x, in.size.y)".to_string());
431                    format!(
432                        "vec4<f32>(col.rgb * exp(-{} * {:.6}), col.a)",
433                        dist_var, intensity / radius.max(0.001)
434                    )
435                }
436                MaterialOp::GlassBlur => {
437                    r#"
438    let uv = clamp(in.uv, vec2<f32>(0.0), vec2<f32>(1.0));
439    let local = in.logical / in.size;
440    let centered = local - vec2<f32>(0.5, 0.5);
441    let lens_dir = normalize(centered + vec2<f32>(1e-5, 1e-5));
442    let lens_dist = length(centered);
443    let fresnel = pow(lens_dist * 1.8, 2.5);
444    let lens = lens_dir * lens_dist * 0.08;
445    let blur_mip = theme.glass_blur_strength;
446    let env_base = textureSampleLevel(t_env, s_env, uv, blur_mip).rgb;
447    let brightness = dot(env_base, vec3<f32>(0.299, 0.587, 0.114));
448    var distortion = lens * 1.2;
449    distortion *= (1.0 + brightness * 0.7);
450    distortion *= 2.0;
451    let ab_offset = distortion * 0.04;
452    let r_sample = textureSampleLevel(t_env, s_env, uv + distortion + ab_offset * 1.2, blur_mip).r;
453    let g_sample = textureSampleLevel(t_env, s_env, uv + distortion, blur_mip).g;
454    let b_sample = textureSampleLevel(t_env, s_env, uv + distortion - ab_offset * 1.2, blur_mip).b;
455    let refracted = vec3<f32>(r_sample, g_sample, b_sample);
456    let tint = vec3<f32>(0.85, 0.9, 1.0);
457    var final_rgb = refracted * tint;
458    final_rgb += (brightness * 0.2) * (0.9 + vnoise(uv * 20.0 + scene.time * 3.0) * 0.1);
459    let half_size = in.size * 0.5;
460    let p_sdf = in.logical - half_size;
461    let q_sdf = abs(p_sdf) - (half_size - in.radius);
462    let d_sdf = length(max(q_sdf, vec2(0.0))) + min(max(q_sdf.x, q_sdf.y), 0.0) - in.radius;
463    let d_norm = clamp(-d_sdf / 20.0, 0.0, 1.0);
464    let flicker = 0.9 + vnoise(uv * 20.0 + scene.time * 3.0) * 0.1;
465    final_rgb += smoothstep(1.0, 0.96, d_norm) * 0.25 * flicker * vec3<f32>(0.7, 1.0, 1.3);
466    final_rgb -= smoothstep(0.96, 0.88, d_norm) * 0.15;
467    let light_dir_h = normalize(vec2<f32>(-0.4, -0.8));
468    let l = dot(uv, light_dir_h);
469    final_rgb += smoothstep(0.45, 0.55, l) * 0.12;
470    __RESULT__ = vec4<f32>(final_rgb, 0.02 + fresnel * 0.15) * (1.0 - smoothstep(-length(vec2(dpdx(in.logical.x), dpdy(in.logical.y))), length(vec2(dpdx(in.logical.x), dpdy(in.logical.y))), d_sdf));"#.trim().to_string()
471                }
472                MaterialOp::LayerBlend { mode } => {
473                    let bottom = Self::find_input(&var_names, node_id, MaterialSocket::Color, graph)
474                        .unwrap_or_else(|| "col".to_string());
475                    let top = Self::find_input_map(&var_names, node_id, MaterialSocket::Color, graph, 1)
476                        .unwrap_or_else(|| "col".to_string());
477                    let opacity = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
478                        .unwrap_or_else(|| "1.0".to_string());
479                    match mode {
480                        BlendMode::Add => {
481                            format!("mix({}, {}, {})", bottom, top, opacity)
482                        }
483                        BlendMode::Screen => {
484                            format!("mix({}, 1.0 - (1.0 - {}) * (1.0 - {}), {})", bottom, bottom, top, opacity)
485                        }
486                        BlendMode::Multiply => {
487                            format!("mix({}, {} * {}, {})", bottom, bottom, top, opacity)
488                        }
489                        BlendMode::Overlay => {
490                            format!("mix({}, select(2.0 * {} * {}, 1.0 - 2.0 * (1.0 - {}) * (1.0 - {}), step(vec4<f32>(0.5), {})), {})", bottom, bottom, top, bottom, top, bottom, opacity)
491                        }
492                    }
493                }
494                MaterialOp::PBRLighting => {
495                    r#"
496    let _n = normalize(in.normal);
497    let _metallic = in.slice.x;
498    let _roughness = in.slice.y;
499    let _opacity = in.slice.z;
500    let _ld = normalize(vec3<f32>(0.5, 0.8, 0.6));
501    let _lc = vec3<f32>(1.0, 0.95, 0.9);
502    let _ndl = max(dot(_n, _ld), 0.0);
503    let _diffuse = _ndl * _lc;
504    let _vd = vec3<f32>(0.0, 0.0, 1.0);
505    let _hd = normalize(_ld + _vd);
506    let _ndh = max(dot(_n, _hd), 0.0);
507    let _shiny = mix(8.0, 256.0, 1.0 - _roughness);
508    let _spec = pow(_ndh, _shiny) * _lc;
509    let _f0 = mix(vec3<f32>(0.04), col.rgb, _metallic);
510    let _fresnel = _f0 + (vec3<f32>(1.0) - _f0) * pow(1.0 - max(dot(_n, -_vd), 0.0), 5.0);
511    let _amb = vec3<f32>(0.06, 0.07, 0.1);
512    var _lit = col.rgb * (_amb + _diffuse);
513    _lit += _spec * mix(vec3<f32>(1.0), col.rgb, _metallic) * _fresnel;
514    let _depth = in.clip_position.z;
515    let _fog = clamp(1.0 - _depth * 0.0005, 0.7, 1.0);
516    _lit *= _fog;
517    __RESULT__ = vec4<f32>(_lit, col.a * _opacity);"#.trim().to_string()
518                }
519                MaterialOp::DropShadow => {
520                    r#"
521    let margin = in.uv.x;
522    let blur = max(in.uv.y, 1.0);
523    let original_size = in.size - 2.0 * margin;
524    let half_size = original_size * 0.5;
525    let p = in.logical - margin - half_size;
526    let d = sd_round_rect(p, half_size - in.radius, in.radius);
527    __RESULT__ = vec4<f32>(col.rgb, col.a * smoothstep(blur, 0.0, d));"#.trim().to_string()
528                }
529                MaterialOp::NineSlice => {
530                    "col".to_string() // Passthrough: 9-slice UV remapping is resolved on CPU
531                }
532                MaterialOp::Heatmap => {
533                    let val_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
534                        .unwrap_or_else(|| "textureSample(t_diffuse[0], s_diffuse, in.uv).r".to_string());
535                    format!("vec4<f32>(heatmap_palette({}), col.a)", val_var)
536                }
537                MaterialOp::Raymarch { shape } => {
538                    match shape {
539                        RaymarchShape::Box => {
540                            r#"
541    let uv = (in.uv - 0.5) * 2.0;
542    let ro = vec3<f32>(0.0, 0.0, -2.5);
543    let rd = normalize(vec3<f32>(uv.x, uv.y, 1.5));
544    let m = rotX(in.slice.x) * rotY(in.slice.y) * rotZ(in.slice.z);
545    var t = 0.0;
546    var hit = false;
547    var d = 0.0;
548    for (var i = 0; i < 40; i++) {
549        let p = m * (ro + rd * t);
550        d = sd_box_3d(p, vec3(0.5, 0.5, 0.5));
551        if d < 0.001 {
552            hit = true;
553            break;
554        }
555        t += d;
556        if t > 5.0 { break; }
557    }
558    if hit {
559        let p = m * (ro + rd * t);
560        let eps = vec2(0.001, 0.0);
561        let n = normalize(vec3(
562            sd_box_3d(p + eps.xyy, vec3(0.5)) - sd_box_3d(p - eps.xyy, vec3(0.5)),
563            sd_box_3d(p + eps.yxy, vec3(0.5)) - sd_box_3d(p - eps.yxy, vec3(0.5)),
564            sd_box_3d(p + eps.yyx, vec3(0.5)) - sd_box_3d(p - eps.yyx, vec3(0.5))
565        ));
566        let light_dir = normalize(vec3(1.0, 1.0, -2.0));
567        let diff = max(dot(n, light_dir), 0.1);
568        let rim = pow(1.0 - max(dot(n, -rd), 0.0), 3.0) * 0.5;
569        __RESULT__ = vec4<f32>(col.rgb * diff + rim, col.a);
570    } else {
571        discard;
572    }"#.trim().to_string()
573                        }
574                        RaymarchShape::Sphere => {
575                            r#"
576    let ro = vec3<f32>(in.uv * 2.0 - 1.0, -2.0);
577    let rd = normalize(vec3<f32>(0.0, 0.0, 1.0));
578    var t = 0.0;
579    var hit = false;
580    for (var i = 0; i < 32; i++) {
581        let p = ro + rd * t;
582        let d = length(p) - 1.0;
583        if d < 0.01 { hit = true; break; }
584        t += d;
585    }
586    if hit {
587        let p = ro + rd * t;
588        let n = normalize(p);
589        let ld = normalize(vec3<f32>(1.0, 1.0, -1.0));
590        let diff = max(dot(n, ld), 0.0);
591        __RESULT__ = vec4<f32>(col.rgb * diff, col.a);
592    } else {
593        discard;
594    }"#.trim().to_string()
595                        }
596                    }
597                }
598                MaterialOp::Lightning => {
599                    r#"
600    let d = length((in.uv - 0.5) * vec2<f32>(1.0, 4.0));
601    __RESULT__ = theme.primary_neon * neon_glow(d, 0.01, 0.2);"#.trim().to_string()
602                }
603                MaterialOp::RuneGlow => {
604                    r#"
605    let p = (in.uv - 0.5) * 2.0;
606    let d = min(sd_segment(p, vec2(-0.5, -0.8), vec2(0.5, 0.8)), sd_segment(p, vec2(0.5, -0.8), vec2(-0.5, 0.8)));
607    __RESULT__ = theme.rune_glow * neon_glow(d, 0.02, 0.15) * theme.rune_opacity;"#.trim().to_string()
608                }
609                MaterialOp::RaymarchReflections => {
610                    r#"
611    let ro = vec3<f32>(in.uv.x - 0.5, in.uv.y - 0.5, -2.0);
612    let rd = normalize(vec3<f32>(in.uv.x - 0.5, in.uv.y - 0.5, 1.0));
613    let t = ray_march(ro, rd);
614    if t > 0.0 {
615        let p = ro + rd * t;
616        let n = calc_normal(p);
617        let light_dir = normalize(vec3<f32>(1.0, 1.0, -1.0));
618        let diff = max(dot(n, light_dir), 0.2);
619        let ref_rd = reflect(rd, n);
620        let ref_t = ray_march(p + n * 0.01, ref_rd);
621        var reflection_color = vec3<f32>(0.05, 0.05, 0.1);
622        if ref_t > 0.0 { reflection_color = mix(theme.primary_neon.rgb, theme.shatter_neon.rgb, 0.5); }
623        __RESULT__ = vec4<f32>(mix(col.rgb * diff, reflection_color, 0.3), 1.0);
624    } else { discard; }"#.trim().to_string()
625                }
626                MaterialOp::Stroke => {
627                    r#"
628    let half_size = in.size * 0.5;
629    let d = sd_round_rect(in.logical - half_size, half_size - in.radius, in.radius);
630    let thickness = max(in.slice.x, 1.0);
631    let fw = length(vec2(dpdx(in.logical.x), dpdy(in.logical.y)));
632    __RESULT__ = vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(-fw, fw, abs(d + thickness * 0.5) - thickness * 0.5)));"#.trim().to_string()
633                }
634                MaterialOp::DashedStroke => {
635                    r#"
636    let half_size = in.size * 0.5;
637    let d = sd_round_rect(in.logical - half_size, half_size - in.radius, in.radius);
638    let thickness = max(in.slice.x, 1.0);
639    let perimeter = (in.uv.x + in.uv.y) * max(in.size.x, in.size.y);
640    var alpha = 1.0 - smoothstep(-length(vec2(dpdx(in.logical.x), dpdy(in.logical.y))), length(vec2(dpdx(in.logical.x), dpdy(in.logical.y))), abs(d + thickness * 0.5) - thickness * 0.5);
641    if (perimeter + scene.time * 20.0) % (max(in.slice.y, 1.0) + max(in.slice.z, 1.0)) > max(in.slice.y, 1.0) { alpha = 0.0; }
642    __RESULT__ = vec4<f32>(col.rgb, col.a * alpha);"#.trim().to_string()
643                }
644            };
645
646            if expr.contains("__RESULT__") {
647                lines.push(format!("    var {}: vec4<f32>;", result_var));
648                lines.push("    {".to_string());
649                lines.push(expr.replace("__RESULT__", &result_var));
650                lines.push("    }".to_string());
651            } else {
652                lines.push(format!("    var {} = {};", result_var, expr));
653            }
654            var_names.insert((node_id, MaterialSocket::Color), result_var);
655        }
656
657        let body = lines.join("\n");
658        let out_id = graph.output.ok_or(MaterialError::NoOutput)?;
659        let fn_name = "material_entry".to_string();
660
661        let wgsl_fn = format!(
662            "fn {}(in: VertexOutput, col: vec4<f32>) -> vec4<f32> {{\n{}\n    return v_{};\n}}",
663            fn_name, body, out_id
664        );
665
666        Ok(CompiledMaterial { wgsl_fn, fn_name })
667    }
668
669    fn find_input(
670        names: &HashMap<(MatNodeId, MaterialSocket), String>,
671        node: MatNodeId,
672        socket: MaterialSocket,
673        graph: &MaterialGraph,
674    ) -> Option<String> {
675        for edge in &graph.edges {
676            if edge.to_node == node && edge.to_socket == socket {
677                return names.get(&(edge.from_node, edge.from_socket)).cloned();
678            }
679        }
680        None
681    }
682
683    fn find_input_map(
684        names: &HashMap<(MatNodeId, MaterialSocket), String>,
685        node: MatNodeId,
686        socket: MaterialSocket,
687        graph: &MaterialGraph,
688        offset: usize,
689    ) -> Option<String> {
690        let mut matches = graph
691            .edges
692            .iter()
693            .filter(|e| e.to_node == node && e.to_socket == socket);
694        let edge = matches.nth(offset)?;
695        names.get(&(edge.from_node, edge.from_socket)).cloned()
696    }
697
698    fn topo_sort(graph: &MaterialGraph) -> Result<Vec<MatNodeId>, MaterialError> {
699        let n = graph.nodes.len();
700        let mut in_degree = vec![0u32; n];
701        let mut adj: Vec<Vec<MatNodeId>> = vec![Vec::new(); n];
702
703        for edge in &graph.edges {
704            adj[edge.from_node as usize].push(edge.to_node);
705            in_degree[edge.to_node as usize] += 1;
706        }
707
708        let mut queue: std::collections::VecDeque<MatNodeId> = std::collections::VecDeque::new();
709        for (i, &deg) in in_degree.iter().enumerate() {
710            if deg == 0 {
711                queue.push_back(i as MatNodeId);
712            }
713        }
714
715        let mut order = Vec::with_capacity(n);
716        while let Some(node) = queue.pop_front() {
717            order.push(node);
718            for &next in &adj[node as usize] {
719                in_degree[next as usize] -= 1;
720                if in_degree[next as usize] == 0 {
721                    queue.push_back(next);
722                }
723            }
724        }
725
726        if order.len() != n {
727            return Err(MaterialError::Cycle);
728        }
729
730        Ok(order)
731    }
732}
733
734/// Pre-built material graphs for the built-in modes.
735/// These replace the if/else chains in shapes.wgsl.
736pub mod builtins {
737    use super::*;
738
739    /// Build a rounded rectangle material (old mode 3).
740    pub fn rounded_rect() -> MaterialGraph {
741        let mut g = MaterialGraph::new();
742        let input = g.add_node(MaterialOp::InputColor);
743        let sdf = g.add_node(MaterialOp::SDFRoundRect);
744        // The SDF node reads vertex data directly; input color provides the base
745        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
746        g.set_output(sdf);
747        g
748    }
749
750    /// Build a glass material (old mode 7).
751    pub fn glass() -> MaterialGraph {
752        let mut g = MaterialGraph::new();
753        let glass = g.add_node(MaterialOp::GlassBlur);
754        g.set_output(glass);
755        g
756    }
757
758    /// Build a solid color material (old mode 0 / default).
759    pub fn solid() -> MaterialGraph {
760        let mut g = MaterialGraph::new();
761        let input = g.add_node(MaterialOp::InputColor);
762        g.set_output(input);
763        g
764    }
765
766    /// Build a PBR material (old mode 13).
767    pub fn pbr() -> MaterialGraph {
768        let mut g = MaterialGraph::new();
769        let input = g.add_node(MaterialOp::InputColor);
770        let pbr = g.add_node(MaterialOp::PBRLighting);
771        g.connect(input, MaterialSocket::Color, pbr, MaterialSocket::Color);
772        g.set_output(pbr);
773        g
774    }
775
776    /// Build a text material (old mode 6) with premultiplied alpha.
777    pub fn text(tex_index: u32) -> MaterialGraph {
778        let mut g = MaterialGraph::new();
779        let input = g.add_node(MaterialOp::InputColor);
780        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
781        let blend = g.add_node(MaterialOp::PremultipliedBlend);
782        g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
783        g.connect(tex, MaterialSocket::Float, blend, MaterialSocket::Float);
784        g.set_output(blend);
785        g
786    }
787
788    /// Build a texture sample material (old mode 2).
789    pub fn textured(tex_index: u32) -> MaterialGraph {
790        let mut g = MaterialGraph::new();
791        let input = g.add_node(MaterialOp::InputColor);
792        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
793        let blend = g.add_node(MaterialOp::LayerBlend {
794            mode: BlendMode::Multiply,
795        });
796        g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
797        g.connect(tex, MaterialSocket::Color, blend, MaterialSocket::Color);
798        g.set_output(blend);
799        g
800    }
801
802    /// Build a neon glow material (old mode 8).
803    pub fn neon_glow(radius: f32, intensity: f32) -> MaterialGraph {
804        let mut g = MaterialGraph::new();
805        let input = g.add_node(MaterialOp::InputColor);
806        let glow = g.add_node(MaterialOp::NeonGlow { radius, intensity });
807        g.connect(input, MaterialSocket::Color, glow, MaterialSocket::Color);
808        g.set_output(glow);
809        g
810    }
811
812    /// Build a linear gradient material (old mode 15).
813    pub fn linear_gradient(start: [f32; 4], end: [f32; 4]) -> MaterialGraph {
814        let mut g = MaterialGraph::new();
815        let grad = g.add_node(MaterialOp::LinearGradient { start, end });
816        g.set_output(grad);
817        g
818    }
819
820    /// Build a radial gradient material (old mode 16).
821    pub fn radial_gradient(start: [f32; 4], end: [f32; 4]) -> MaterialGraph {
822        let mut g = MaterialGraph::new();
823        let grad = g.add_node(MaterialOp::RadialGradient { start, end });
824        g.set_output(grad);
825        g
826    }
827
828    /// Build an ellipse material (old mode 4).
829    pub fn ellipse() -> MaterialGraph {
830        let mut g = MaterialGraph::new();
831        let input = g.add_node(MaterialOp::InputColor);
832        let sdf = g.add_node(MaterialOp::SDFEllipse);
833        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
834        g.set_output(sdf);
835        g
836    }
837
838    /// Build a neon line material (old mode 1).
839    pub fn neon_line() -> MaterialGraph {
840        let mut g = MaterialGraph::new();
841        let color = g.add_node(MaterialOp::ConstantColor {
842            r: 1.5,
843            g: 1.5,
844            b: 1.5,
845            a: 1.0,
846        });
847        g.set_output(color);
848        g
849    }
850
851    /// Build a heatmap material (old mode 12).
852    pub fn heatmap(tex_index: u32) -> MaterialGraph {
853        let mut g = MaterialGraph::new();
854        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
855        let hm = g.add_node(MaterialOp::Heatmap);
856        g.connect(tex, MaterialSocket::Float, hm, MaterialSocket::Float);
857        g.set_output(hm);
858        g
859    }
860
861    /// Build a 9-slice material (old mode 20).
862    pub fn nine_slice(tex_index: u32) -> MaterialGraph {
863        let mut g = MaterialGraph::new();
864        let input = g.add_node(MaterialOp::InputColor);
865        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
866        let blend = g.add_node(MaterialOp::LayerBlend {
867            mode: BlendMode::Multiply,
868        });
869        g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
870        g.connect(tex, MaterialSocket::Color, blend, MaterialSocket::Color);
871        g.set_output(blend);
872        g
873    }
874
875    /// Build a raymarched cube material (old mode 21).
876    pub fn raymarch_cube() -> MaterialGraph {
877        let mut g = MaterialGraph::new();
878        let input = g.add_node(MaterialOp::InputColor);
879        let rm = g.add_node(MaterialOp::Raymarch {
880            shape: RaymarchShape::Box,
881        });
882        g.connect(input, MaterialSocket::Color, rm, MaterialSocket::Color);
883        g.set_output(rm);
884        g
885    }
886
887    /// Build a stroke material (old mode 17).
888    pub fn stroke() -> MaterialGraph {
889        let mut g = MaterialGraph::new();
890        let input = g.add_node(MaterialOp::InputColor);
891        let sdf = g.add_node(MaterialOp::Stroke);
892        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
893        g.set_output(sdf);
894        g
895    }
896
897    /// Build a drop shadow material (old mode 18).
898    pub fn drop_shadow() -> MaterialGraph {
899        let mut g = MaterialGraph::new();
900        let input = g.add_node(MaterialOp::InputColor);
901        let shadow = g.add_node(MaterialOp::DropShadow);
902        g.connect(input, MaterialSocket::Color, shadow, MaterialSocket::Color);
903        g.set_output(shadow);
904        g
905    }
906
907    /// Build a dashed stroke material (old mode 19).
908    pub fn dashed_stroke() -> MaterialGraph {
909        let mut g = MaterialGraph::new();
910        let input = g.add_node(MaterialOp::InputColor);
911        let sdf = g.add_node(MaterialOp::DashedStroke);
912        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
913        g.set_output(sdf);
914        g
915    }
916
917    pub fn lightning() -> MaterialGraph {
918        let mut g = MaterialGraph::new();
919        let l = g.add_node(MaterialOp::Lightning);
920        g.set_output(l);
921        g
922    }
923
924    pub fn rune_glow() -> MaterialGraph {
925        let mut g = MaterialGraph::new();
926        let r = g.add_node(MaterialOp::RuneGlow);
927        g.set_output(r);
928        g
929    }
930
931    pub fn raymarch() -> MaterialGraph {
932        let mut g = MaterialGraph::new();
933        let input = g.add_node(MaterialOp::InputColor);
934        let rm = g.add_node(MaterialOp::RaymarchReflections);
935        g.connect(input, MaterialSocket::Color, rm, MaterialSocket::Color);
936        g.set_output(rm);
937        g
938    }
939}
940
941pub fn generate_builtins_wgsl() -> String {
942    let mut out = String::new();
943    out.push_str("// ── Auto-generated material functions (Runtime) ──\n\n");
944
945    let builtins = vec![
946        (0, "solid", builtins::solid()),
947        (1, "neon_line", builtins::neon_line()),
948        (2, "textured", builtins::textured(0)),
949        (3, "rounded_rect", builtins::rounded_rect()),
950        (4, "ellipse", builtins::ellipse()),
951        (6, "text", builtins::text(0)),
952        (7, "glass", builtins::glass()),
953        (8, "neon_glow", builtins::neon_glow(1.0, 1.0)),
954        (9, "lightning", builtins::lightning()),
955        (10, "rune_glow", builtins::rune_glow()),
956        (12, "heatmap", builtins::heatmap(0)),
957        (13, "pbr", builtins::pbr()),
958        (14, "raymarch", builtins::raymarch()),
959        (
960            15,
961            "linear_grad",
962            builtins::linear_gradient([0.0; 4], [0.0; 4]),
963        ),
964        (
965            16,
966            "radial_grad",
967            builtins::radial_gradient([0.0; 4], [0.0; 4]),
968        ),
969        (17, "stroke", builtins::stroke()),
970        (18, "drop_shadow", builtins::drop_shadow()),
971        (19, "dashed", builtins::dashed_stroke()),
972        (20, "nine_slice", builtins::nine_slice(0)),
973        (21, "raymarch_cube", builtins::raymarch_cube()),
974    ];
975
976    let mut dispatch = String::new();
977    dispatch.push_str(
978        "fn dispatch_material(material_id: u32, in: VertexOutput, col: vec4<f32>) -> vec4<f32> {\n",
979    );
980    dispatch.push_str("    switch material_id {\n");
981
982    for (id, name, graph) in builtins {
983        let compiled = MaterialCompiler::compile(&graph).unwrap();
984        let fn_name = format!("material_{}_{}", id, name);
985        let fn_code = compiled.wgsl_fn.replace("material_entry", &fn_name);
986        out.push_str(&fn_code);
987        out.push_str("\n\n");
988
989        dispatch.push_str(&format!(
990            "        case {}u: {{ return {}(in, col); }}\n",
991            id, fn_name
992        ));
993    }
994
995    dispatch.push_str("        default: { return col; }\n");
996    dispatch.push_str("    }\n}\n");
997
998    out.push_str(&dispatch);
999    out
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005
1006    #[test]
1007    fn test_solid_material_compiles() {
1008        let graph = builtins::solid();
1009        let compiled = MaterialCompiler::compile(&graph).unwrap();
1010        assert!(compiled.wgsl_fn.contains("fn material_"));
1011        assert!(compiled.wgsl_fn.contains("col"));
1012    }
1013
1014    #[test]
1015    fn test_rounded_rect_compiles() {
1016        let graph = builtins::rounded_rect();
1017        let compiled = MaterialCompiler::compile(&graph).unwrap();
1018        assert!(compiled.wgsl_fn.contains("sd_round_rect"));
1019    }
1020
1021    #[test]
1022    fn test_pbr_compiles() {
1023        let graph = builtins::pbr();
1024        let compiled = MaterialCompiler::compile(&graph).unwrap();
1025        assert!(compiled.wgsl_fn.contains("PBRLighting") || compiled.wgsl_fn.contains("_n"));
1026    }
1027
1028    #[test]
1029    fn test_graph_validation_no_output() {
1030        let mut g = MaterialGraph::new();
1031        g.add_node(MaterialOp::InputColor);
1032        assert!(g.validate().is_err());
1033    }
1034
1035    #[test]
1036    fn test_graph_validation_cycle() {
1037        let mut g = MaterialGraph::new();
1038        let a = g.add_node(MaterialOp::InputColor);
1039        let b = g.add_node(MaterialOp::NeonGlow {
1040            radius: 1.0,
1041            intensity: 1.0,
1042        });
1043        g.connect(a, MaterialSocket::Color, b, MaterialSocket::Color);
1044        g.connect(b, MaterialSocket::Color, a, MaterialSocket::Color); // cycle!
1045        g.set_output(b);
1046        assert!(g.validate().is_err());
1047    }
1048
1049    #[test]
1050    fn test_all_builtins_compile() {
1051        let graphs: Vec<MaterialGraph> = vec![
1052            builtins::solid(),
1053            builtins::rounded_rect(),
1054            builtins::glass(),
1055            builtins::pbr(),
1056            builtins::text(0),
1057            builtins::textured(0),
1058            builtins::neon_glow(4.0, 1.5),
1059            builtins::linear_gradient([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]),
1060            builtins::radial_gradient([1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]),
1061            builtins::ellipse(),
1062            builtins::neon_line(),
1063            builtins::heatmap(0),
1064            builtins::nine_slice(0),
1065            builtins::raymarch_cube(),
1066            builtins::stroke(),
1067            builtins::drop_shadow(),
1068            builtins::dashed_stroke(),
1069        ];
1070
1071        for (i, graph) in graphs.iter().enumerate() {
1072            match MaterialCompiler::compile(graph) {
1073                Ok(compiled) => {
1074                    assert!(
1075                        !compiled.wgsl_fn.is_empty(),
1076                        "graph {} produced empty WGSL",
1077                        i
1078                    );
1079                    assert!(
1080                        !compiled.fn_name.is_empty(),
1081                        "graph {} produced empty fn name",
1082                        i
1083                    );
1084                }
1085                Err(e) => {
1086                    panic!("graph {} failed to compile: {}", i, e);
1087                }
1088            }
1089        }
1090    }
1091}