Skip to content

Commit 5476cc4

Browse files
committed
Material Instances (Stage 3): .material_instance + per-parameter overrides
Adds the .material_instance file format and resolver path so projects can ship many visually distinct materials sharing one compiled master shader, mirroring Unreal's Material Instances. - New crates/renzora_shader/src/material/instance.rs — JSON file format with `master` path + `overrides` map keyed by Stage 2's parameter names. - Resolver: trivial masters splice overrides into param/* defaults and re-classify, emitting a fresh StandardMaterial per instance. Procedural masters compile once (cached), then each instance clones the GraphMaterial and overwrites only the parameter UBO slots — wgpu reuses the same specialized pipeline across every instance. - codegen.rs exposes MaterialParam / ParamKind so the instance code can build override slots without reaching into private state. - surface_ext.rs declares SURFACE_GRAPH_PARAM_SLOTS as the canonical UBO layout instances target. - Asset browser + material inspector recognize .material_instance as a material doc and an acceptable drop target.
1 parent b23cf31 commit 5476cc4

7 files changed

Lines changed: 613 additions & 68 deletions

File tree

crates/renzora_asset_browser/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,10 @@ fn open_double_clicked(world: &bevy::prelude::World, path: std::path::PathBuf) {
795795
fn asset_doc_kind(path: &std::path::Path) -> Option<renzora_editor::DocTabKind> {
796796
use renzora_editor::DocTabKind;
797797
let name = path.file_name().and_then(|n| n.to_str()).map(|s| s.to_lowercase())?;
798-
if name.ends_with(".material_bp") || name.ends_with(".material") {
798+
if name.ends_with(".material_bp")
799+
|| name.ends_with(".material_instance")
800+
|| name.ends_with(".material")
801+
{
799802
return Some(DocTabKind::Material);
800803
}
801804
if name.ends_with(".particle") {

crates/renzora_material_editor/src/material_inspector.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ fn material_custom_ui(
5858

5959
let payload = world.get_resource::<AssetDragPayload>();
6060

61-
let mut all_exts: Vec<&str> = vec!["material"];
61+
let mut all_exts: Vec<&str> = vec!["material", "material_instance"];
6262
all_exts.extend_from_slice(IMAGE_EXTENSIONS);
6363

6464
let current_label = if current_path.is_empty() {
@@ -514,7 +514,7 @@ fn material_custom_ui(
514514
.unwrap_or("")
515515
.to_ascii_lowercase();
516516

517-
if ext == "material" {
517+
if ext == "material" || ext == "material_instance" {
518518
cmds.push(move |world: &mut World| {
519519
let mat_path = if let Some(project) = world.get_resource::<renzora::core::CurrentProject>() {
520520
project.make_asset_relative(&dropped)
@@ -616,7 +616,10 @@ fn find_material_files(project_root: &std::path::Path) -> Vec<(String, String)>
616616
stack.push((path, depth + 1));
617617
}
618618
} else if ft.is_file()
619-
&& path.extension().and_then(|e| e.to_str()) == Some("material")
619+
&& matches!(
620+
path.extension().and_then(|e| e.to_str()),
621+
Some("material") | Some("material_instance")
622+
)
620623
{
621624
if let Ok(rel) = path.strip_prefix(project_root) {
622625
let rel_str = rel.to_string_lossy().replace('\\', "/");

crates/renzora_shader/src/material/codegen.rs

Lines changed: 70 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ use super::nodes;
1111
/// Populated from disk by the resolver and passed to `compile()`.
1212
pub type FunctionRegistry = HashMap<String, MaterialFunction>;
1313

14+
/// How many distinct named parameters one master graph can declare. Must
15+
/// match the array size in the WGSL `SurfaceGraphParams` declaration and
16+
/// `surface_ext::SURFACE_GRAPH_PARAM_SLOTS`. Bumping this requires updating
17+
/// all three locations together.
18+
pub const MAX_PARAMETER_SLOTS: usize = 32;
19+
1420
/// Sanitize a function name into a WGSL-identifier-safe string.
1521
fn safe_fn_ident(name: &str) -> String {
1622
let mut out = String::with_capacity(name.len());
@@ -144,10 +150,17 @@ struct Ctx<'a> {
144150
uses_cube_0: bool,
145151
uses_array_0: bool,
146152
uses_volume_0: bool,
147-
/// Named parameters discovered while walking the graph. Order is the order
148-
/// nodes were visited during recursion — stable across compiles of an
149-
/// unchanged graph, which matters for instance override stability.
153+
/// Named parameters discovered while walking the graph. The `Vec`'s
154+
/// position is the slot index in `material_params.slots[N]` — codegen
155+
/// emits reads keyed on that index, and the resolver writes the
156+
/// corresponding default (or instance override) into the same slot.
157+
/// Names are deduped: two `param/float` nodes with the same name share
158+
/// one slot so changing one override updates every reader.
150159
parameters: Vec<MaterialParam>,
160+
/// Name → slot index, mirroring `parameters` ordering. Lets the codegen
161+
/// look up an already-allocated slot in O(1) when the same parameter
162+
/// name appears on multiple nodes.
163+
parameter_slots: HashMap<String, usize>,
151164
}
152165

153166
impl<'a> Ctx<'a> {
@@ -190,7 +203,34 @@ impl<'a> Ctx<'a> {
190203
uses_array_0: false,
191204
uses_volume_0: false,
192205
parameters: Vec::new(),
206+
parameter_slots: HashMap::new(),
207+
}
208+
}
209+
210+
/// Allocate (or reuse) a parameter slot by name. The first call for a
211+
/// given name appends to `parameters`; subsequent calls return the
212+
/// existing slot. Saturates at the last slot if a graph exceeds the
213+
/// uniform buffer's capacity — every read past the cap collides on
214+
/// slot N-1, which is wrong but won't UB-trap the GPU. The compile
215+
/// emits a warning so the user can split the master.
216+
fn intern_parameter(&mut self, name: &str, kind: ParamKind, default: graph::PinValue) -> usize {
217+
if let Some(&slot) = self.parameter_slots.get(name) {
218+
return slot;
219+
}
220+
let slot = self.parameters.len();
221+
if slot >= MAX_PARAMETER_SLOTS {
222+
// Once we hit the cap, every subsequent unique name aliases the
223+
// last slot. We still record the parameter so tooling can list
224+
// it, but the actual reads will collide.
225+
return MAX_PARAMETER_SLOTS - 1;
193226
}
227+
self.parameters.push(MaterialParam {
228+
name: name.to_string(),
229+
kind,
230+
default,
231+
});
232+
self.parameter_slots.insert(name.to_string(), slot);
233+
slot
194234
}
195235

196236
fn next_var(&mut self, prefix: &str) -> String {
@@ -495,24 +535,23 @@ impl<'a> Ctx<'a> {
495535

496536
// ── Parameters ──────────────────────────────────────────
497537
//
498-
// Stage 2: each `param/*` node bakes its authored default into the
499-
// shader as a literal AND records the (name, kind, default) so
500-
// downstream tooling can build material instances. Stage 3 will
501-
// swap the literal for a uniform read so instances can override
502-
// without recompiling the master.
538+
// Each `param/*` node reads from `material_params.slots[N]`
539+
// where `N` is the slot index allocated by `intern_parameter`.
540+
// The resolver writes the master's authored default into that
541+
// slot when building the master GraphMaterial; material
542+
// instances reuse the master's compiled shader and overwrite
543+
// the same slot with their per-instance value.
503544
"param/float" => {
504545
let name = param_name(node, "FloatParam");
505546
let default = match node.input_values.get("default") {
506547
Some(PinValue::Float(f)) => *f,
507548
_ => 0.0,
508549
};
509-
self.parameters.push(MaterialParam {
510-
name,
511-
kind: ParamKind::Float,
512-
default: PinValue::Float(default),
513-
});
550+
let slot = self.intern_parameter(&name, ParamKind::Float, PinValue::Float(default));
514551
let v = self.next_var("param_f");
515-
self.emit(format!(" let {v} = {:.6};", default));
552+
self.emit(format!(
553+
" let {v} = material_params.slots[{slot}].x;"
554+
));
516555
self.set_out(id, "value", v);
517556
}
518557
"param/color" => {
@@ -521,16 +560,9 @@ impl<'a> Ctx<'a> {
521560
Some(PinValue::Color(c)) => *c,
522561
_ => [1.0, 1.0, 1.0, 1.0],
523562
};
524-
self.parameters.push(MaterialParam {
525-
name,
526-
kind: ParamKind::Color,
527-
default: PinValue::Color(default),
528-
});
563+
let slot = self.intern_parameter(&name, ParamKind::Color, PinValue::Color(default));
529564
let v = self.next_var("param_c");
530-
self.emit(format!(
531-
" let {v} = vec4<f32>({:.6}, {:.6}, {:.6}, {:.6});",
532-
default[0], default[1], default[2], default[3]
533-
));
565+
self.emit(format!(" let {v} = material_params.slots[{slot}];"));
534566
self.set_out(id, "value", v);
535567
}
536568
"param/vec2" => {
@@ -539,15 +571,10 @@ impl<'a> Ctx<'a> {
539571
Some(PinValue::Vec2(v)) => *v,
540572
_ => [0.0, 0.0],
541573
};
542-
self.parameters.push(MaterialParam {
543-
name,
544-
kind: ParamKind::Vec2,
545-
default: PinValue::Vec2(default),
546-
});
574+
let slot = self.intern_parameter(&name, ParamKind::Vec2, PinValue::Vec2(default));
547575
let v = self.next_var("param_v2");
548576
self.emit(format!(
549-
" let {v} = vec2<f32>({:.6}, {:.6});",
550-
default[0], default[1]
577+
" let {v} = material_params.slots[{slot}].xy;"
551578
));
552579
self.set_out(id, "value", v);
553580
}
@@ -557,15 +584,10 @@ impl<'a> Ctx<'a> {
557584
Some(PinValue::Vec3(v)) => *v,
558585
_ => [0.0, 0.0, 0.0],
559586
};
560-
self.parameters.push(MaterialParam {
561-
name,
562-
kind: ParamKind::Vec3,
563-
default: PinValue::Vec3(default),
564-
});
587+
let slot = self.intern_parameter(&name, ParamKind::Vec3, PinValue::Vec3(default));
565588
let v = self.next_var("param_v3");
566589
self.emit(format!(
567-
" let {v} = vec3<f32>({:.6}, {:.6}, {:.6});",
568-
default[0], default[1], default[2]
590+
" let {v} = material_params.slots[{slot}].xyz;"
569591
));
570592
self.set_out(id, "value", v);
571593
}
@@ -575,16 +597,9 @@ impl<'a> Ctx<'a> {
575597
Some(PinValue::Vec4(v)) => *v,
576598
_ => [0.0, 0.0, 0.0, 0.0],
577599
};
578-
self.parameters.push(MaterialParam {
579-
name,
580-
kind: ParamKind::Vec4,
581-
default: PinValue::Vec4(default),
582-
});
600+
let slot = self.intern_parameter(&name, ParamKind::Vec4, PinValue::Vec4(default));
583601
let v = self.next_var("param_v4");
584-
self.emit(format!(
585-
" let {v} = vec4<f32>({:.6}, {:.6}, {:.6}, {:.6});",
586-
default[0], default[1], default[2], default[3]
587-
));
602+
self.emit(format!(" let {v} = material_params.slots[{slot}];"));
588603
self.set_out(id, "value", v);
589604
}
590605
"param/bool" => {
@@ -593,15 +608,10 @@ impl<'a> Ctx<'a> {
593608
Some(PinValue::Bool(b)) => *b,
594609
_ => false,
595610
};
596-
self.parameters.push(MaterialParam {
597-
name,
598-
kind: ParamKind::Bool,
599-
default: PinValue::Bool(default),
600-
});
611+
let slot = self.intern_parameter(&name, ParamKind::Bool, PinValue::Bool(default));
601612
let v = self.next_var("param_b");
602613
self.emit(format!(
603-
" let {v} = {};",
604-
if default { "true" } else { "false" }
614+
" let {v} = material_params.slots[{slot}].x > 0.5;"
605615
));
606616
self.set_out(id, "value", v);
607617
}
@@ -2602,6 +2612,13 @@ fn texture_bindings_wgsl(ctx: &Ctx) -> String {
26022612
s.push_str("@group(3) @binding(112) var volume_0: texture_3d<f32>;\n");
26032613
s.push_str("@group(3) @binding(113) var volume_0_sampler: sampler;\n");
26042614
}
2615+
// Always declare the parameter UBO at @binding(118) — the
2616+
// `AsBindGroup` derive on `SurfaceGraphExt` requires this slot to be
2617+
// bound regardless of whether the graph reads any params, otherwise
2618+
// wgpu rejects the pipeline at draw time. Keep the slot count in sync
2619+
// with `surface_ext::SURFACE_GRAPH_PARAM_SLOTS`.
2620+
s.push_str("struct SurfaceGraphParams { slots: array<vec4<f32>, 32>, }\n");
2621+
s.push_str("@group(3) @binding(118) var<uniform> material_params: SurfaceGraphParams;\n");
26052622
s
26062623
}
26072624

0 commit comments

Comments
 (0)