1use std::collections::HashMap;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum MaterialSocket {
18 Color, Float, Vec2, Vec3, Mask, }
24
25#[derive(Debug, Clone)]
27pub enum MaterialOp {
28 InputColor,
31
32 ConstantColor {
36 r: f32,
37 g: f32,
38 b: f32,
39 a: f32,
40 },
41
42 SampleTexture {
45 tex_index: u32,
46 },
47
48 PremultipliedBlend,
52
53 SDFRoundRect,
57
58 SDFEllipse,
61
62 LinearGradient {
66 start: [f32; 4],
67 end: [f32; 4],
68 },
69
70 RadialGradient {
74 start: [f32; 4],
75 end: [f32; 4],
76 },
77
78 NeonGlow {
82 radius: f32,
83 intensity: f32,
84 },
85
86 GlassBlur,
90
91 LayerBlend {
95 mode: BlendMode,
96 },
97
98 PBRLighting,
102
103 DropShadow,
107
108 NineSlice,
112
113 Heatmap,
117
118 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#[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
154pub type MatNodeId = u32;
156
157#[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 pub fn validate(&self) -> Result<(), MaterialError> {
201 self.validate_with_config(&MaterialValidationConfig::default())
202 }
203
204 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 if self.edges.len() > config.max_edges {
222 return Err(MaterialError::TooManyEdges(
223 self.edges.len(),
224 config.max_edges,
225 ));
226 }
227 let mut visited = vec![false; self.nodes.len()];
229 let mut in_stack = vec![false; self.nodes.len()];
230
231 for &(id, _) in &self.nodes {
232 if !visited[id as usize] {
233 self.dfs_check(id, &mut visited, &mut in_stack)?;
234 }
235 }
236
237 if let Some(output_id) = self.output {
241 let mut reachable = vec![false; self.nodes.len()];
242 self.dfs_reachable(output_id, &mut reachable);
243 for &(id, _) in &self.nodes {
244 if !reachable[id as usize] {
245 return Err(MaterialError::UnreachableNode(id));
246 }
247 }
248 }
249 Ok(())
250 }
251
252 fn dfs_check(
253 &self,
254 node: MatNodeId,
255 visited: &mut [bool],
256 in_stack: &mut [bool],
257 ) -> Result<(), MaterialError> {
258 let idx = node as usize;
259 if in_stack[idx] {
260 return Err(MaterialError::Cycle);
261 }
262 if visited[idx] {
263 return Ok(());
264 }
265 visited[idx] = true;
266 in_stack[idx] = true;
267
268 for edge in &self.edges {
270 if edge.to_node == node {
271 self.dfs_check(edge.from_node, visited, in_stack)?;
272 }
273 }
274
275 in_stack[idx] = false;
276 Ok(())
277 }
278
279 fn dfs_reachable(&self, node: MatNodeId, reachable: &mut [bool]) {
283 let idx = node as usize;
284 if reachable[idx] {
285 return;
286 }
287 reachable[idx] = true;
288 for edge in &self.edges {
290 if edge.to_node == node {
291 self.dfs_reachable(edge.from_node, reachable);
292 }
293 }
294 }
295}
296
297impl Default for MaterialGraph {
298 fn default() -> Self {
299 Self::new()
300 }
301}
302
303#[derive(Debug)]
304pub enum MaterialError {
305 NoOutput,
306 Cycle,
307 DisconnectedInput {
308 node: MatNodeId,
309 socket: MaterialSocket,
310 },
311 TypeMismatch {
312 from: MaterialSocket,
313 to: MaterialSocket,
314 },
315 CompileError(String),
316 TooManyNodes(usize, usize),
317 UnsupportedNodeType(String),
318 TooManyEdges(usize, usize),
320 UnreachableNode(MatNodeId),
322}
323
324pub struct MaterialValidationConfig {
325 pub max_nodes: usize,
326 pub max_edges: usize,
333}
334
335impl Default for MaterialValidationConfig {
336 fn default() -> Self {
337 Self { max_nodes: 1024, max_edges: 4096 }
342 }
343}
344
345impl std::error::Error for MaterialError {}
346
347impl std::fmt::Display for MaterialError {
348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349 match self {
350 Self::NoOutput => write!(f, "material graph has no output node"),
351 Self::Cycle => write!(f, "material graph contains a cycle"),
352 Self::DisconnectedInput { node, socket } => {
353 write!(f, "node {:?} missing input {:?}", node, socket)
354 }
355 Self::TypeMismatch { from, to } => {
356 write!(f, "type mismatch: {:?} -> {:?}", from, to)
357 }
358 Self::CompileError(msg) => write!(f, "WGSL compilation error: {}", msg),
359 Self::TooManyNodes(count, max) => write!(f, "too many nodes: {} (max {})", count, max),
360 Self::UnsupportedNodeType(kind) => write!(f, "unsupported node type: {}", kind),
361 Self::TooManyEdges(count, max) => write!(f, "too many edges: {} (max {})", count, max),
362 Self::UnreachableNode(id) => write!(f, "unreachable node: {:?}", id),
363 }
364 }
365}
366
367#[derive(Debug, Clone)]
369pub struct CompiledMaterial {
370 pub wgsl_fn: String,
372 pub fn_name: String,
374}
375
376impl CompiledMaterial {
377 pub fn hash_code(&self) -> u64 {
378 use std::hash::{Hash, Hasher};
379 let mut hasher = std::collections::hash_map::DefaultHasher::new();
380 self.wgsl_fn.hash(&mut hasher);
381 hasher.finish()
382 }
383}
384
385pub struct MaterialCompiler;
387
388impl MaterialCompiler {
389 pub fn compile(graph: &MaterialGraph) -> Result<CompiledMaterial, MaterialError> {
400 graph.validate()?;
401
402 let order = Self::topo_sort(graph)?;
404
405 let mut lines: Vec<String> = Vec::new();
407 let mut var_names: HashMap<(MatNodeId, MaterialSocket), String> = HashMap::new();
408 let mut next_var = 0;
409
410 let mut mk_var = |prefix: &str| -> String {
411 let v = format!("{}_{}", prefix, next_var);
412 next_var += 1;
413 v
414 };
415
416 for &node_id in &order {
417 let (_, op) = &graph.nodes[node_id as usize];
418 let result_var = mk_var("v");
419
420 let expr = match op {
421 MaterialOp::InputColor => {
422 "col".to_string()
423 }
424 MaterialOp::ConstantColor { r, g, b, a } => {
425 format!("vec4<f32>({:.6}, {:.6}, {:.6}, {:.6})", r, g, b, a)
426 }
427 MaterialOp::SampleTexture { tex_index } => {
428 format!(
429 "textureSample(t_diffuse[{}u], s_diffuse, in.uv)",
430 tex_index
431 )
432 }
433 MaterialOp::PremultipliedBlend => {
434 let color_var = Self::find_input(&var_names, node_id, MaterialSocket::Color, graph)
435 .unwrap_or_else(|| "col".to_string());
436 let alpha_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
438 .unwrap_or_else(|| "1.0".to_string());
439 format!(
440 "vec4<f32>(({}).rgb, ({}).a * ({}))",
441 color_var, color_var, alpha_var
442 )
443 }
444 MaterialOp::SDFRoundRect => {
445 let half = "in.size * 0.5";
446 format!(
447 r#"
448 let _d = sd_round_rect(in.logical - {0}, {0} - in.radius, in.radius);
449 let _aa = fwidth(_d);
450 __RESULT__ = vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(0.0, _aa, _d)));"#,
451 half
452 ).trim().to_string()
453 }
454 MaterialOp::SDFEllipse => {
455 let half = "in.size * 0.5";
456 format!(
457 r#"
458 let _sh = max({0}, vec2<f32>(0.001));
459 let _d = length((in.logical - {0}) / _sh) - 1.0;
460 let _aa = fwidth(_d);
461 __RESULT__ = vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(0.0, _aa, _d)));"#,
462 half
463 ).trim().to_string()
464 }
465 MaterialOp::LinearGradient { start, end } => {
466 let t_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
467 .unwrap_or_else(|| "in.uv.x".to_string());
468 format!(
469 "mix(vec4<f32>({:.6},{:.6},{:.6},{:.6}), vec4<f32>({:.6},{:.6},{:.6},{:.6}), clamp({}, 0.0, 1.0))",
470 start[0], start[1], start[2], start[3],
471 end[0], end[1], end[2], end[3],
472 t_var
473 )
474 }
475 MaterialOp::RadialGradient { start, end } => {
476 format!(
477 r#"
478 let _dist = length(in.uv - 0.5) * 2.0;
479 __RESULT__ = mix(vec4<f32>({:.6},{:.6},{:.6},{:.6}), vec4<f32>({:.6},{:.6},{:.6},{:.6}), clamp(_dist, 0.0, 1.0));"#,
480 start[0], start[1], start[2], start[3],
481 end[0], end[1], end[2], end[3],
482 ).trim().to_string()
483 }
484 MaterialOp::NeonGlow { radius, intensity } => {
485 let dist_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
486 .unwrap_or_else(|| "length(in.logical - in.size * 0.5) / max(in.size.x, in.size.y)".to_string());
487 format!(
488 "vec4<f32>(col.rgb * exp(-{} * {:.6}), col.a)",
489 dist_var, intensity / radius.max(0.001)
490 )
491 }
492 MaterialOp::GlassBlur => {
493 r#"
494 let uv = clamp(in.uv, vec2<f32>(0.0), vec2<f32>(1.0));
495 let local = in.logical / in.size;
496 let centered = local - vec2<f32>(0.5, 0.5);
497 let lens_dir = normalize(centered + vec2<f32>(1e-5, 1e-5));
498 let lens_dist = length(centered);
499 let fresnel = pow(lens_dist * 1.8, 2.5);
500 let lens = lens_dir * lens_dist * 0.08;
501 let blur_mip = theme.glass_blur_strength;
502 let env_base = textureSampleLevel(t_env, s_env, uv, blur_mip).rgb;
503 let brightness = dot(env_base, vec3<f32>(0.299, 0.587, 0.114));
504 var distortion = lens * 1.2;
505 distortion *= (1.0 + brightness * 0.7);
506 distortion *= 2.0;
507 let ab_offset = distortion * 0.04;
508 let r_sample = textureSampleLevel(t_env, s_env, uv + distortion + ab_offset * 1.2, blur_mip).r;
509 let g_sample = textureSampleLevel(t_env, s_env, uv + distortion, blur_mip).g;
510 let b_sample = textureSampleLevel(t_env, s_env, uv + distortion - ab_offset * 1.2, blur_mip).b;
511 let refracted = vec3<f32>(r_sample, g_sample, b_sample);
512 let tint = vec3<f32>(0.85, 0.9, 1.0);
513 var final_rgb = refracted * tint;
514 final_rgb += (brightness * 0.2) * (0.9 + vnoise(uv * 20.0 + scene.time * 3.0) * 0.1);
515 let half_size = in.size * 0.5;
516 let p_sdf = in.logical - half_size;
517 let q_sdf = abs(p_sdf) - (half_size - in.radius);
518 let d_sdf = length(max(q_sdf, vec2(0.0))) + min(max(q_sdf.x, q_sdf.y), 0.0) - in.radius;
519 let d_norm = clamp(-d_sdf / 20.0, 0.0, 1.0);
520 let flicker = 0.9 + vnoise(uv * 20.0 + scene.time * 3.0) * 0.1;
521 final_rgb += smoothstep(1.0, 0.96, d_norm) * 0.25 * flicker * vec3<f32>(0.7, 1.0, 1.3);
522 final_rgb -= smoothstep(0.96, 0.88, d_norm) * 0.15;
523 let light_dir_h = normalize(vec2<f32>(-0.4, -0.8));
524 let l = dot(uv, light_dir_h);
525 final_rgb += smoothstep(0.45, 0.55, l) * 0.12;
526 __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()
527 }
528 MaterialOp::LayerBlend { mode } => {
529 let bottom = Self::find_input(&var_names, node_id, MaterialSocket::Color, graph)
530 .unwrap_or_else(|| "col".to_string());
531 let top = Self::find_input_map(&var_names, node_id, MaterialSocket::Color, graph, 1)
532 .unwrap_or_else(|| "col".to_string());
533 let opacity = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
534 .unwrap_or_else(|| "1.0".to_string());
535 match mode {
536 BlendMode::Add => {
537 format!("mix({}, {}, {})", bottom, top, opacity)
538 }
539 BlendMode::Screen => {
540 format!("mix({}, 1.0 - (1.0 - {}) * (1.0 - {}), {})", bottom, bottom, top, opacity)
541 }
542 BlendMode::Multiply => {
543 format!("mix({}, {} * {}, {})", bottom, bottom, top, opacity)
544 }
545 BlendMode::Overlay => {
546 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)
547 }
548 }
549 }
550 MaterialOp::PBRLighting => {
551 r#"
552 let _n = normalize(in.normal);
553 let _metallic = in.slice.x;
554 let _roughness = in.slice.y;
555 let _opacity = in.slice.z;
556 let _ld = normalize(vec3<f32>(0.5, 0.8, 0.6));
557 let _lc = vec3<f32>(1.0, 0.95, 0.9);
558 let _ndl = max(dot(_n, _ld), 0.0);
559 let _diffuse = _ndl * _lc;
560 let _vd = vec3<f32>(0.0, 0.0, 1.0);
561 let _hd = normalize(_ld + _vd);
562 let _ndh = max(dot(_n, _hd), 0.0);
563 let _shiny = mix(8.0, 256.0, 1.0 - _roughness);
564 let _spec = pow(_ndh, _shiny) * _lc;
565 let _f0 = mix(vec3<f32>(0.04), col.rgb, _metallic);
566 let _fresnel = _f0 + (vec3<f32>(1.0) - _f0) * pow(1.0 - max(dot(_n, -_vd), 0.0), 5.0);
567 let _amb = vec3<f32>(0.06, 0.07, 0.1);
568 var _lit = col.rgb * (_amb + _diffuse);
569 _lit += _spec * mix(vec3<f32>(1.0), col.rgb, _metallic) * _fresnel;
570 let _depth = in.clip_position.z;
571 let _fog = clamp(1.0 - _depth * 0.0005, 0.7, 1.0);
572 _lit *= _fog;
573 __RESULT__ = vec4<f32>(_lit, col.a * _opacity);"#.trim().to_string()
574 }
575 MaterialOp::DropShadow => {
576 r#"
577 let margin = in.uv.x;
578 let blur = max(in.uv.y, 1.0);
579 let original_size = in.size - 2.0 * margin;
580 let half_size = original_size * 0.5;
581 let p = in.logical - margin - half_size;
582 let d_sdf = sd_round_rect(p, half_size - in.radius, in.radius);
583 __RESULT__ = vec4<f32>(col.rgb, col.a * smoothstep(blur, 0.0, d_sdf));"#.trim().to_string()
584 }
585 MaterialOp::NineSlice => {
586 "col".to_string() }
588 MaterialOp::Heatmap => {
589 let val_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
590 .unwrap_or_else(|| "textureSample(t_diffuse[0], s_diffuse, in.uv).r".to_string());
591 format!("vec4<f32>(heatmap_palette({}), col.a)", val_var)
592 }
593 MaterialOp::Raymarch { shape } => {
594 match shape {
595 RaymarchShape::Box => {
596 r#"
597 let uv = (in.uv - 0.5) * 2.0;
598 let ro = vec3<f32>(0.0, 0.0, -2.5);
599 let rd = normalize(vec3<f32>(uv.x, uv.y, 1.5));
600 let m = rotX(in.slice.x) * rotY(in.slice.y) * rotZ(in.slice.z);
601 var t = 0.0;
602 var hit = false;
603 var d = 0.0;
604 for (var i = 0; i < 40; i++) {
605 let p = m * (ro + rd * t);
606 d = sd_box_3d(p, vec3(0.5, 0.5, 0.5));
607 if d < 0.001 {
608 hit = true;
609 break;
610 }
611 t += d;
612 if t > 5.0 { break; }
613 }
614 if hit {
615 let p = m * (ro + rd * t);
616 let eps = vec2(0.001, 0.0);
617 let n = normalize(vec3(
618 sd_box_3d(p + eps.xyy, vec3(0.5)) - sd_box_3d(p - eps.xyy, vec3(0.5)),
619 sd_box_3d(p + eps.yxy, vec3(0.5)) - sd_box_3d(p - eps.yxy, vec3(0.5)),
620 sd_box_3d(p + eps.yyx, vec3(0.5)) - sd_box_3d(p - eps.yyx, vec3(0.5))
621 ));
622 let light_dir = normalize(vec3(1.0, 1.0, -2.0));
623 let diff = max(dot(n, light_dir), 0.1);
624 let rim = pow(1.0 - max(dot(n, -rd), 0.0), 3.0) * 0.5;
625 __RESULT__ = vec4<f32>(col.rgb * diff + rim, col.a);
626 } else {
627 discard;
628 }"#.trim().to_string()
629 }
630 RaymarchShape::Sphere => {
631 r#"
632 let ro = vec3<f32>(in.uv * 2.0 - 1.0, -2.0);
633 let rd = normalize(vec3<f32>(0.0, 0.0, 1.0));
634 var t = 0.0;
635 var hit = false;
636 for (var i = 0; i < 32; i++) {
637 let p = ro + rd * t;
638 let d = length(p) - 1.0;
639 if d < 0.01 { hit = true; break; }
640 t += d;
641 }
642 if hit {
643 let p = ro + rd * t;
644 let n = normalize(p);
645 let ld = normalize(vec3<f32>(1.0, 1.0, -1.0));
646 let diff = max(dot(n, ld), 0.0);
647 __RESULT__ = vec4<f32>(col.rgb * diff, col.a);
648 } else {
649 discard;
650 }"#.trim().to_string()
651 }
652 }
653 }
654 MaterialOp::Lightning => {
655 r#"
656 let d = length((in.uv - 0.5) * vec2<f32>(1.0, 4.0));
657 __RESULT__ = theme.primary_neon * neon_glow(d, 0.01, 0.2);"#.trim().to_string()
658 }
659 MaterialOp::RuneGlow => {
660 r#"
661 let p = (in.uv - 0.5) * 2.0;
662 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)));
663 __RESULT__ = theme.rune_glow * neon_glow(d, 0.02, 0.15) * theme.rune_opacity;"#.trim().to_string()
664 }
665 MaterialOp::RaymarchReflections => {
666 r#"
667 let ro = vec3<f32>(in.uv.x - 0.5, in.uv.y - 0.5, -2.0);
668 let rd = normalize(vec3<f32>(in.uv.x - 0.5, in.uv.y - 0.5, 1.0));
669 let t = ray_march(ro, rd);
670 if t > 0.0 {
671 let p = ro + rd * t;
672 let n = calc_normal(p);
673 let light_dir = normalize(vec3<f32>(1.0, 1.0, -1.0));
674 let diff = max(dot(n, light_dir), 0.2);
675 let ref_rd = reflect(rd, n);
676 let ref_t = ray_march(p + n * 0.01, ref_rd);
677 var reflection_color = vec3<f32>(0.05, 0.05, 0.1);
678 if ref_t > 0.0 { reflection_color = mix(theme.primary_neon.rgb, theme.shatter_neon.rgb, 0.5); }
679 __RESULT__ = vec4<f32>(mix(col.rgb * diff, reflection_color, 0.3), 1.0);
680 } else { discard; }"#.trim().to_string()
681 }
682 MaterialOp::Stroke => {
683 r#"
684 let half_size = in.size * 0.5;
685 let d = sd_round_rect(in.logical - half_size, half_size - in.radius, in.radius);
686 let thickness = max(in.slice.x, 1.0);
687 let fw = length(vec2(dpdx(in.logical.x), dpdy(in.logical.y)));
688 __RESULT__ = vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(-fw, fw, abs(d + thickness * 0.5) - thickness * 0.5)));"#.trim().to_string()
689 }
690 MaterialOp::DashedStroke => {
691 r#"
692 let half_size = in.size * 0.5;
693 let d = sd_round_rect(in.logical - half_size, half_size - in.radius, in.radius);
694 let thickness = max(in.slice.x, 1.0);
695 let perimeter = (in.uv.x + in.uv.y) * max(in.size.x, in.size.y);
696 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);
697 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; }
698 __RESULT__ = vec4<f32>(col.rgb, col.a * alpha);"#.trim().to_string()
699 }
700 };
701
702 if expr.contains("__RESULT__") {
703 lines.push(format!(" var {}: vec4<f32>;", result_var));
704 lines.push(" {".to_string());
705 lines.push(expr.replace("__RESULT__", &result_var));
706 lines.push(" }".to_string());
707 } else {
708 lines.push(format!(" var {} = {};", result_var, expr));
709 }
710 var_names.insert((node_id, MaterialSocket::Color), result_var);
711 }
712
713 let body = lines.join("\n");
714 let out_id = graph.output.ok_or(MaterialError::NoOutput)?;
715 let fn_name = "material_entry".to_string();
716
717 let wgsl_fn = format!(
718 "fn {}(in: VertexOutput, col: vec4<f32>) -> vec4<f32> {{\n{}\n return v_{};\n}}",
719 fn_name, body, out_id
720 );
721
722 Ok(CompiledMaterial { wgsl_fn, fn_name })
723 }
724
725 fn find_input(
726 names: &HashMap<(MatNodeId, MaterialSocket), String>,
727 node: MatNodeId,
728 socket: MaterialSocket,
729 graph: &MaterialGraph,
730 ) -> Option<String> {
731 for edge in &graph.edges {
732 if edge.to_node == node && edge.to_socket == socket {
733 return names.get(&(edge.from_node, edge.from_socket)).cloned();
734 }
735 }
736 None
737 }
738
739 fn find_input_map(
740 names: &HashMap<(MatNodeId, MaterialSocket), String>,
741 node: MatNodeId,
742 socket: MaterialSocket,
743 graph: &MaterialGraph,
744 offset: usize,
745 ) -> Option<String> {
746 let mut matches = graph
747 .edges
748 .iter()
749 .filter(|e| e.to_node == node && e.to_socket == socket);
750 let edge = matches.nth(offset)?;
751 names.get(&(edge.from_node, edge.from_socket)).cloned()
752 }
753
754 fn topo_sort(graph: &MaterialGraph) -> Result<Vec<MatNodeId>, MaterialError> {
755 let n = graph.nodes.len();
756 let mut in_degree = vec![0u32; n];
757 let mut adj: Vec<Vec<MatNodeId>> = vec![Vec::new(); n];
758
759 for edge in &graph.edges {
760 adj[edge.from_node as usize].push(edge.to_node);
761 in_degree[edge.to_node as usize] += 1;
762 }
763
764 let mut queue: std::collections::VecDeque<MatNodeId> = std::collections::VecDeque::new();
765 for (i, °) in in_degree.iter().enumerate() {
766 if deg == 0 {
767 queue.push_back(i as MatNodeId);
768 }
769 }
770
771 let mut order = Vec::with_capacity(n);
772 while let Some(node) = queue.pop_front() {
773 order.push(node);
774 for &next in &adj[node as usize] {
775 in_degree[next as usize] -= 1;
776 if in_degree[next as usize] == 0 {
777 queue.push_back(next);
778 }
779 }
780 }
781
782 if order.len() != n {
783 return Err(MaterialError::Cycle);
784 }
785
786 Ok(order)
787 }
788}
789
790pub mod builtins {
793 use super::*;
794
795 pub fn rounded_rect() -> MaterialGraph {
797 let mut g = MaterialGraph::new();
798 let input = g.add_node(MaterialOp::InputColor);
799 let sdf = g.add_node(MaterialOp::SDFRoundRect);
800 g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
802 g.set_output(sdf);
803 g
804 }
805
806 pub fn glass() -> MaterialGraph {
808 let mut g = MaterialGraph::new();
809 let glass = g.add_node(MaterialOp::GlassBlur);
810 g.set_output(glass);
811 g
812 }
813
814 pub fn solid() -> MaterialGraph {
816 let mut g = MaterialGraph::new();
817 let input = g.add_node(MaterialOp::InputColor);
818 g.set_output(input);
819 g
820 }
821
822 pub fn pbr() -> MaterialGraph {
824 let mut g = MaterialGraph::new();
825 let input = g.add_node(MaterialOp::InputColor);
826 let pbr = g.add_node(MaterialOp::PBRLighting);
827 g.connect(input, MaterialSocket::Color, pbr, MaterialSocket::Color);
828 g.set_output(pbr);
829 g
830 }
831
832 pub fn text(tex_index: u32) -> MaterialGraph {
834 let mut g = MaterialGraph::new();
835 let input = g.add_node(MaterialOp::InputColor);
836 let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
837 let blend = g.add_node(MaterialOp::PremultipliedBlend);
838 g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
839 g.connect(tex, MaterialSocket::Float, blend, MaterialSocket::Float);
840 g.set_output(blend);
841 g
842 }
843
844 pub fn textured(tex_index: u32) -> MaterialGraph {
846 let mut g = MaterialGraph::new();
847 let input = g.add_node(MaterialOp::InputColor);
848 let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
849 let blend = g.add_node(MaterialOp::LayerBlend {
850 mode: BlendMode::Multiply,
851 });
852 g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
853 g.connect(tex, MaterialSocket::Color, blend, MaterialSocket::Color);
854 g.set_output(blend);
855 g
856 }
857
858 pub fn neon_glow(radius: f32, intensity: f32) -> MaterialGraph {
860 let mut g = MaterialGraph::new();
861 let input = g.add_node(MaterialOp::InputColor);
862 let glow = g.add_node(MaterialOp::NeonGlow { radius, intensity });
863 g.connect(input, MaterialSocket::Color, glow, MaterialSocket::Color);
864 g.set_output(glow);
865 g
866 }
867
868 pub fn linear_gradient(start: [f32; 4], end: [f32; 4]) -> MaterialGraph {
870 let mut g = MaterialGraph::new();
871 let grad = g.add_node(MaterialOp::LinearGradient { start, end });
872 g.set_output(grad);
873 g
874 }
875
876 pub fn radial_gradient(start: [f32; 4], end: [f32; 4]) -> MaterialGraph {
878 let mut g = MaterialGraph::new();
879 let grad = g.add_node(MaterialOp::RadialGradient { start, end });
880 g.set_output(grad);
881 g
882 }
883
884 pub fn ellipse() -> MaterialGraph {
886 let mut g = MaterialGraph::new();
887 let input = g.add_node(MaterialOp::InputColor);
888 let sdf = g.add_node(MaterialOp::SDFEllipse);
889 g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
890 g.set_output(sdf);
891 g
892 }
893
894 pub fn neon_line() -> MaterialGraph {
896 let mut g = MaterialGraph::new();
897 let color = g.add_node(MaterialOp::ConstantColor {
898 r: 1.5,
899 g: 1.5,
900 b: 1.5,
901 a: 1.0,
902 });
903 g.set_output(color);
904 g
905 }
906
907 pub fn heatmap(tex_index: u32) -> MaterialGraph {
909 let mut g = MaterialGraph::new();
910 let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
911 let hm = g.add_node(MaterialOp::Heatmap);
912 g.connect(tex, MaterialSocket::Float, hm, MaterialSocket::Float);
913 g.set_output(hm);
914 g
915 }
916
917 pub fn nine_slice(tex_index: u32) -> MaterialGraph {
919 let mut g = MaterialGraph::new();
920 let input = g.add_node(MaterialOp::InputColor);
921 let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
922 let blend = g.add_node(MaterialOp::LayerBlend {
923 mode: BlendMode::Multiply,
924 });
925 g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
926 g.connect(tex, MaterialSocket::Color, blend, MaterialSocket::Color);
927 g.set_output(blend);
928 g
929 }
930
931 pub fn raymarch_cube() -> MaterialGraph {
933 let mut g = MaterialGraph::new();
934 let input = g.add_node(MaterialOp::InputColor);
935 let rm = g.add_node(MaterialOp::Raymarch {
936 shape: RaymarchShape::Box,
937 });
938 g.connect(input, MaterialSocket::Color, rm, MaterialSocket::Color);
939 g.set_output(rm);
940 g
941 }
942
943 pub fn stroke() -> MaterialGraph {
945 let mut g = MaterialGraph::new();
946 let input = g.add_node(MaterialOp::InputColor);
947 let sdf = g.add_node(MaterialOp::Stroke);
948 g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
949 g.set_output(sdf);
950 g
951 }
952
953 pub fn drop_shadow() -> MaterialGraph {
955 let mut g = MaterialGraph::new();
956 let input = g.add_node(MaterialOp::InputColor);
957 let shadow = g.add_node(MaterialOp::DropShadow);
958 g.connect(input, MaterialSocket::Color, shadow, MaterialSocket::Color);
959 g.set_output(shadow);
960 g
961 }
962
963 pub fn dashed_stroke() -> MaterialGraph {
965 let mut g = MaterialGraph::new();
966 let input = g.add_node(MaterialOp::InputColor);
967 let sdf = g.add_node(MaterialOp::DashedStroke);
968 g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
969 g.set_output(sdf);
970 g
971 }
972
973 pub fn lightning() -> MaterialGraph {
974 let mut g = MaterialGraph::new();
975 let l = g.add_node(MaterialOp::Lightning);
976 g.set_output(l);
977 g
978 }
979
980 pub fn rune_glow() -> MaterialGraph {
981 let mut g = MaterialGraph::new();
982 let r = g.add_node(MaterialOp::RuneGlow);
983 g.set_output(r);
984 g
985 }
986
987 pub fn raymarch() -> MaterialGraph {
988 let mut g = MaterialGraph::new();
989 let input = g.add_node(MaterialOp::InputColor);
990 let rm = g.add_node(MaterialOp::RaymarchReflections);
991 g.connect(input, MaterialSocket::Color, rm, MaterialSocket::Color);
992 g.set_output(rm);
993 g
994 }
995}
996
997pub fn generate_builtins_wgsl() -> String {
998 let mut out = String::new();
999 out.push_str("// ── Auto-generated material functions (Runtime) ──\n\n");
1000
1001 let builtins = vec![
1002 (0, "solid", builtins::solid()),
1003 (1, "neon_line", builtins::neon_line()),
1004 (2, "textured", builtins::textured(0)),
1005 (3, "rounded_rect", builtins::rounded_rect()),
1006 (4, "ellipse", builtins::ellipse()),
1007 (6, "text", builtins::text(0)),
1008 (7, "glass", builtins::glass()),
1009 (8, "neon_glow", builtins::neon_glow(1.0, 1.0)),
1010 (9, "lightning", builtins::lightning()),
1011 (10, "rune_glow", builtins::rune_glow()),
1012 (12, "heatmap", builtins::heatmap(0)),
1013 (13, "pbr", builtins::pbr()),
1014 (14, "raymarch", builtins::raymarch()),
1015 (
1016 15,
1017 "linear_grad",
1018 builtins::linear_gradient([0.0; 4], [0.0; 4]),
1019 ),
1020 (
1021 16,
1022 "radial_grad",
1023 builtins::radial_gradient([0.0; 4], [0.0; 4]),
1024 ),
1025 (17, "stroke", builtins::stroke()),
1026 (18, "drop_shadow", builtins::drop_shadow()),
1027 (19, "dashed", builtins::dashed_stroke()),
1028 (20, "nine_slice", builtins::nine_slice(0)),
1029 (21, "raymarch_cube", builtins::raymarch_cube()),
1030 ];
1031
1032 let mut dispatch = String::new();
1033 dispatch.push_str(
1034 "fn dispatch_material(material_id: u32, in: VertexOutput, col: vec4<f32>) -> vec4<f32> {\n",
1035 );
1036 dispatch.push_str(" switch material_id {\n");
1037
1038 for (id, name, graph) in builtins {
1039 let compiled = MaterialCompiler::compile(&graph).unwrap();
1040 let fn_name = format!("material_{}_{}", id, name);
1041 let fn_code = compiled.wgsl_fn.replace("material_entry", &fn_name);
1042 out.push_str(&fn_code);
1043 out.push_str("\n\n");
1044
1045 dispatch.push_str(&format!(
1046 " case {}u: {{ return {}(in, col); }}\n",
1047 id, fn_name
1048 ));
1049 }
1050
1051 dispatch.push_str(" default: { return col; }\n");
1052 dispatch.push_str(" }\n}\n");
1053
1054 out.push_str(&dispatch);
1055 out
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060 use super::*;
1061
1062 #[test]
1063 fn test_solid_material_compiles() {
1064 let graph = builtins::solid();
1065 let compiled = MaterialCompiler::compile(&graph).unwrap();
1066 assert!(compiled.wgsl_fn.contains("fn material_"));
1067 assert!(compiled.wgsl_fn.contains("col"));
1068 }
1069
1070 #[test]
1071 fn test_rounded_rect_compiles() {
1072 let graph = builtins::rounded_rect();
1073 let compiled = MaterialCompiler::compile(&graph).unwrap();
1074 assert!(compiled.wgsl_fn.contains("sd_round_rect"));
1075 }
1076
1077 #[test]
1078 fn test_pbr_compiles() {
1079 let graph = builtins::pbr();
1080 let compiled = MaterialCompiler::compile(&graph).unwrap();
1081 assert!(compiled.wgsl_fn.contains("PBRLighting") || compiled.wgsl_fn.contains("_n"));
1082 }
1083
1084 #[test]
1085 fn test_graph_validation_no_output() {
1086 let mut g = MaterialGraph::new();
1087 g.add_node(MaterialOp::InputColor);
1088 assert!(g.validate().is_err());
1089 }
1090
1091 #[test]
1092 fn test_graph_validation_cycle() {
1093 let mut g = MaterialGraph::new();
1094 let a = g.add_node(MaterialOp::InputColor);
1095 let b = g.add_node(MaterialOp::NeonGlow {
1096 radius: 1.0,
1097 intensity: 1.0,
1098 });
1099 g.connect(a, MaterialSocket::Color, b, MaterialSocket::Color);
1100 g.connect(b, MaterialSocket::Color, a, MaterialSocket::Color); g.set_output(b);
1102 assert!(g.validate().is_err());
1103 }
1104
1105 #[test]
1106 fn test_all_builtins_compile() {
1107 let graphs: Vec<MaterialGraph> = vec![
1108 builtins::solid(),
1109 builtins::rounded_rect(),
1110 builtins::glass(),
1111 builtins::pbr(),
1112 builtins::text(0),
1113 builtins::textured(0),
1114 builtins::neon_glow(4.0, 1.5),
1115 builtins::linear_gradient([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]),
1116 builtins::radial_gradient([1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]),
1117 builtins::ellipse(),
1118 builtins::neon_line(),
1119 builtins::heatmap(0),
1120 builtins::nine_slice(0),
1121 builtins::raymarch_cube(),
1122 builtins::stroke(),
1123 builtins::drop_shadow(),
1124 builtins::dashed_stroke(),
1125 ];
1126
1127 for (i, graph) in graphs.iter().enumerate() {
1128 match MaterialCompiler::compile(graph) {
1129 Ok(compiled) => {
1130 assert!(
1131 !compiled.wgsl_fn.is_empty(),
1132 "graph {} produced empty WGSL",
1133 i
1134 );
1135 assert!(
1136 !compiled.fn_name.is_empty(),
1137 "graph {} produced empty fn name",
1138 i
1139 );
1140 }
1141 Err(e) => {
1142 panic!("graph {} failed to compile: {}", i, e);
1143 }
1144 }
1145 }
1146 }
1147
1148 #[test]
1153 fn p1_4_validate_rejects_too_many_edges() {
1154 let mut graph = MaterialGraph::new();
1156 graph.output = Some(0);
1158 graph.add_node(MaterialOp::InputColor);
1160 graph.add_node(MaterialOp::InputColor);
1161 graph.add_node(MaterialOp::InputColor);
1162 graph.connect(0, MaterialSocket::Color, 1, MaterialSocket::Color);
1164 graph.connect(1, MaterialSocket::Color, 2, MaterialSocket::Color);
1165 assert_eq!(graph.edges.len(), 2, "test setup: need 2 edges");
1166 let config = MaterialValidationConfig { max_nodes: 1024, max_edges: 1 };
1168 let result = graph.validate_with_config(&config);
1169 assert!(matches!(result, Err(MaterialError::TooManyEdges(2, 1))),
1170 "expected TooManyEdges(2, 1), got {result:?}");
1171 }
1172
1173 #[test]
1174 fn p1_4_default_config_has_max_edges() {
1175 let config = MaterialValidationConfig::default();
1178 assert!(config.max_edges > 0,
1179 "default max_edges must be > 0, got {}", config.max_edges);
1180 }
1181
1182 #[test]
1183 fn p1_4_validate_accepts_graph_within_edge_limit() {
1184 let mut graph = MaterialGraph::new();
1186 graph.output = Some(0);
1187 graph.add_node(MaterialOp::InputColor);
1188 graph.add_node(MaterialOp::InputColor);
1189 graph.connect(0, MaterialSocket::Color, 1, MaterialSocket::Color);
1190 let result = graph.validate_with_config(&MaterialValidationConfig::default());
1191 if let Err(MaterialError::TooManyEdges(_, _)) = result {
1195 panic!("default config should accept 1 edge, got {result:?}");
1196 }
1197 }
1198
1199 #[test]
1201 fn p2_10_unreachable_node_detected() {
1202 let mut graph = MaterialGraph::new();
1203 let n0 = graph.add_node(MaterialOp::InputColor);
1204 let n1 = graph.add_node(MaterialOp::ConstantColor { r: 1.0, g: 0.0, b: 0.0, a: 1.0 });
1205 let n2 = graph.add_node(MaterialOp::ConstantColor { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }); graph.connect(n0, MaterialSocket::Color, n1, MaterialSocket::Color);
1207 graph.set_output(n1);
1208 let result = graph.validate();
1210 assert!(
1211 matches!(result, Err(MaterialError::UnreachableNode(id)) if id == n2),
1212 "expected UnreachableNode({n2}), got {result:?}"
1213 );
1214 }
1215
1216 #[test]
1217 fn p2_10_all_reachable_passes() {
1218 let mut graph = MaterialGraph::new();
1219 let n0 = graph.add_node(MaterialOp::InputColor);
1220 let n1 = graph.add_node(MaterialOp::ConstantColor { r: 1.0, g: 0.0, b: 0.0, a: 1.0 });
1221 graph.connect(n0, MaterialSocket::Color, n1, MaterialSocket::Color);
1222 graph.set_output(n1);
1223 assert!(graph.validate().is_ok(), "valid graph should pass");
1225 }
1226}