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