Skip to main content

kvas_engine/
material_graph.rs

1//! Node-based materials. A material is a graph of small nodes (textures, colours, maths, noise,
2//! gradients...) saved as a `.kmat` file; [`to_wgsl`] turns it into a fragment shader that the
3//! renderer compiles like any other custom shader. Drop a `.kmat` file on a model to use it.
4//!
5//! Every socket carries a `vec3` (a number is spread over all three channels), so any output can
6//! feed any input. The three texture slots of a material (albedo, normal, ORM) are bound for the
7//! "Texture" node.
8//!
9//! ```
10//! use kvas_engine::material_graph::{MaterialGraph, to_wgsl};
11//! let graph = MaterialGraph::default_graph();
12//! let wgsl = to_wgsl(&graph).unwrap();
13//! assert!(wgsl.contains("fn fs_main"));
14//! ```
15
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19/// One input socket of a node.
20#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
21pub struct Socket {
22    /// Node whose output feeds this socket (`None` = use `value`).
23    pub link: Option<u32>,
24    /// Constant used when nothing is connected (a number is stored in all three channels).
25    pub value: [f32; 3],
26    /// Explicit constant override, including zero. Older files used nonzero values as overrides.
27    #[serde(default)]
28    pub override_default: bool,
29    /// Which output of the linked node feeds this socket (0 = its main result).
30    #[serde(default, skip_serializing_if = "is_zero")]
31    pub output: u8,
32    /// How the linked value is converted, an index into [`crate::material_nodes_v4::CONVERSIONS`].
33    #[serde(default, skip_serializing_if = "is_zero")]
34    pub convert: u8,
35}
36
37fn is_zero(v: &u8) -> bool {
38    *v == 0
39}
40
41impl Socket {
42    /// An unconnected socket that uses the default of its spec.
43    pub fn empty() -> Socket {
44        Socket { link: None, value: [0.0; 3], override_default: false, output: 0, convert: 0 }
45    }
46}
47
48/// What a graph produces.
49#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
50#[serde(rename_all = "lowercase")]
51pub enum GraphMode {
52    /// A surface material (`.kmat`): colour, roughness, metal, glow, opacity, normal.
53    #[default]
54    Material,
55    /// A full object shader (`.kshader`): colour, opacity, glow, lighting amount and vertex offset.
56    Shader,
57    /// A camera effect (`.kfx`): gets the picture and returns a new one.
58    Effect,
59}
60
61impl GraphMode {
62    /// Mode implied by a file name.
63    pub fn from_path(path: &str) -> GraphMode {
64        if path.ends_with(".kshader") {
65            GraphMode::Shader
66        } else if path.ends_with(".kfx") {
67            GraphMode::Effect
68        } else {
69            GraphMode::Material
70        }
71    }
72
73    /// File extension (without the dot).
74    pub fn extension(self) -> &'static str {
75        match self {
76            GraphMode::Material => "kmat",
77            GraphMode::Shader => "kshader",
78            GraphMode::Effect => "kfx",
79        }
80    }
81
82    /// Node type of the graph's result node.
83    pub fn output_kind(self) -> &'static str {
84        match self {
85            GraphMode::Material => "Output",
86            GraphMode::Shader => "Shader output",
87            GraphMode::Effect => "Effect output",
88        }
89    }
90}
91
92/// Whether a node type can be used in a graph of this kind.
93pub fn available(kind: &str, mode: GraphMode) -> bool {
94    use crate::material_nodes::{EFFECT_ONLY, MESH_ONLY};
95    if is_output(kind) {
96        return kind == mode.output_kind();
97    }
98    match mode {
99        GraphMode::Effect => !MESH_ONLY.contains(&kind) && !crate::material_nodes_ext::MESH_ONLY.contains(&kind) && !crate::material_nodes_v4::MESH_ONLY.contains(&kind),
100        _ => !EFFECT_ONLY.contains(&kind),
101    }
102}
103
104/// True for the three result nodes.
105pub fn is_output(kind: &str) -> bool {
106    matches!(kind, "Output" | "Shader output" | "Effect output")
107}
108
109/// A node of the graph.
110#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
111pub struct MatNode {
112    /// Unique id within the graph.
113    pub id: u32,
114    /// Node type name, see [`SPECS`].
115    pub kind: String,
116    /// Position on the canvas.
117    pub pos: [f32; 2],
118    /// Input sockets, in the order of the spec.
119    pub inputs: Vec<Socket>,
120    /// Numeric parameters, in the order of the spec.
121    pub params: Vec<f32>,
122    /// Note text (Comment nodes).
123    #[serde(default)]
124    pub text: String,
125    /// Frame size on the canvas (Comment nodes).
126    #[serde(default)]
127    pub size: [f32; 2],
128    /// Show a small live preview of this node's result inside the node (editor only).
129    #[serde(default)]
130    pub preview: bool,
131}
132
133/// A whole material.
134#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
135#[serde(default)]
136pub struct MaterialGraph {
137    /// All nodes, including the single `Output` node.
138    pub nodes: Vec<MatNode>,
139    /// Images bound to the Texture node slots: albedo, normal map, ORM (occlusion/roughness/metallic).
140    pub textures: [String; 3],
141    /// Next free node id.
142    pub next_id: u32,
143    /// What the graph produces.
144    pub mode: GraphMode,
145}
146
147impl Default for MaterialGraph {
148    fn default() -> Self {
149        Self::default_graph()
150    }
151}
152
153/// Static description of a node type.
154pub struct NodeSpec {
155    /// Type name stored in files.
156    pub kind: &'static str,
157    /// Menu group.
158    pub category: &'static str,
159    /// Inputs: name and the WGSL expression used when unconnected (`vec3<f32>`).
160    pub inputs: &'static [(&'static str, &'static str)],
161    /// Parameters: name, default, min, max.
162    pub params: &'static [(&'static str, f32, f32, f32)],
163    /// WGSL expression with `{0}`.. for inputs and `{p0}`.. for parameters.
164    pub expr: &'static str,
165    /// The first three parameters are a colour (shown with a colour picker).
166    pub color: bool,
167}
168
169const COORD: &str = "vec3<f32>(in.uv, 0.0)";
170const ZERO: &str = "vec3<f32>(0.0)";
171
172/// Every node type.
173pub const SPECS: &[NodeSpec] = &[
174    NodeSpec { kind: "Output", category: "Output", inputs: &[("Base color", "vec3<f32>(0.8)"), ("Roughness", "vec3<f32>(0.6)"), ("Metallic", "vec3<f32>(0.0)"), ("Emission", "vec3<f32>(0.0)"), ("Alpha", "vec3<f32>(1.0)"), ("Occlusion", "vec3<f32>(1.0)"), ("Normal", "n"), ("Height", "vec3<f32>(0.0)")], params: &[], expr: "", color: false },
175    // inputs
176    NodeSpec { kind: "Color", category: "Input", inputs: &[], params: &[("r", 0.8, 0.0, 1.0), ("g", 0.8, 0.0, 1.0), ("b", 0.8, 0.0, 1.0)], expr: "vec3<f32>({p0}, {p1}, {p2})", color: true },
177    NodeSpec { kind: "Value", category: "Input", inputs: &[], params: &[("value", 0.5, -100.0, 100.0)], expr: "vec3<f32>({p0})", color: false },
178    NodeSpec { kind: "Texture", category: "Input", inputs: &[("UV", COORD)], params: &[("slot (0 albedo, 1 normal, 2 ORM)", 0.0, 0.0, 2.0), ("tiling", 1.0, 0.01, 100.0)], expr: "", color: false },
179    NodeSpec { kind: "UV", category: "Geometry", inputs: &[], params: &[], expr: "vec3<f32>(in.uv, 0.0)", color: false },
180    NodeSpec { kind: "World position", category: "Geometry", inputs: &[], params: &[], expr: "in.world_pos", color: false },
181    NodeSpec { kind: "Normal", category: "Geometry", inputs: &[], params: &[], expr: "n", color: false },
182    NodeSpec { kind: "Height", category: "Geometry", inputs: &[], params: &[], expr: "vec3<f32>(in.world_pos.y)", color: false },
183    NodeSpec { kind: "Time", category: "Input", inputs: &[], params: &[], expr: "vec3<f32>(globals.time.x)", color: false },
184    NodeSpec { kind: "Object colour", category: "Input", inputs: &[], params: &[], expr: "object.color.rgb", color: false },
185    NodeSpec { kind: "Fresnel", category: "Light", inputs: &[("Power", "vec3<f32>(3.0)")], params: &[], expr: "vec3<f32>(pow(1.0 - max(dot(n, view), 0.0), max({0}.x, 0.01)))", color: false },
186    // maths
187    NodeSpec { kind: "Add", category: "Math", inputs: &[("A", ZERO), ("B", ZERO)], params: &[], expr: "({0} + {1})", color: false },
188    NodeSpec { kind: "Subtract", category: "Math", inputs: &[("A", ZERO), ("B", ZERO)], params: &[], expr: "({0} - {1})", color: false },
189    NodeSpec { kind: "Multiply", category: "Math", inputs: &[("A", "vec3<f32>(1.0)"), ("B", "vec3<f32>(1.0)")], params: &[], expr: "({0} * {1})", color: false },
190    NodeSpec { kind: "Divide", category: "Math", inputs: &[("A", "vec3<f32>(1.0)"), ("B", "vec3<f32>(1.0)")], params: &[], expr: "({0} / select(vec3<f32>(0.0001), {1}, abs({1}) >= vec3<f32>(0.0001)))", color: false },
191    NodeSpec { kind: "Mix", category: "Math", inputs: &[("A", ZERO), ("B", "vec3<f32>(1.0)"), ("Factor", "vec3<f32>(0.5)")], params: &[], expr: "mix({0}, {1}, clamp({2}.x, 0.0, 1.0))", color: false },
192    NodeSpec { kind: "Power", category: "Math", inputs: &[("Value", "vec3<f32>(0.5)"), ("Exponent", "vec3<f32>(2.0)")], params: &[], expr: "pow(max({0}, vec3<f32>(0.0)), {1})", color: false },
193    NodeSpec { kind: "Clamp", category: "Math", inputs: &[("Value", ZERO)], params: &[("min", 0.0, -100.0, 100.0), ("max", 1.0, -100.0, 100.0)], expr: "clamp({0}, vec3<f32>({p0}), vec3<f32>({p1}))", color: false },
194    NodeSpec { kind: "One minus", category: "Math", inputs: &[("Value", ZERO)], params: &[], expr: "(vec3<f32>(1.0) - {0})", color: false },
195    NodeSpec { kind: "Sine", category: "Math", inputs: &[("Value", ZERO)], params: &[("frequency", 1.0, 0.0, 100.0)], expr: "(sin({0} * {p0}) * 0.5 + vec3<f32>(0.5))", color: false },
196    NodeSpec { kind: "Smoothstep", category: "Math", inputs: &[("Value", "vec3<f32>(0.5)")], params: &[("edge 0", 0.3, -10.0, 10.0), ("edge 1", 0.7, -10.0, 10.0)], expr: "smoothstep(vec3<f32>({p0}), vec3<f32>({p1}), {0})", color: false },
197    NodeSpec { kind: "Abs", category: "Math", inputs: &[("Value", ZERO)], params: &[], expr: "abs({0})", color: false },
198    NodeSpec { kind: "Min", category: "Math", inputs: &[("A", ZERO), ("B", ZERO)], params: &[], expr: "min({0}, {1})", color: false },
199    NodeSpec { kind: "Max", category: "Math", inputs: &[("A", ZERO), ("B", ZERO)], params: &[], expr: "max({0}, {1})", color: false },
200    // colour
201    NodeSpec { kind: "Hue shift", category: "Colour", inputs: &[("Colour", "vec3<f32>(0.8)")], params: &[("turns", 0.0, -1.0, 1.0)], expr: "lib_hue_shift({0}, {p0})", color: false },
202    NodeSpec { kind: "Saturation", category: "Colour", inputs: &[("Colour", "vec3<f32>(0.8)")], params: &[("amount", 1.0, 0.0, 3.0)], expr: "lib_saturation({0}, {p0})", color: false },
203    NodeSpec { kind: "Contrast", category: "Colour", inputs: &[("Colour", "vec3<f32>(0.5)")], params: &[("amount", 1.0, 0.0, 3.0)], expr: "lib_contrast({0}, {p0})", color: false },
204    NodeSpec { kind: "Brightness", category: "Colour", inputs: &[("Colour", "vec3<f32>(0.5)")], params: &[("scale", 1.0, 0.0, 10.0)], expr: "({0} * {p0})", color: false },
205    NodeSpec { kind: "Gradient", category: "Colour", inputs: &[("Position", "vec3<f32>(0.5)"), ("From", "vec3<f32>(0.05, 0.1, 0.3)"), ("To", "vec3<f32>(0.9, 0.6, 0.2)")], params: &[], expr: "mix({1}, {2}, clamp({0}.x, 0.0, 1.0))", color: false },
206    NodeSpec { kind: "To grey", category: "Colour", inputs: &[("Colour", "vec3<f32>(0.5)")], params: &[], expr: "vec3<f32>(lib_luma({0}))", color: false },
207    // procedural
208    NodeSpec { kind: "Noise", category: "Noise", inputs: &[("Coords", "in.world_pos")], params: &[("scale", 2.0, 0.01, 100.0)], expr: "vec3<f32>(lib_fbm({0}.xz * {p0}))", color: false },
209    NodeSpec { kind: "Checker", category: "Procedural", inputs: &[("Coords", COORD)], params: &[("scale", 8.0, 1.0, 200.0)], expr: "vec3<f32>(abs((floor({0}.x * {p0}) + floor({0}.y * {p0})) % 2.0))", color: false },
210    NodeSpec { kind: "Stripes", category: "Procedural", inputs: &[("Coords", COORD)], params: &[("scale", 10.0, 1.0, 200.0), ("sharpness", 0.5, 0.0, 1.0)], expr: "vec3<f32>(smoothstep(0.5 - {p1} * 0.5, 0.5 + {p1} * 0.5 + 0.001, abs(fract({0}.x * {p0}) - 0.5) * 2.0))", color: false },
211    NodeSpec { kind: "Voronoi", category: "Noise", inputs: &[("Coords", "in.world_pos")], params: &[("scale", 3.0, 0.01, 100.0)], expr: "vec3<f32>(lib_voronoi({0}.xz * {p0}).x)", color: false },
212    NodeSpec { kind: "Cell colour", category: "Noise", inputs: &[("Coords", "in.world_pos")], params: &[("scale", 3.0, 0.01, 100.0)], expr: "vec3<f32>(lib_voronoi({0}.xz * {p0}).y, lib_voronoi({0}.xz * {p0} + 11.0).y, lib_voronoi({0}.xz * {p0} + 23.0).y)", color: false },
213    NodeSpec { kind: "Bricks", category: "Procedural", inputs: &[("Coords", COORD)], params: &[("scale", 6.0, 1.0, 100.0), ("mortar", 0.06, 0.0, 0.3)], expr: "vec3<f32>(smoothstep({p1}, {p1} + 0.02, min(min(fract({0}.x * {p0} + 0.5 * (floor({0}.y * {p0} * 2.0) % 2.0)), 1.0 - fract({0}.x * {p0} + 0.5 * (floor({0}.y * {p0} * 2.0) % 2.0))), min(fract({0}.y * {p0} * 2.0), 1.0 - fract({0}.y * {p0} * 2.0)))))", color: false },
214    NodeSpec { kind: "Slope mask", category: "Geometry", inputs: &[], params: &[("from", 0.5, 0.0, 1.0), ("to", 0.9, 0.0, 1.0)], expr: "vec3<f32>(smoothstep({p0}, {p1} + 0.001, n.y))", color: false },
215    NodeSpec { kind: "Sun light", category: "Light", inputs: &[], params: &[], expr: "vec3<f32>(max(dot(n, normalize(globals.sun_dir.xyz)), 0.0))", color: false },
216    NodeSpec { kind: "Camera distance", category: "Geometry", inputs: &[], params: &[("range", 50.0, 1.0, 1000.0)], expr: "vec3<f32>(clamp(distance(globals.camera_pos.xyz, in.world_pos) / {p0}, 0.0, 1.0))", color: false },
217    NodeSpec { kind: "Fract", category: "Math", inputs: &[("Value", "vec3<f32>(0.5)")], params: &[], expr: "fract({0})", color: false },
218    NodeSpec { kind: "Step", category: "Math", inputs: &[("Value", "vec3<f32>(0.5)")], params: &[("edge", 0.5, 0.0, 1.0)], expr: "step(vec3<f32>({p0}), {0})", color: false },
219    NodeSpec { kind: "Remap", category: "Math", inputs: &[("Value", "vec3<f32>(0.5)")], params: &[("in min", 0.0, -10.0, 10.0), ("in max", 1.0, -10.0, 10.0), ("out min", 0.0, -10.0, 10.0), ("out max", 1.0, -10.0, 10.0)], expr: "(({0}) - vec3<f32>({p0})) / vec3<f32>({p1} - {p0} + 0.00001) * vec3<f32>({p3} - {p2}) + vec3<f32>({p2})", color: false },
220    NodeSpec { kind: "Dots", category: "Procedural", inputs: &[("Coords", COORD)], params: &[("scale", 10.0, 1.0, 200.0), ("size", 0.3, 0.01, 0.5)], expr: "vec3<f32>(1.0 - smoothstep({p1}, {p1} + 0.05, length(fract({0}.xy * {p0}) - vec2<f32>(0.5))))", color: false },
221];
222
223/// Looks a node type up by name.
224pub fn spec(kind: &str) -> Option<&'static NodeSpec> {
225    all_specs().find(|s| s.kind == kind)
226}
227
228/// Every node type of both lists.
229pub fn all_specs() -> impl Iterator<Item = &'static NodeSpec> {
230    SPECS.iter().chain(crate::material_nodes::MORE_SPECS.iter()).chain(crate::material_nodes_ext::EXT_SPECS.iter()).chain(crate::material_nodes_v4::V4_SPECS.iter())
231}
232
233impl MaterialGraph {
234    /// A new graph with only an Output node (grey, rough).
235    pub fn default_graph() -> Self {
236        Self::default_graph_for(GraphMode::Material)
237    }
238
239    /// A starter graph for the given kind: the result node plus one connected node.
240    pub fn default_graph_for(mode: GraphMode) -> Self {
241        let mut g = MaterialGraph { nodes: Vec::new(), textures: Default::default(), next_id: 1, mode };
242        g.add_node(mode.output_kind(), [420.0, 80.0]);
243        let first = match mode {
244            GraphMode::Effect => g.add_node("Screen color", [60.0, 60.0]),
245            _ => g.add_node("Color", [60.0, 60.0]),
246        };
247        g.connect(first, 1, 0);
248        g
249    }
250
251    /// Adds a node of a known type with its default values.
252    pub fn add_node(&mut self, kind: &str, pos: [f32; 2]) -> u32 {
253        let spec = spec(kind).expect("known node type");
254        let id = self.next_id;
255        self.next_id += 1;
256        self.nodes.push(MatNode {
257            id,
258            kind: kind.to_string(),
259            pos,
260            inputs: spec.inputs.iter().map(|_| Socket::empty()).collect(),
261            params: spec.params.iter().map(|p| p.1).collect(),
262            text: if kind == "Comment" { "Comment".into() } else { String::new() },
263            size: if kind == "Comment" { [260.0, 160.0] } else { [0.0, 0.0] },
264            preview: matches!(spec.category, "Noise" | "Procedural"),
265        });
266        id
267    }
268
269    /// Connects `from`'s main output to input `input` of node `to`. Returns false for self-links or cycles.
270    pub fn connect(&mut self, from: u32, to: u32, input: usize) -> bool {
271        self.connect_output(from, 0, to, input, 0)
272    }
273
274    /// Connects output `output` of `from` to input `input` of `to`, converting with `convert`
275    /// (see [`crate::material_nodes_v4::CONVERSIONS`]).
276    pub fn connect_output(&mut self, from: u32, output: u8, to: u32, input: usize, convert: u8) -> bool {
277        if !self.nodes.iter().any(|n| n.id == from && !is_output(&n.kind) && n.kind != "Comment") {
278            return false;
279        }
280        if from == to || self.reaches(from, to) {
281            return false;
282        }
283        match self.nodes.iter_mut().find(|n| n.id == to).and_then(|n| n.inputs.get_mut(input)) {
284            Some(socket) => {
285                socket.link = Some(from);
286                socket.output = output;
287                socket.convert = convert;
288                true
289            }
290            None => false,
291        }
292    }
293
294    /// Whether `target` is reachable going upstream from `start` (a link would close a loop).
295    fn reaches(&self, start: u32, target: u32) -> bool {
296        let mut stack = vec![start];
297        let mut seen = std::collections::HashSet::new();
298        while let Some(id) = stack.pop() {
299            if id == target {
300                return true;
301            }
302            if !seen.insert(id) {
303                continue;
304            }
305            if let Some(n) = self.nodes.iter().find(|n| n.id == id) {
306                stack.extend(n.inputs.iter().filter_map(|s| s.link));
307            }
308        }
309        false
310    }
311
312    /// Removes a node (the Output node stays) and every link that used it.
313    pub fn remove_node(&mut self, id: u32) {
314        if self.nodes.iter().any(|n| n.id == id && is_output(&n.kind)) {
315            return;
316        }
317        self.nodes.retain(|n| n.id != id);
318        for n in &mut self.nodes {
319            for s in &mut n.inputs {
320                if s.link == Some(id) {
321                    s.link = None;
322                }
323            }
324        }
325    }
326
327    /// Parses a `.kmat` file.
328    pub fn from_json(text: &str) -> Result<Self, String> {
329        let mut g: MaterialGraph = serde_json::from_str(text).map_err(|e| e.to_string())?;
330        if g.nodes.iter().any(|n| n.id >= u32::MAX - 1) { return Err("node id space is exhausted".into()); }
331        // Files written by older or hand-edited versions may have too few sockets or parameters.
332        for n in &mut g.nodes {
333            if let Some(spec) = spec(&n.kind) {
334                n.inputs.resize(spec.inputs.len(), Socket::empty());
335                while n.params.len() < spec.params.len() {
336                    n.params.push(spec.params[n.params.len()].1);
337                }
338            }
339        }
340        for n in &mut g.nodes {
341            if n.kind == "Comment" && n.size == [0.0, 0.0] {
342                n.size = [260.0, 160.0];
343            }
344        }
345        g.next_id = g.next_id.max(g.nodes.iter().map(|n| n.id + 1).max().unwrap_or(1));
346        Ok(g)
347    }
348
349    /// Serialises to pretty JSON.
350    pub fn to_json(&self) -> String {
351        serde_json::to_string_pretty(self).unwrap_or_default()
352    }
353
354    /// Checks graph identity, sockets and links before generating executable shader code.
355    /// Unconnected nodes are checked too, so saving an invalid graph cannot hide broken links.
356    pub fn validate(&self) -> Result<(), String> {
357        let mut ids = std::collections::HashSet::new();
358        let mut outputs = 0;
359        for node in &self.nodes {
360            if !ids.insert(node.id) { return Err(format!("duplicate node id {}", node.id)); }
361            let s = spec(&node.kind).ok_or_else(|| format!("unknown node type '{}'", node.kind))?;
362            if !available(&node.kind, self.mode) { return Err(format!("node '{}' is unavailable in {:?} graphs", node.kind, self.mode)); }
363            outputs += usize::from(is_output(&node.kind));
364            if node.inputs.len() != s.inputs.len() || node.params.len() < s.params.len() {
365                return Err(format!("node {} ({}) has invalid sockets or parameters", node.id, node.kind));
366            }
367            if node.params.iter().chain(node.inputs.iter().flat_map(|s| &s.value)).any(|v| !v.is_finite()) {
368                return Err(format!("node {} contains a non-finite value", node.id));
369            }
370        }
371        if outputs != 1 { return Err(format!("expected one '{}' node, found {outputs}", self.mode.output_kind())); }
372        let nodes: HashMap<_, _> = self.nodes.iter().map(|n| (n.id, n)).collect();
373        // Iterative DFS keeps malformed or hand-edited graphs from overflowing the stack.
374        let mut state = HashMap::new();
375        for node in &self.nodes {
376            let mut stack = vec![(node.id, false, 0usize)];
377            while let Some((id, leave, depth)) = stack.pop() {
378                if leave { state.insert(id, 2); continue; }
379                match state.get(&id) {
380                    Some(1) => return Err(format!("the graph has a loop at node {id}")),
381                    Some(2) => continue,
382                    _ => {}
383                }
384                if depth > 256 { return Err("graph exceeds the 256-node dependency depth limit".into()); }
385                let n = nodes.get(&id).ok_or_else(|| format!("a link points to missing node {id}"))?;
386                state.insert(id, 1);
387                stack.push((id, true, depth));
388                for link in n.inputs.iter().filter_map(|s| s.link) {
389                    let source = nodes.get(&link).ok_or_else(|| format!("node {id} links to missing node {link}"))?;
390                    if is_output(&source.kind) || source.kind == "Comment" { return Err(format!("node {link} has no output socket")); }
391                    stack.push((link, false, depth + 1));
392                }
393            }
394        }
395        Ok(())
396    }
397}
398
399fn f(v: f32) -> String {
400    if v.is_finite() { format!("{v:?}") } else { "0.0".into() }
401}
402
403struct Gen<'a> {
404    graph: &'a MaterialGraph,
405    lines: Vec<String>,
406    done: HashMap<u32, String>,
407    visiting: Vec<u32>,
408    /// Variables of the extra outputs already generated, keyed by `id | output << 32`.
409    extra: HashMap<u64, String>,
410    /// The (named) input values of every generated node, for its extra outputs.
411    inputs_of: HashMap<u32, Vec<String>>,
412    /// Generating the vertex stage (textures are not available there).
413    vertex: bool,
414}
415
416/// Adapts a surface expression to the camera-effect context (where `in.uv`, `time` ... differ).
417fn for_effect(expr: &str) -> String {
418    expr.replace("in.world_pos", "vec3<f32>(uv, 0.0)").replace("in.uv", "uv").replace("globals.time.x", "time")
419}
420
421impl Gen<'_> {
422    fn ctx(&self, expr: String) -> String {
423        if self.graph.mode == GraphMode::Effect {
424            for_effect(&expr)
425        } else {
426            expr
427        }
428    }
429
430    fn input(&mut self, node: &MatNode, index: usize, spec: &NodeSpec) -> Result<String, String> {
431        let socket = node.inputs.get(index);
432        match socket.and_then(|s| s.link) {
433            Some(id) => {
434                let socket = socket.unwrap();
435                let value = self.eval_output(id, socket.output as usize)?;
436                match crate::material_nodes_v4::CONVERSIONS.get(socket.convert as usize) {
437                    Some((_, conv)) if socket.convert > 0 => Ok(conv.replace("{v}", &value)),
438                    _ => Ok(value),
439                }
440            }
441            None => {
442                // A socket the user typed a value into overrides the default expression.
443                match socket.map(|s| s.value) {
444                    Some(v) if socket.is_some_and(|s| s.override_default) || v != [0.0; 3] => Ok(format!("vec3<f32>({}, {}, {})", f(v[0]), f(v[1]), f(v[2]))),
445                    _ => Ok(self.ctx(spec.inputs.get(index).map_or(ZERO, |i| i.1).to_string())),
446                }
447            }
448        }
449    }
450
451    /// The value of output `output` of node `id` (0 is the main result).
452    fn eval_output(&mut self, id: u32, output: usize) -> Result<String, String> {
453        let main = self.eval(id)?;
454        let node = self.graph.nodes.iter().find(|n| n.id == id).ok_or("a link points to a missing node")?;
455        // Output 0 is the main result, unless the node's first named output reshapes it
456        // (Voronoi "Factor", Brick "Colour"...).
457        let Some((_, template)) = crate::material_nodes_v4::outputs(&node.kind).get(output) else { return Ok(main) };
458        if *template == "{v}" {
459            return Ok(main);
460        }
461        let key = id as u64 | ((output as u64) << 32);
462        if let Some(v) = self.extra.get(&key) {
463            return Ok(v.clone());
464        }
465        let mut e = template.replace("{v}", &main);
466        let inputs = self.inputs_of.get(&id).cloned().unwrap_or_default();
467        for (i, input) in inputs.iter().enumerate() {
468            e = e.replace(&format!("{{{i}}}"), &format!("({input})"));
469        }
470        for (i, p) in node.params.iter().enumerate() {
471            e = e.replace(&format!("{{p{i}}}"), &f(*p));
472        }
473        let e = self.ctx(e);
474        let name = format!("v{id}_{output}");
475        self.lines.push(format!("    let {name} = {e};"));
476        self.extra.insert(key, name.clone());
477        Ok(name)
478    }
479
480    fn eval(&mut self, id: u32) -> Result<String, String> {
481        if let Some(v) = self.done.get(&id) {
482            return Ok(v.clone());
483        }
484        if self.visiting.contains(&id) {
485            return Err("the graph has a loop".into());
486        }
487        let node = self.graph.nodes.iter().find(|n| n.id == id).ok_or("a link points to a missing node")?.clone();
488        let spec = spec(&node.kind).ok_or_else(|| format!("unknown node type '{}'", node.kind))?;
489        if !available(&node.kind, self.graph.mode) {
490            return Err(format!("the node '{}' cannot be used in this kind of graph", node.kind));
491        }
492        if self.vertex && (matches!(node.kind.as_str(), "Texture" | "Normal map" | "Bump from height" | "Shadow" | "Lighting" | "Sky reflection" | "Screen position") || crate::material_nodes_ext::FRAGMENT_ONLY.contains(&node.kind.as_str()) || crate::material_nodes_v4::FRAGMENT_ONLY.contains(&node.kind.as_str())) {
493            return Err(format!("'{}' requires fragment-stage sampling or derivatives and cannot feed Vertex offset", node.kind));
494        }
495        self.visiting.push(id);
496        let mut inputs = Vec::new();
497        for i in 0..spec.inputs.len() {
498            inputs.push(self.input(&node, i, spec)?);
499        }
500        self.visiting.pop();
501        // Inputs get short names, so extra outputs that reuse them do not repeat long expressions.
502        let mut short = Vec::new();
503        for (i, input) in inputs.iter().enumerate() {
504            if input.starts_with('v') && input[1..].chars().all(|c| c.is_ascii_digit() || c == '_') {
505                short.push(input.clone());
506            } else {
507                let name = format!("i{id}_{i}");
508                self.lines.push(format!("    let {name} = {input};"));
509                short.push(name);
510            }
511        }
512        let inputs = short;
513        self.inputs_of.insert(id, inputs.clone());
514        let expr = if node.kind == "Texture" {
515            let slot = node.params.first().copied().unwrap_or(0.0).round() as i32;
516            let tex = match slot {
517                1 => "normal_tex",
518                2 => "orm_tex",
519                _ => "albedo_tex",
520            };
521            let tiling = f(node.params.get(1).copied().unwrap_or(1.0));
522            format!("textureSample({tex}, albedo_smp, ({}).xy * {tiling}).rgb", inputs[0])
523        } else if node.kind == "Inspector number" {
524            let slot = node.params.first().copied().unwrap_or(0.0).round().clamp(0.0, 7.0) as usize;
525            if self.graph.mode == GraphMode::Effect && slot > 3 { return Err("camera effect Inspector number has only slots 0..3 (params.xyzw)".into()); }
526            let default = f(node.params.get(1).copied().unwrap_or(1.0));
527            let comp = ["x", "y", "z", "w"][slot % 4];
528            let source = if self.graph.mode == GraphMode::Effect { format!("params.{comp}") } else { format!("object.user{}.{comp}", slot / 4) };
529            if node.params.get(2).copied().unwrap_or(1.0) >= 0.5 { format!("vec3<f32>(select({source}, {default}, {source} == 0.0))") }
530            else { format!("vec3<f32>({source})") }
531        } else {
532            let mut e = spec.expr.to_string();
533            if e.contains("{tex}") {
534                let slot = node.params.first().copied().unwrap_or(0.0).round() as i32;
535                e = e.replace("{tex}", match slot { 1 => "normal_tex", 2 => "orm_tex", _ => "albedo_tex" });
536            }
537            for (i, input) in inputs.iter().enumerate() {
538                e = e.replace(&format!("{{{i}}}"), &format!("({input})"));
539            }
540            for (i, p) in node.params.iter().enumerate() {
541                e = e.replace(&format!("{{p{i}}}"), &f(*p));
542            }
543            self.ctx(e)
544        };
545        let name = format!("v{id}");
546        self.lines.push(format!("    let {name} = {expr};"));
547        self.done.insert(id, name.clone());
548        Ok(name)
549    }
550}
551
552fn new_gen(graph: &MaterialGraph, vertex: bool) -> Gen<'_> {
553    Gen { graph, lines: Vec::new(), done: HashMap::new(), visiting: Vec::new(), extra: HashMap::new(), inputs_of: HashMap::new(), vertex }
554}
555
556/// Generates the shader of a graph (the prelude and the library functions are added by the renderer):
557/// a mesh shader for materials and shaders, an `effect` function for camera effects.
558pub fn to_wgsl(graph: &MaterialGraph) -> Result<String, String> {
559    graph.validate()?;
560    let kind = graph.mode.output_kind();
561    let output = graph.nodes.iter().find(|n| n.kind == kind).ok_or_else(|| format!("the graph has no '{kind}' node"))?.clone();
562    let out_spec = spec(kind).unwrap();
563    let mut gen = new_gen(graph, false);
564    let mut results = Vec::new();
565    // The vertex offset is evaluated in its own stage.
566    let skip = if graph.mode == GraphMode::Shader { Some(5) } else { None };
567    for i in 0..out_spec.inputs.len() {
568        if Some(i) == skip {
569            results.push(String::new());
570            continue;
571        }
572        results.push(gen.input(&output, i, out_spec)?);
573    }
574    let body = gen.lines.join("\n");
575    match graph.mode {
576        GraphMode::Material => Ok(format!(
577            "// Generated from a .kmat material graph.
578@vertex
579fn vs_main(v: VertexIn) -> VsOut {{ return standard_vertex(v); }}
580
581@fragment
582fn fs_main(in: VsOut) -> @location(0) vec4<f32> {{
583    let n0 = normalize(in.normal);
584    let n = n0;
585    let view = normalize(globals.camera_pos.xyz - in.world_pos);
586{body}
587    let albedo = max({0}, vec3<f32>(0.0));
588    let rough = clamp(({1}).x, 0.04, 1.0);
589    let metal = clamp(({2}).x, 0.0, 1.0);
590    let emission = max({3}, vec3<f32>(0.0));
591    let alpha = clamp(({4}).x, 0.0, 1.0);
592    let occlusion = clamp(({5}).x, 0.0, 1.0);
593    let bumped = lib_bump(({7}).x, in.world_pos, n, 1.0);
594    let nrm = normalize({6} + (bumped - n) + vec3<f32>(0.00001));
595    if (alpha < 0.003) {{ discard; }}
596    let lit = lighting_pbr(in.world_pos, nrm, albedo, rough, metal, occlusion) + emission;
597    return vec4<f32>(to_display_at(apply_fog(lit, in.world_pos), in.clip), alpha * object.color.a);
598}}
599",
600            results[0], results[1], results[2], results[3], results[4], results[5], results[6], results[7]
601        )),
602        GraphMode::Shader => {
603            // Vertex stage: runs the nodes feeding "Vertex offset" with the standard vertex output as `in`.
604            let mut vgen = new_gen(graph, true);
605            let offset = vgen.input(&output, 5, out_spec)?;
606            let vbody = vgen.lines.join("\n");
607            Ok(format!(
608                "// Generated from a .kshader graph.
609@vertex
610fn vs_main(v: VertexIn) -> VsOut {{
611    var in = standard_vertex(v);
612    let n = in.normal;
613    let view = normalize(globals.camera_pos.xyz - in.world_pos);
614{vbody}
615    in.world_pos = in.world_pos + {offset};
616    in.clip = globals.view_proj * vec4<f32>(in.world_pos, 1.0);
617    return in;
618}}
619
620@fragment
621fn fs_main(in: VsOut) -> @location(0) vec4<f32> {{
622    let n = normalize(in.normal);
623    let view = normalize(globals.camera_pos.xyz - in.world_pos);
624{body}
625    let base = max({0}, vec3<f32>(0.0));
626    let alpha = clamp(({1}).x, 0.0, 1.0);
627    let emission = max({2}, vec3<f32>(0.0));
628    let amount = clamp(({3}).x, 0.0, 1.0);
629    let rough = clamp(({4}).x, 0.04, 1.0);
630    let lit = mix(base, lighting(in.world_pos, n, base, rough), amount) + emission;
631    if (alpha < 0.003) {{ discard; }}
632    return vec4<f32>(to_display_at(apply_fog(lit, in.world_pos), in.clip), alpha * object.color.a);
633}}
634",
635                results[0], results[1], results[2], results[3], results[4]
636            ))
637        }
638        GraphMode::Effect => Ok(format!(
639            "// Generated from a .kfx camera effect graph.
640fn effect(uv: vec2<f32>, color: vec3<f32>, params: vec4<f32>, time: f32) -> vec3<f32> {{
641{body}
642    return max({0}, vec3<f32>(0.0));
643}}
644",
645            results[0]
646        )),
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn explicit_zero_and_legacy_defaults_round_trip() {
656        let mut g = MaterialGraph::default_graph();
657        g.nodes[0].inputs[1].override_default = true;
658        let src = to_wgsl(&MaterialGraph::from_json(&g.to_json()).unwrap()).unwrap();
659        assert!(src.contains("let rough = clamp((vec3<f32>(0.0, 0.0, 0.0)).x"));
660        g.nodes[0].inputs[1].override_default = false;
661        assert!(to_wgsl(&g).unwrap().contains("clamp((vec3<f32>(0.6)).x"));
662    }
663
664    #[test]
665    fn malformed_links_and_duplicate_ids_report_errors() {
666        let mut g = MaterialGraph::default_graph();
667        assert!(!g.connect(999, 1, 0));
668        assert!(!g.connect(1, 2, 0));
669        g.nodes[0].inputs[0].link = Some(999);
670        assert!(to_wgsl(&g).unwrap_err().contains("missing node"));
671        g.nodes[0].inputs[0].link = Some(2);
672        g.nodes[1].id = 1;
673        assert!(to_wgsl(&g).unwrap_err().contains("duplicate node id"));
674    }
675
676    #[test]
677    fn hand_edited_cycles_and_vertex_derivatives_are_rejected() {
678        let mut g = MaterialGraph::default_graph_for(GraphMode::Shader);
679        let id = g.add_node("Bump from height", [0.0; 2]);
680        assert!(g.connect(id, 1, 5));
681        assert!(to_wgsl(&g).unwrap_err().contains("fragment-stage"));
682        let a = g.add_node("Add", [0.0; 2]);
683        let b = g.add_node("Add", [0.0; 2]);
684        g.nodes.iter_mut().find(|n| n.id == a).unwrap().inputs[0].link = Some(b);
685        g.nodes.iter_mut().find(|n| n.id == b).unwrap().inputs[0].link = Some(a);
686        assert!(g.validate().unwrap_err().contains("loop"));
687    }
688
689    #[test]
690    fn graph_with_every_node_generates() {
691        for mode in [GraphMode::Material, GraphMode::Shader, GraphMode::Effect] {
692            for spec in all_specs().filter(|s| !is_output(s.kind) && available(s.kind, mode)) {
693                let mut g = MaterialGraph::default_graph_for(mode);
694                let id = g.add_node(spec.kind, [0.0, 0.0]);
695                g.connect(id, 1, 0);
696                let wgsl = to_wgsl(&g).unwrap_or_else(|e| panic!("{:?} {}: {e}", mode, spec.kind));
697                assert!(wgsl.contains("fn "), "{}", spec.kind);
698            }
699        }
700    }
701
702    #[test]
703    fn loops_and_json_round_trip() {
704        let mut g = MaterialGraph::default_graph();
705        let a = g.add_node("Add", [0.0, 0.0]);
706        let b = g.add_node("Multiply", [0.0, 0.0]);
707        assert!(g.connect(a, b, 0));
708        assert!(!g.connect(b, a, 0), "a loop must be refused");
709        let back = MaterialGraph::from_json(&g.to_json()).unwrap();
710        assert_eq!(back, g);
711    }
712}