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 { r: f32, g: f32, b: f32, a: f32 },
36
37    /// Input: UV from vertex.
38    /// Output: sample result Color
39    SampleTexture { tex_index: u32 },
40
41    /// Premultiplied alpha blend (for font atlas).
42    /// Inputs: color (Color), alpha (Float from texture)
43    /// Output: Color
44    PremultipliedBlend,
45
46    /// SDF rounded rectangle mask.
47    /// Inputs: none (reads vertex logical, size, radius)
48    /// Output: Mask
49    SDFRoundRect,
50
51    /// SDF ellipse mask.
52    /// Output: Mask
53    SDFEllipse,
54
55    /// Linear gradient between two colors.
56    /// Input: t (Float, typically UV-based)
57    /// Output: Color
58    LinearGradient { start: [f32; 4], end: [f32; 4] },
59
60    /// Radial gradient.
61    /// Input: dist (Float)
62    /// Output: Color
63    RadialGradient { start: [f32; 4], end: [f32; 4] },
64
65    /// Neon glow effect.
66    /// Input: dist (Float), color (Color)
67    /// Output: Color
68    NeonGlow { radius: f32, intensity: f32 },
69
70    /// Glass fresnel refraction.
71    /// Inputs: uv (Vec2), blur_mip (Float)
72    /// Output: Color
73    GlassBlur,
74
75    /// Layer two inputs with a blend mode.
76    /// Inputs: bottom (Color), top (Color), opacity (Float)
77    /// Output: Color
78    LayerBlend { mode: BlendMode },
79
80    /// PBR lighting.
81    /// Input: normal (Vec3), metallic (Float), roughness (Float), opacity (Float)
82    /// Output: Color
83    PBRLighting,
84
85    /// Drop shadow.
86    /// Inputs: uv (Vec2), size (Vec2), radius (Float)
87    /// Output: Mask
88    DropShadow,
89
90    /// 9-slice UV remapping.
91    /// Input: uv (Vec2)
92    /// Output: Vec2
93    NineSlice,
94
95    /// Heatmap palette lookup.
96    /// Input: value (Float)
97    /// Output: Color
98    Heatmap,
99
100    /// Raymarched SDF shape.
101    /// Output: Color
102    Raymarch { shape: RaymarchShape },
103}
104
105#[derive(Debug, Clone, Copy)]
106pub enum BlendMode {
107    Add,
108    Screen,
109    Multiply,
110    Overlay,
111}
112
113#[derive(Debug, Clone, Copy)]
114pub enum RaymarchShape {
115    Sphere,
116    Box,
117}
118
119/// Connection between two nodes.
120#[derive(Debug, Clone)]
121pub struct MaterialEdge {
122    pub from_node: u32,
123    pub from_socket: MaterialSocket,
124    pub to_node: u32,
125    pub to_socket: MaterialSocket,
126}
127
128/// Index into the material graph's node list.
129pub type MatNodeId = u32;
130
131/// A directed acyclic graph of material operations.
132#[derive(Debug, Clone)]
133pub struct MaterialGraph {
134    pub nodes: Vec<(MatNodeId, MaterialOp)>,
135    pub edges: Vec<MaterialEdge>,
136    pub output: Option<MatNodeId>,
137}
138
139impl MaterialGraph {
140    pub fn new() -> Self {
141        Self {
142            nodes: Vec::new(),
143            edges: Vec::new(),
144            output: None,
145        }
146    }
147
148    pub fn add_node(&mut self, op: MaterialOp) -> MatNodeId {
149        let id = self.nodes.len() as MatNodeId;
150        self.nodes.push((id, op));
151        id
152    }
153
154    pub fn connect(
155        &mut self,
156        from: MatNodeId,
157        from_socket: MaterialSocket,
158        to: MatNodeId,
159        to_socket: MaterialSocket,
160    ) {
161        self.edges.push(MaterialEdge {
162            from_node: from,
163            from_socket,
164            to_node: to,
165            to_socket,
166        });
167    }
168
169    pub fn set_output(&mut self, node: MatNodeId) {
170        self.output = Some(node);
171    }
172
173    /// Validate the graph: no cycles, output connected, all inputs satisfied.
174    pub fn validate(&self) -> Result<(), MaterialError> {
175        if self.output.is_none() {
176            return Err(MaterialError::NoOutput);
177        }
178        // Cycle detection via DFS
179        let mut visited = vec![false; self.nodes.len()];
180        let mut in_stack = vec![false; self.nodes.len()];
181
182        for &(id, _) in &self.nodes {
183            if !visited[id as usize] {
184                self.dfs_check(id, &mut visited, &mut in_stack)?;
185            }
186        }
187        Ok(())
188    }
189
190    fn dfs_check(
191        &self,
192        node: MatNodeId,
193        visited: &mut [bool],
194        in_stack: &mut [bool],
195    ) -> Result<(), MaterialError> {
196        let idx = node as usize;
197        if in_stack[idx] {
198            return Err(MaterialError::Cycle);
199        }
200        if visited[idx] {
201            return Ok(());
202        }
203        visited[idx] = true;
204        in_stack[idx] = true;
205
206        // Find all edges where this node is the consumer (to_node)
207        for edge in &self.edges {
208            if edge.to_node == node {
209                self.dfs_check(edge.from_node, visited, in_stack)?;
210            }
211        }
212
213        in_stack[idx] = false;
214        Ok(())
215    }
216}
217
218impl Default for MaterialGraph {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224#[derive(Debug)]
225pub enum MaterialError {
226    NoOutput,
227    Cycle,
228    DisconnectedInput { node: MatNodeId, socket: MaterialSocket },
229    TypeMismatch { from: MaterialSocket, to: MaterialSocket },
230    CompileError(String),
231}
232
233impl std::error::Error for MaterialError {}
234
235impl std::fmt::Display for MaterialError {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        match self {
238            Self::NoOutput => write!(f, "material graph has no output node"),
239            Self::Cycle => write!(f, "material graph contains a cycle"),
240            Self::DisconnectedInput { node, socket } => {
241                write!(f, "node {:?} missing input {:?}", node, socket)
242            }
243            Self::TypeMismatch { from, to } => {
244                write!(f, "type mismatch: {:?} -> {:?}", from, to)
245            }
246            Self::CompileError(msg) => write!(f, "compile error: {}", msg),
247        }
248    }
249}
250
251/// Compiled material — a WGSL function that can be included in the main shader.
252#[derive(Debug, Clone)]
253pub struct CompiledMaterial {
254    /// The WGSL function body (everything between the `{` and `}` of the fragment function).
255    pub wgsl_fn: String,
256    /// The function name (unique per material).
257    pub fn_name: String,
258}
259
260/// Compiles MaterialGraph → WGSL fragment function.
261pub struct MaterialCompiler;
262
263impl MaterialCompiler {
264    /// Compile a material graph into a WGSL function.
265    ///
266    /// The emitted function has the signature:
267    ///
268    /// ```text
269    /// fn material_<id>(in: VertexOutput, col: vec4<f32>) -> vec4<f32>
270    /// ```
271    ///
272    /// where `in` provides UV/position/size/etc. from the vertex output,
273    /// and `col` is the base vertex color.
274    pub fn compile(graph: &MaterialGraph) -> Result<CompiledMaterial, MaterialError> {
275        graph.validate()?;
276
277        // Topological sort
278        let order = Self::topo_sort(graph)?;
279
280        // Generate WGSL for each node in order
281        let mut lines: Vec<String> = Vec::new();
282        let mut var_names: HashMap<(MatNodeId, MaterialSocket), String> = HashMap::new();
283        let mut next_var = 0;
284
285        let mut mk_var = |prefix: &str| -> String {
286            let v = format!("{}_{}", prefix, next_var);
287            next_var += 1;
288            v
289        };
290
291        for &node_id in &order {
292            let (_, op) = &graph.nodes[node_id as usize];
293            let result_var = mk_var("v");
294
295            let expr = match op {
296                MaterialOp::InputColor => {
297                    "col".to_string()
298                }
299                MaterialOp::ConstantColor { r, g, b, a } => {
300                    format!("vec4<f32>({:.6}, {:.6}, {:.6}, {:.6})", r, g, b, a)
301                }
302                MaterialOp::SampleTexture { tex_index } => {
303                    format!(
304                        "textureSample(t_diffuse[{}u], s_diffuse, in.uv)",
305                        tex_index
306                    )
307                }
308                MaterialOp::PremultipliedBlend => {
309                    let color_var = Self::find_input(&var_names, node_id, MaterialSocket::Color, graph)
310                        .unwrap_or_else(|| "col".to_string());
311                    // Read alpha from a separate texture sample — for fonts this is the single channel
312                    let alpha_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
313                        .unwrap_or_else(|| "1.0".to_string());
314                    format!(
315                        "vec4<f32>(({}).rgb, ({}).a * ({}))",
316                        color_var, color_var, alpha_var
317                    )
318                }
319                MaterialOp::SDFRoundRect => {
320                    let half = "in.size * 0.5";
321                    format!(
322                        r#"
323    let _d = sd_round_rect(in.logical - {0}, {0} - in.radius, in.radius);
324    let _aa = fwidth(_d);
325    vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(0.0, _aa, _d)))"#,
326                        half
327                    ).trim().to_string()
328                }
329                MaterialOp::SDFEllipse => {
330                    let half = "in.size * 0.5";
331                    format!(
332                        r#"
333    let _sh = max({0}, vec2<f32>(0.001));
334    let _d = length((in.logical - {0}) / _sh) - 1.0;
335    let _aa = fwidth(_d);
336    vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(0.0, _aa, _d)))"#,
337                        half
338                    ).trim().to_string()
339                }
340                MaterialOp::LinearGradient { start, end } => {
341                    let t_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
342                        .unwrap_or_else(|| "in.uv.x".to_string());
343                    format!(
344                        "mix(vec4<f32>({:.6},{:.6},{:.6},{:.6}), vec4<f32>({:.6},{:.6},{:.6},{:.6}), clamp({}, 0.0, 1.0))",
345                        start[0], start[1], start[2], start[3],
346                        end[0], end[1], end[2], end[3],
347                        t_var
348                    )
349                }
350                MaterialOp::RadialGradient { start, end } => {
351                    format!(
352                        r#"
353    let _dist = length(in.uv - 0.5) * 2.0;
354    mix(vec4<f32>({:.6},{:.6},{:.6},{:.6}), vec4<f32>({:.6},{:.6},{:.6},{:.6}), clamp(_dist, 0.0, 1.0))"#,
355                        start[0], start[1], start[2], start[3],
356                        end[0], end[1], end[2], end[3],
357                    ).trim().to_string()
358                }
359                MaterialOp::NeonGlow { radius, intensity } => {
360                    let dist_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
361                        .unwrap_or_else(|| "length(in.logical - in.size * 0.5) / max(in.size.x, in.size.y)".to_string());
362                    format!(
363                        "vec4<f32>(col.rgb * exp(-{} * {:.6}), col.a)",
364                        dist_var, intensity / radius.max(0.001)
365                    )
366                }
367                MaterialOp::GlassBlur => {
368                    r#"
369    let _uv = clamp(in.uv, vec2<f32>(0.0), vec2<f32>(1.0));
370    let _blur_mip = theme.glass_blur_strength;
371    let _env_base = textureSampleLevel(t_env, s_env, _uv, _blur_mip).rgb;
372    vec4<f32>(_env_base, 0.02 + pow(length(in.logical / in.size - 0.5) * 1.8, 2.5) * 0.15)"#.trim().to_string()
373                }
374                MaterialOp::LayerBlend { mode } => {
375                    let bottom = Self::find_input(&var_names, node_id, MaterialSocket::Color, graph)
376                        .unwrap_or_else(|| "col".to_string());
377                    let top = Self::find_input_map(&var_names, node_id, MaterialSocket::Color, graph, 1)
378                        .unwrap_or_else(|| "col".to_string());
379                    let opacity = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
380                        .unwrap_or_else(|| "1.0".to_string());
381                    match mode {
382                        BlendMode::Add => {
383                            format!("mix({}, {}, {})", bottom, top, opacity)
384                        }
385                        BlendMode::Screen => {
386                            format!("mix({}, 1.0 - (1.0 - {}) * (1.0 - {}), {})", bottom, bottom, top, opacity)
387                        }
388                        BlendMode::Multiply => {
389                            format!("mix({}, {} * {}, {})", bottom, bottom, top, opacity)
390                        }
391                        BlendMode::Overlay => {
392                            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)
393                        }
394                    }
395                }
396                MaterialOp::PBRLighting => {
397                    r#"
398    let _n = normalize(in.normal);
399    let _metallic = in.slice.x;
400    let _roughness = in.slice.y;
401    let _opacity = in.slice.z;
402    let _ld = normalize(vec3<f32>(0.5, 0.8, 0.6));
403    let _lc = vec3<f32>(1.0, 0.95, 0.9);
404    let _ndl = max(dot(_n, _ld), 0.0);
405    let _diffuse = _ndl * _lc;
406    let _vd = vec3<f32>(0.0, 0.0, 1.0);
407    let _hd = normalize(_ld + _vd);
408    let _ndh = max(dot(_n, _hd), 0.0);
409    let _shiny = mix(8.0, 256.0, 1.0 - _roughness);
410    let _spec = pow(_ndh, _shiny) * _lc;
411    let _f0 = mix(vec3<f32>(0.04), col.rgb, _metallic);
412    let _fresnel = _f0 + (vec3<f32>(1.0) - _f0) * pow(1.0 - max(dot(_n, -_vd), 0.0), 5.0);
413    let _amb = vec3<f32>(0.06, 0.07, 0.1);
414    var _lit = col.rgb * (_amb + _diffuse);
415    _lit += _spec * mix(vec3<f32>(1.0), col.rgb, _metallic) * _fresnel;
416    let _depth = in.clip_position.z;
417    let _fog = clamp(1.0 - _depth * 0.0005, 0.7, 1.0);
418    _lit *= _fog;
419    vec4<f32>(_lit, col.a * _opacity)"#.trim().to_string()
420                }
421                MaterialOp::DropShadow => {
422                    r#"
423    let _margin = in.uv.x;
424    let _blur = max(in.uv.y, 1.0);
425    let _original_size = in.size - 2.0 * _margin;
426    let _half_size = _original_size * 0.5;
427    let _p = in.logical - _margin - _half_size;
428    let _d = length(max(abs(_p) - (_half_size - in.radius), vec2(0.0))) + min(max(abs(_p).x - (_half_size - in.radius).x, abs(_p).y - (_half_size - in.radius).y), 0.0) - in.radius;
429    vec4<f32>(col.rgb, col.a * smoothstep(_blur, 0.0, _d))"#.trim().to_string()
430                }
431                MaterialOp::NineSlice => {
432                    "col".to_string() // Passthrough: 9-slice UV remapping is resolved on CPU
433                }
434                MaterialOp::Heatmap => {
435                    let val_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
436                        .unwrap_or_else(|| "textureSample(t_diffuse[0], s_diffuse, in.uv).r".to_string());
437                    format!("vec4<f32>(heatmap_palette({}), col.a)", val_var)
438                }
439                MaterialOp::Raymarch { shape } => {
440                    match shape {
441                        RaymarchShape::Box => {
442                            r#"
443    let _uv = (in.uv - 0.5) * 2.0;
444    let _ro = vec3<f32>(0.0, 0.0, -2.5);
445    let _rd = normalize(vec3<f32>(_uv.x, _uv.y, 1.5));
446    let _m = rotX(in.slice.x) * rotY(in.slice.y) * rotZ(in.slice.z);
447    var _t = 0.0;
448    var _hit = false;
449    var _d = 0.0;
450    for (var _i = 0; _i < 40; _i++) {
451        let _p = _m * (_ro + _rd * _t);
452        _d = sd_box_3d(_p, vec3(0.5, 0.5, 0.5));
453        if _d < 0.001 {
454            _hit = true;
455            break;
456        }
457        _t += _d;
458        if _t > 5.0 { break; }
459    }
460    if _hit {
461        let _p2 = _m * (_ro + _rd * _t);
462        let _eps = vec2(0.001, 0.0);
463        let _n = normalize(vec3(
464            sd_box_3d(_p2 + _eps.xyy, vec3(0.5)) - sd_box_3d(_p2 - _eps.xyy, vec3(0.5)),
465            sd_box_3d(_p2 + _eps.yxy, vec3(0.5)) - sd_box_3d(_p2 - _eps.yxy, vec3(0.5)),
466            sd_box_3d(_p2 + _eps.yyx, vec3(0.5)) - sd_box_3d(_p2 - _eps.yyx, vec3(0.5))
467        ));
468        let _ld2 = normalize(vec3(1.0, 1.0, -2.0));
469        let _diff2 = max(dot(_n, _ld2), 0.1);
470        let _rim = pow(1.0 - max(dot(_n, -_rd), 0.0), 3.0) * 0.5;
471        vec4<f32>(col.rgb * _diff2 + _rim, col.a)
472    } else {
473        discard;
474    }"#.trim().to_string()
475                        }
476                        RaymarchShape::Sphere => {
477                            r#"
478    let _ro = vec3<f32>(in.uv * 2.0 - 1.0, -2.0);
479    let _rd = normalize(vec3<f32>(0.0, 0.0, 1.0));
480    var _t = 0.0;
481    var _hit = false;
482    for (var i = 0; i < 32; i++) {
483        let _p = _ro + _rd * _t;
484        let _d = length(_p) - 1.0;
485        if _d < 0.01 { _hit = true; break; }
486        _t += _d;
487    }
488    if _hit {
489        let _p = _ro + _rd * _t;
490        let _n = normalize(_p);
491        let _ld = normalize(vec3<f32>(1.0, 1.0, -1.0));
492        let _diff = max(dot(_n, _ld), 0.0);
493        vec4<f32>(col.rgb * _diff, col.a)
494    } else {
495        discard;
496    }"#.trim().to_string()
497                        }
498                    }
499                }
500            };
501
502            lines.push(format!("    var {} = {};", result_var, expr));
503            var_names.insert((node_id, MaterialSocket::Color), result_var);
504        }
505
506        let body = lines.join("\n");
507        let out_id = graph.output.ok_or(MaterialError::NoOutput)?;
508        let fn_name = format!("material_{}", out_id);
509
510        let wgsl_fn = format!(
511            "fn {}(in: VertexOutput, col: vec4<f32>) -> vec4<f32> {{\n{}\n    return v_{};\n}}",
512            fn_name, body, out_id
513        );
514
515        Ok(CompiledMaterial { wgsl_fn, fn_name })
516    }
517
518    fn find_input(
519        names: &HashMap<(MatNodeId, MaterialSocket), String>,
520        node: MatNodeId,
521        socket: MaterialSocket,
522        graph: &MaterialGraph,
523    ) -> Option<String> {
524        for edge in &graph.edges {
525            if edge.to_node == node && edge.to_socket == socket {
526                return names.get(&(edge.from_node, edge.from_socket)).cloned();
527            }
528        }
529        None
530    }
531
532    fn find_input_map(
533        names: &HashMap<(MatNodeId, MaterialSocket), String>,
534        node: MatNodeId,
535        socket: MaterialSocket,
536        graph: &MaterialGraph,
537        offset: usize,
538    ) -> Option<String> {
539        let mut matches = graph.edges.iter().filter(|e| e.to_node == node && e.to_socket == socket);
540        let edge = matches.nth(offset)?;
541        names.get(&(edge.from_node, edge.from_socket)).cloned()
542    }
543
544    fn topo_sort(graph: &MaterialGraph) -> Result<Vec<MatNodeId>, MaterialError> {
545        let n = graph.nodes.len();
546        let mut in_degree = vec![0u32; n];
547        let mut adj: Vec<Vec<MatNodeId>> = vec![Vec::new(); n];
548
549        for edge in &graph.edges {
550            adj[edge.from_node as usize].push(edge.to_node);
551            in_degree[edge.to_node as usize] += 1;
552        }
553
554        let mut queue: std::collections::VecDeque<MatNodeId> = std::collections::VecDeque::new();
555        for (i, &deg) in in_degree.iter().enumerate() {
556            if deg == 0 {
557                queue.push_back(i as MatNodeId);
558            }
559        }
560
561        let mut order = Vec::with_capacity(n);
562        while let Some(node) = queue.pop_front() {
563            order.push(node);
564            for &next in &adj[node as usize] {
565                in_degree[next as usize] -= 1;
566                if in_degree[next as usize] == 0 {
567                    queue.push_back(next);
568                }
569            }
570        }
571
572        if order.len() != n {
573            return Err(MaterialError::Cycle);
574        }
575
576        Ok(order)
577    }
578}
579
580/// Pre-built material graphs for the built-in modes.
581/// These replace the if/else chains in shapes.wgsl.
582pub mod builtins {
583    use super::*;
584
585    /// Build a rounded rectangle material (old mode 3).
586    pub fn rounded_rect() -> MaterialGraph {
587        let mut g = MaterialGraph::new();
588        let input = g.add_node(MaterialOp::InputColor);
589        let sdf = g.add_node(MaterialOp::SDFRoundRect);
590        // The SDF node reads vertex data directly; input color provides the base
591        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
592        g.set_output(sdf);
593        g
594    }
595
596    /// Build a glass material (old mode 7).
597    pub fn glass() -> MaterialGraph {
598        let mut g = MaterialGraph::new();
599        let glass = g.add_node(MaterialOp::GlassBlur);
600        g.set_output(glass);
601        g
602    }
603
604    /// Build a solid color material (old mode 0 / default).
605    pub fn solid() -> MaterialGraph {
606        let mut g = MaterialGraph::new();
607        let input = g.add_node(MaterialOp::InputColor);
608        g.set_output(input);
609        g
610    }
611
612    /// Build a PBR material (old mode 13).
613    pub fn pbr() -> MaterialGraph {
614        let mut g = MaterialGraph::new();
615        let input = g.add_node(MaterialOp::InputColor);
616        let pbr = g.add_node(MaterialOp::PBRLighting);
617        g.connect(input, MaterialSocket::Color, pbr, MaterialSocket::Color);
618        g.set_output(pbr);
619        g
620    }
621
622    /// Build a text material (old mode 6) with premultiplied alpha.
623    pub fn text(tex_index: u32) -> MaterialGraph {
624        let mut g = MaterialGraph::new();
625        let input = g.add_node(MaterialOp::InputColor);
626        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
627        let blend = g.add_node(MaterialOp::PremultipliedBlend);
628        g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
629        g.connect(tex, MaterialSocket::Float, blend, MaterialSocket::Float);
630        g.set_output(blend);
631        g
632    }
633
634    /// Build a texture sample material (old mode 2).
635    pub fn textured(tex_index: u32) -> MaterialGraph {
636        let mut g = MaterialGraph::new();
637        let input = g.add_node(MaterialOp::InputColor);
638        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
639        let blend = g.add_node(MaterialOp::LayerBlend { mode: BlendMode::Multiply });
640        g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
641        g.connect(tex, MaterialSocket::Color, blend, MaterialSocket::Color);
642        g.set_output(blend);
643        g
644    }
645
646    /// Build a neon glow material (old mode 8).
647    pub fn neon_glow(radius: f32, intensity: f32) -> MaterialGraph {
648        let mut g = MaterialGraph::new();
649        let input = g.add_node(MaterialOp::InputColor);
650        let glow = g.add_node(MaterialOp::NeonGlow { radius, intensity });
651        g.connect(input, MaterialSocket::Color, glow, MaterialSocket::Color);
652        g.set_output(glow);
653        g
654    }
655
656    /// Build a linear gradient material (old mode 15).
657    pub fn linear_gradient(start: [f32; 4], end: [f32; 4]) -> MaterialGraph {
658        let mut g = MaterialGraph::new();
659        let grad = g.add_node(MaterialOp::LinearGradient { start, end });
660        g.set_output(grad);
661        g
662    }
663
664    /// Build a radial gradient material (old mode 16).
665    pub fn radial_gradient(start: [f32; 4], end: [f32; 4]) -> MaterialGraph {
666        let mut g = MaterialGraph::new();
667        let grad = g.add_node(MaterialOp::RadialGradient { start, end });
668        g.set_output(grad);
669        g
670    }
671
672    /// Build an ellipse material (old mode 4).
673    pub fn ellipse() -> MaterialGraph {
674        let mut g = MaterialGraph::new();
675        let input = g.add_node(MaterialOp::InputColor);
676        let sdf = g.add_node(MaterialOp::SDFEllipse);
677        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
678        g.set_output(sdf);
679        g
680    }
681
682    /// Build a neon line material (old mode 1).
683    pub fn neon_line() -> MaterialGraph {
684        let mut g = MaterialGraph::new();
685        let color = g.add_node(MaterialOp::ConstantColor { r: 1.5, g: 1.5, b: 1.5, a: 1.0 });
686        g.set_output(color);
687        g
688    }
689
690    /// Build a heatmap material (old mode 12).
691    pub fn heatmap(tex_index: u32) -> MaterialGraph {
692        let mut g = MaterialGraph::new();
693        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
694        let hm = g.add_node(MaterialOp::Heatmap);
695        g.connect(tex, MaterialSocket::Float, hm, MaterialSocket::Float);
696        g.set_output(hm);
697        g
698    }
699
700    /// Build a 9-slice material (old mode 20).
701    pub fn nine_slice(tex_index: u32) -> MaterialGraph {
702        let mut g = MaterialGraph::new();
703        let input = g.add_node(MaterialOp::InputColor);
704        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
705        let blend = g.add_node(MaterialOp::LayerBlend { mode: BlendMode::Multiply });
706        g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
707        g.connect(tex, MaterialSocket::Color, blend, MaterialSocket::Color);
708        g.set_output(blend);
709        g
710    }
711
712    /// Build a raymarched cube material (old mode 21).
713    pub fn raymarch_cube() -> MaterialGraph {
714        let mut g = MaterialGraph::new();
715        let input = g.add_node(MaterialOp::InputColor);
716        let rm = g.add_node(MaterialOp::Raymarch { shape: RaymarchShape::Box });
717        g.connect(input, MaterialSocket::Color, rm, MaterialSocket::Color);
718        g.set_output(rm);
719        g
720    }
721
722    /// Build a stroke material (old mode 17).
723    pub fn stroke() -> MaterialGraph {
724        let mut g = MaterialGraph::new();
725        let input = g.add_node(MaterialOp::InputColor);
726        let sdf = g.add_node(MaterialOp::SDFRoundRect);
727        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
728        g.set_output(sdf);
729        g
730    }
731
732    /// Build a drop shadow material (old mode 18).
733    pub fn drop_shadow() -> MaterialGraph {
734        let mut g = MaterialGraph::new();
735        let input = g.add_node(MaterialOp::InputColor);
736        let shadow = g.add_node(MaterialOp::DropShadow);
737        g.connect(input, MaterialSocket::Color, shadow, MaterialSocket::Color);
738        g.set_output(shadow);
739        g
740    }
741
742    /// Build a dashed stroke material (old mode 19).
743    pub fn dashed_stroke() -> MaterialGraph {
744        let mut g = MaterialGraph::new();
745        let input = g.add_node(MaterialOp::InputColor);
746        let sdf = g.add_node(MaterialOp::SDFRoundRect);
747        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
748        g.set_output(sdf);
749        g
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    #[test]
758    fn test_solid_material_compiles() {
759        let graph = builtins::solid();
760        let compiled = MaterialCompiler::compile(&graph).unwrap();
761        assert!(compiled.wgsl_fn.contains("fn material_"));
762        assert!(compiled.wgsl_fn.contains("col"));
763    }
764
765    #[test]
766    fn test_rounded_rect_compiles() {
767        let graph = builtins::rounded_rect();
768        let compiled = MaterialCompiler::compile(&graph).unwrap();
769        assert!(compiled.wgsl_fn.contains("sd_round_rect"));
770    }
771
772    #[test]
773    fn test_pbr_compiles() {
774        let graph = builtins::pbr();
775        let compiled = MaterialCompiler::compile(&graph).unwrap();
776        assert!(compiled.wgsl_fn.contains("PBRLighting") || compiled.wgsl_fn.contains("_n"));
777    }
778
779    #[test]
780    fn test_graph_validation_no_output() {
781        let mut g = MaterialGraph::new();
782        g.add_node(MaterialOp::InputColor);
783        assert!(g.validate().is_err());
784    }
785
786    #[test]
787    fn test_graph_validation_cycle() {
788        let mut g = MaterialGraph::new();
789        let a = g.add_node(MaterialOp::InputColor);
790        let b = g.add_node(MaterialOp::NeonGlow { radius: 1.0, intensity: 1.0 });
791        g.connect(a, MaterialSocket::Color, b, MaterialSocket::Color);
792        g.connect(b, MaterialSocket::Color, a, MaterialSocket::Color); // cycle!
793        g.set_output(b);
794        assert!(g.validate().is_err());
795    }
796
797    #[test]
798    fn test_all_builtins_compile() {
799        let graphs: Vec<MaterialGraph> = vec![
800            builtins::solid(),
801            builtins::rounded_rect(),
802            builtins::glass(),
803            builtins::pbr(),
804            builtins::text(0),
805            builtins::textured(0),
806            builtins::neon_glow(4.0, 1.5),
807            builtins::linear_gradient([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]),
808            builtins::radial_gradient([1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]),
809            builtins::ellipse(),
810            builtins::neon_line(),
811            builtins::heatmap(0),
812            builtins::nine_slice(0),
813            builtins::raymarch_cube(),
814            builtins::stroke(),
815            builtins::drop_shadow(),
816            builtins::dashed_stroke(),
817        ];
818
819        for (i, graph) in graphs.iter().enumerate() {
820            match MaterialCompiler::compile(graph) {
821                Ok(compiled) => {
822                    assert!(!compiled.wgsl_fn.is_empty(), "graph {} produced empty WGSL", i);
823                    assert!(!compiled.fn_name.is_empty(), "graph {} produced empty fn name", i);
824                }
825                Err(e) => {
826                    panic!("graph {} failed to compile: {}", i, e);
827                }
828            }
829        }
830    }
831}