makepad/libs/render/src/custom_material.rs
Admin 53e0e85b92 draw and widgets: Slug text everywhere, no blank glyphs, slimmer widgets
All outline text draws with Slug curves on every platform (SDF/MSDF stays
selectable per size); a frame never presents a blank glyph and retained draw
lists keep theirs. One coverage model for vector feathering, widget families
split out of the core crate, fewer steady-state allocations, video keeps its
aspect (Contain/Cover), render and SSAO pass unification.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:26:21 +02:00

592 lines
31 KiB
Rust

//! Splash materials on the model lanes (KERNELS.md §3.3): the Pbr lane with
//! a material's hooks installed (makepad-render-material), the Unlit kind,
//! the error material, opt-in IBL, and the derived shadow caster.
//!
//! The engine owns the lane: its vertex stage, cascades, clustered lights
//! and fog run unchanged, and the hooks replace only what they name (their
//! stock bodies are in shaders/pbr.rs, skinned.rs and clustered.rs). A
//! recoloured car keeps the clear-coat response of its stock material. A
//! host passes the hooks it allows: Sandbox's game.material allows the
//! albedo `surface` hook only (`HookMask::ALBEDO`).
use makepad_draw::*;
use makepad_render_material::{self as material, builtin, Builtin, HookMask, HookSet, MaterialDesc, MaterialError, ShadowVariant, VariantPlan};
use makepad_scene::{BaseKind, Blend};
use crate::renderer::variants::{self, Features};
use crate::shaders::{DrawLmSunDepthCutout, DrawScenePbr};
use std::collections::HashMap;
use std::rc::Rc;
#[derive(Script, ScriptHook)]
#[repr(C)]
pub struct DrawSceneCustom {
#[deref] pub pbr: DrawScenePbr,
}
impl DrawSceneCustom {
/// Set the material's four keyable floats (`self.params` in the hooks).
pub fn set_params(&mut self, cx: &Cx, params: Vec4f) {
self.pbr.skinned.draw_vars.set_uniform(cx, live_id!(params), &[params.x, params.y, params.z, params.w]);
}
/// The material's four keyable floats as last set.
pub fn params(&self, cx: &Cx) -> Vec4f {
let p = self.pbr.skinned.uniform_value(cx, live_id!(params));
vec4(p[0], p[1], p[2], p[3])
}
}
/// The derived shadow caster: the sun-depth projection with the material's
/// `vertex` hook, and for a masked material its `surface` alpha.
#[derive(Script, ScriptHook)]
#[repr(C)]
pub struct DrawMaterialShadow {
#[deref] pub depth: DrawLmSunDepthCutout,
#[live(vec4(0.0, 0.0, 0.0, 0.0))] pub params: Vec4f,
/// Alpha under this cuts the caster (Mask); 0 casts every fragment.
#[live(0.0)] pub cutoff: f32,
}
script_mod! {
use mod.prelude.widgets_internal.*
mod.draw.DrawSceneCustom = mod.std.set_type_default() do #(DrawSceneCustom::script_shader(vm)) {
..mod.draw.DrawScenePbr
// The material's four keyable floats (`self.params` in the hooks),
// the whole per-draw budget; more data rides textures. A uniform: on
// the instance stream they took the lane one output register past
// what D3D11's `vs_5_0` allows, and they are one per material draw.
params: uniform(vec4(0.0, 0.0, 0.0, 0.0))
surface: fn(base: vec4) -> vec4 { return base }
// What a hook may read besides its arguments: the clock, the true
// world position, the mesh uv and the geometric normal.
mat_time: fn() -> float { return self.draw_pass.time }
// The same clock for the vertex hook: a helper binds to one stage.
mat_vtime: fn() -> float { return self.draw_pass.time }
mat_pos: fn() -> vec3 { return self.v_csm.xyz }
mat_uv: fn() -> vec2 { return self.v_uv }
mat_geo_normal: fn() -> vec3 { return normalize(self.v_csm_n) }
}
mod.draw.DrawMaterialShadow = mod.std.set_type_default() do #(DrawMaterialShadow::script_shader(vm)) {
..mod.draw.DrawLmSunDepthCutout
v_csm: varying(vec4f)
v_csm_n: varying(vec3f)
// What a colour lane's hooks may read for their alpha: the vertex
// colour, and neutral stand-ins for the instance tint, the scene
// transform and the eye (the caster's alpha never depends on them).
v_tint: varying(vec4f)
tint: uniform(vec4(1.0, 1.0, 1.0, 1.0))
eye: uniform(vec4(0.0, 0.0, 0.0, 0.0))
to_scene: fn(c: vec3) -> vec3 { return c }
oct_decode: fn(e: vec2f) -> vec3f {
let nz = 1.0 - abs(e.x) - abs(e.y)
let t = max(0.0 - nz, 0.0)
let sx = step(0.0, e.x) * 2.0 - 1.0
let sy = step(0.0, e.y) * 2.0 - 1.0
return normalize(vec3(e.x - t * sx, e.y - t * sy, nz))
}
mat_vertex: fn(p: vec3, n: vec3, uv: vec2) -> vec3 { return p }
surface: fn(base: vec4) -> vec4 { return base }
mat_time: fn() -> float { return self.draw_pass.time }
// The same clock for the vertex hook: a helper binds to one stage.
mat_vtime: fn() -> float { return self.draw_pass.time }
mat_pos: fn() -> vec3 { return self.v_csm.xyz }
mat_uv: fn() -> vec2 { return self.v_uv }
mat_geo_normal: fn() -> vec3 { return normalize(self.v_csm_n) }
vertex: fn() {
var pos = vec3(self.geom.px, self.geom.py, self.geom.pz)
if self.morph_ctl.w > 0.5 { pos = pos + self.morph_delta(self.geom.ao_uv, 0.0) }
let n = self.oct_decode(unpack2f16(self.geom.nrm))
self.v_uv = unpack2f16(self.geom.uv)
self.v_tint = unpack4u8(self.geom.color)
pos = self.mat_vertex(pos, n, self.v_uv)
let wp = self.transform * vec4(pos, 1.0)
self.v_csm = vec4(wp.xyz, 0.0)
self.v_csm_n = (self.transform * vec4(n, 0.0)).xyz
let nx = dot(self.sun_rx.xyz, wp.xyz) + self.sun_rx.w
let ny = dot(self.sun_ry.xyz, wp.xyz) + self.sun_ry.w
let nz = dot(self.sun_rz.xyz, wp.xyz) + self.sun_rz.w
self.v_d = nz
self.v_clip = vec2(nx, ny)
let zq = nz + self.flip_a.x * (1.0 - 2.0 * nz)
self.vertex_pos = vec4(
nx * self.tile_a.x + self.tile_a.z,
ny * self.tile_a.y + self.tile_a.w,
zq * (1.0 - self.flip_a.y) + self.flip_a.z,
1.0
)
}
// The caster's alpha: 1 here; the Mask variant installs
// mat_shadow_alpha_mask, the surface hook's alpha of the texel.
mat_shadow_alpha: fn() -> float { return 1.0 }
mat_shadow_alpha_mask: fn() -> float {
let texel = self.tex.sample_repeat(self.v_uv)
return self.surface(texel).w
}
pixel: fn() {
if abs(self.v_clip.x) > 1.001 || abs(self.v_clip.y) > 1.001 {
discard()
}
if self.mat_shadow_alpha() < self.cutoff {
discard()
}
return vec4(self.v_d, 0.0, 0.0, 1.0)
}
}
}
/// Install shader declarations in the owning isolate. Registration grants
/// no IO or host-widget access.
pub fn register(vm: &mut ScriptVm) -> ScriptValue {
if vm.heap().type_default_for_id(DrawSceneCustom::script_type_id_static()).is_some() {
builtin::register(vm);
return NIL;
}
// The scene shaders exactly as `crate::script_mod` registers them (the
// shared stdlib, the shadow sampling this build uses, ...): a second,
// partial list here made different shaders (and shader-pack keys)
// depending on which registration ran first.
if vm.heap().type_default_for_id(DrawScenePbr::script_type_id_static()).is_none() {
crate::script_mod(vm);
}
builtin::register(vm);
script_mod(vm)
}
/// Runs `f` in the VM that owns a material's program (a level isolate's);
/// false when that VM is busy. A material without one uses the Cx's VM.
pub type MaterialVm = Rc<dyn Fn(&mut Cx, &mut dyn FnMut(&mut ScriptVm)) -> bool>;
/// A built material: the colour draw, and what its plan derived.
pub struct CustomMaterial {
pub draw: DrawSceneCustom,
pub plan: VariantPlan,
/// The colour program, which the feature variants derive from
/// (renderer/variants.rs), and its stock shader.
program: Option<ScriptObjectRef>,
stock: Option<DrawShaderId>,
/// The VM that owns `program`, when it is not the Cx's.
pub host_vm: Option<MaterialVm>,
/// The variants built so far per feature set: the colour shader and,
/// for a material no hook of which can cut a pixel, the one without
/// `clip`. `None`: not available (its layout differs), stock instead.
variants: HashMap<Features, Option<(DrawShaderId, Option<DrawShaderId>)>>,
/// The derived caster (`ShadowVariant::Derived`), when it compiled.
pub shadow: Option<DrawShaderId>,
pub cutoff: f32,
/// How far the vertex hook may move geometry (culling grows by it).
pub bounds_pad: f32,
/// The program samples the IBL texture (bind it on `detail_map`).
pub ibl: bool,
/// A texture the host binds on `detail_map` for this program (a
/// planar reflection, a screen): wins over the IBL.
pub texture: Option<Texture>,
/// Problems that did not stop the colour program (a caster that did not
/// compile casts the stock shadow instead).
pub warnings: Vec<material::Diagnostic>,
}
impl CustomMaterial {
fn new(draw: DrawSceneCustom, plan: VariantPlan, program: Option<ScriptObjectRef>) -> Self {
Self {
stock: draw.draw_vars.draw_shader_id,
draw,
plan,
program,
host_vm: None,
variants: HashMap::new(),
shadow: None,
cutoff: 0.0,
bounds_pad: 0.0,
ibl: false,
texture: None,
warnings: Vec::new(),
}
}
/// The colour program's own shader (the one built for every feature).
pub fn stock_shader(&self) -> Option<DrawShaderId> {
self.stock
}
/// Whether the program composes light: the Unlit, Flat and error
/// programs do not, so their variants drop the lighting the lane
/// computes for the composition.
fn lit(&self) -> bool {
!self.plan.builtins.iter().any(|b| matches!(b, Builtin::Unlit | Builtin::Flat | Builtin::Error))
}
/// The blend the colour draw is set to (0 opaque, 1 mask, 2 over): a
/// uniform of the lane, read back from the draw.
pub fn alpha_mode(&self, cx: &Cx) -> f32 {
self.draw.pbr.skinned.uniform_value(cx, live_id!(alpha_mode))[0]
}
/// Whether no draw of this material cuts a pixel (no hook can discard,
/// opaque blend): it may draw through the variant without `clip`.
pub fn cuts_no_pixel(&self, cx: &Cx) -> bool {
!self.plan.can_discard && self.alpha_mode(cx) == 0.0
}
/// The shaders for this frame's `features` (renderer/variants.rs): the
/// one the material draws through, and the one without `clip` when no
/// draw of it cuts a pixel. Built on first use; until then (its VM is
/// busy), or when a variant's layout differs, the stock shader.
pub(crate) fn shaders(&mut self, cx: &mut Cx, features: Features) -> (Option<DrawShaderId>, Option<DrawShaderId>) {
let Some(stock) = self.stock else { return (None, None) };
if std::env::var_os("MAKEPAD_SHADER_VARIANTS").is_some_and(|v| v == "0") {
return (Some(stock), None);
}
let features = Features { clip: true, ..features };
if !self.variants.contains_key(&features) {
let Some(program) = self.program.clone() else { return (Some(stock), None) };
let (lit, opaque) = (self.lit(), self.cuts_no_pixel(cx) && std::env::var_os("MAKEPAD_OPAQUE_VARIANT").is_none_or(|v| v != "0"));
let mut built = None;
let mut run = |vm: &mut ScriptVm| {
// Only against the heap that owns the program.
if vm.heap_key() != program.heap_key() { built = Some(None); return; }
let full = build_variant(vm, program.as_object(), stock, features, lit);
let no_clip = if opaque { build_variant(vm, program.as_object(), stock, Features { clip: false, ..features }, lit) } else { None };
built = Some(full.map(|full| (full, no_clip)));
};
let ran = match &self.host_vm {
Some(host) => host(cx, &mut run),
None => cx.try_with_vm(|vm| run(vm)).is_some(),
};
match built {
Some(built) if ran => { self.variants.insert(features, built); }
_ => return (Some(stock), None),
}
}
match self.variants[&features] {
Some((full, no_clip)) => (Some(full), no_clip),
None => (Some(stock), None),
}
}
}
impl From<DrawSceneCustom> for CustomMaterial {
/// A colour draw built elsewhere: the stock caster, no derived
/// variants.
fn from(draw: DrawSceneCustom) -> Self {
Self::new(draw, VariantPlan { builtins: Vec::new(), can_discard: true, shadow: ShadowVariant::Stock }, None)
}
}
/// `program`'s variant for `features` (the stock shader when it replaces
/// nothing), `None` when it does not build or its layout differs.
fn build_variant(vm: &mut ScriptVm, program: ScriptObject, stock: DrawShaderId, features: Features, lit: bool) -> Option<DrawShaderId> {
let Some(obj) = variants::variant_object(vm, program, features, lit) else { return Some(stock) };
let variant = instance_custom(vm, obj).ok()?.draw_vars.draw_shader_id?;
if !variants::same_layout(vm.cx(), stock, variant) {
log!("render: a material's variant {features:?} has another layout than its shader; drawing through that");
return None;
}
Some(variant)
}
/// Build one program object on the lane and instance a draw struct from it.
fn instance_custom(vm: &mut ScriptVm, obj: ScriptObject) -> Result<DrawSceneCustom, MaterialError> {
// #[deref] construction applies DrawScenePbr's defaults and can already
// have compiled its stock shader: clear it before testing this candidate.
let mut draw = DrawSceneCustom::script_new(vm);
draw.draw_vars.draw_shader_id = None;
draw.draw_vars.geometry_id = None;
draw.script_apply(vm, &Apply::New, &mut Scope::empty(), obj.into());
if !draw.draw_vars.can_instance() {
let detail = makepad_draw::makepad_platform::shader_error::take().unwrap_or_else(|| "see the shader diagnostic in the app log".into());
return Err(MaterialError::new(format!("the material's shader did not build: {detail}")));
}
Ok(draw)
}
impl DrawSceneCustom {
/// A Splash material: `hooks` (checked against `mask`) on the kind the
/// description names, with every variant its plan derives. Errors carry
/// the author's source lines.
pub fn build(vm: &mut ScriptVm, desc: &MaterialDesc, hooks: &HookSet, mask: HookMask, params: Vec4f) -> Result<CustomMaterial, MaterialError> {
Self::build_with(vm, desc, hooks, mask, params, &[])
}
/// [`Self::build`] plus the author's own helper functions (called by the
/// hooks). A helper may add a name, never replace one the lane has:
/// that would reach past the hook set.
pub fn build_with(vm: &mut ScriptVm, desc: &MaterialDesc, hooks: &HookSet, mask: HookMask, params: Vec4f, helpers: &[(LiveId, ScriptObject)]) -> Result<CustomMaterial, MaterialError> {
if !matches!(desc.blend, Blend::Opaque | Blend::Mask { .. } | Blend::Over) {
return Err(MaterialError::new("the model lane draws Opaque, Mask and Over materials; Add, Multiply and Screen blend on the line and point lanes"));
}
register(vm);
let plan = material::plan(desc, hooks)?;
let base = vm.heap().type_default_for_id(Self::script_type_id_static())
.ok_or_else(|| MaterialError::new("custom material shader registration failed"))?;
let mut overrides = Vec::new();
for (name, f) in helpers {
let existing = vm.object_method(base, *name);
let taken = !(existing.is_nil() || existing.is_err())
|| material::HOOKS.iter().any(|h| LiveId::from_str(h.method) == *name);
if taken || !vm.is_fn(*f) {
return Err(MaterialError::new(format!("helper `{name}` would replace a function the lane owns; give it another name")));
}
overrides.push((*name, *f));
}
for b in &plan.builtins {
overrides.extend(builtin::overrides(vm, *b));
}
let obj = material::install_program(vm, &material::ProgramRequest { base, kind: desc.kind, hooks, mask, overrides: &overrides })?;
// Compiled once; the front end is asked for located errors only
// when that fails.
let mut draw = match instance_custom(vm, obj) {
Ok(draw) => draw,
Err(e) => return Err(material::diagnose(vm, obj, hooks).err().unwrap_or(e)),
};
draw.set_params(vm.cx(), params);
let cutoff = match desc.blend {
Blend::Mask { cutoff } => cutoff,
_ => 0.0,
};
match desc.blend {
Blend::Mask { cutoff } => {
draw.pbr.skinned.draw_vars.set_uniform(vm.cx(), live_id!(alpha_mode), &[1.0]);
draw.pbr.skinned.draw_vars.set_uniform(vm.cx(), live_id!(alpha_cutoff), &[cutoff]);
}
Blend::Over => {
draw.pbr.skinned.draw_vars.set_uniform(vm.cx(), live_id!(alpha_mode), &[2.0]);
draw.draw_vars.options.alpha_blend = true;
draw.draw_vars.options.depth_write = false;
}
_ => {}
}
let mut warnings = Vec::new();
let shadow = match plan.shadow {
ShadowVariant::Derived { vertex, mask } => match Self::build_shadow(vm, hooks, vertex, mask, &overrides[..helpers.len()]) {
Ok(id) => Some(id),
Err(e) => {
warnings.extend(e.diagnostics.into_iter().map(|mut d| {
d.message = format!("its shadow caster did not build, so it casts the stock shadow: {}", d.message);
d
}));
None
}
},
_ => None,
};
let ibl = plan.builtins.contains(&Builtin::Ibl);
let program = vm.new_object_ref(obj);
Ok(CustomMaterial { ibl, shadow, cutoff, bounds_pad: hooks.bounds_pad, warnings, ..CustomMaterial::new(draw, plan, Some(program)) })
}
fn build_shadow(vm: &mut ScriptVm, hooks: &HookSet, vertex: bool, mask: bool, helpers: &[(LiveId, ScriptObject)]) -> Result<DrawShaderId, MaterialError> {
let base = vm.heap().type_default_for_id(DrawMaterialShadow::script_type_id_static())
.ok_or_else(|| MaterialError::new("material shadow shader registration failed"))?;
let mut caster_hooks = HookSet::new();
// The author's helpers, where the caster has no function of that name.
let mut overrides: Vec<(LiveId, ScriptObject)> = helpers.iter().copied().filter(|(name, _)| {
let v = vm.object_method(base, *name);
v.is_nil() || v.is_err()
}).collect();
if vertex {
if let Some(f) = hooks.get(material::Hook::Vertex) { caster_hooks.set(material::Hook::Vertex, f); }
}
if mask {
if let Some(f) = hooks.get(material::Hook::Surface) { caster_hooks.set(material::Hook::Surface, f); }
if let Some(alpha) = vm.value(base, id!(mat_shadow_alpha_mask)).as_object() {
overrides.push((id!(mat_shadow_alpha), alpha));
}
}
let obj = material::build_program(vm, &material::ProgramRequest { base, kind: BaseKind::Pbr, hooks: &caster_hooks, mask: HookMask::ALL, overrides: &overrides })?;
let mut draw = DrawMaterialShadow::script_new(vm);
draw.draw_vars.draw_shader_id = None;
draw.draw_vars.geometry_id = None;
draw.script_apply(vm, &Apply::New, &mut Scope::empty(), obj.into());
draw.draw_vars.draw_shader_id.filter(|_| draw.draw_vars.can_instance())
.ok_or_else(|| MaterialError::new("the shadow caster's shader did not build"))
}
/// Unlit: the base colour lifted by `stops` of HDR intensity (params.w;
/// 0 = as authored), no light, shadow or reflection. An instance's own
/// params replace the material's, so a host keeps w for the intensity.
pub fn unlit(vm: &mut ScriptVm, hooks: &HookSet, mask: HookMask, stops: f32) -> Result<CustomMaterial, MaterialError> {
let desc = MaterialDesc { kind: BaseKind::Unlit, ..Default::default() };
Self::build(vm, &desc, hooks, mask, vec4(0.0, 0.0, 0.0, stops))
}
/// The error material: hatched magenta, unlit. What a material that did
/// not compile draws as in a preview.
pub fn error_material(vm: &mut ScriptVm) -> Result<CustomMaterial, MaterialError> {
register(vm);
let overrides = builtin::overrides(vm, Builtin::Error);
let base = vm.heap().type_default_for_id(Self::script_type_id_static())
.ok_or_else(|| MaterialError::new("custom material shader registration failed"))?;
let obj = material::build_program(vm, &material::ProgramRequest { base, kind: BaseKind::Unlit, hooks: &HookSet::new(), mask: HookMask::NONE, overrides: &overrides })?;
let draw = instance_custom(vm, obj)?;
let program = vm.new_object_ref(obj);
Ok(CustomMaterial::new(draw, VariantPlan { builtins: vec![Builtin::Error], can_discard: false, shadow: ShadowVariant::Stock }, Some(program)))
}
/// Sandbox's game.material: one albedo `surface(base: vec4) -> vec4`
/// (a host whose program lives in another VM than the Cx's sets the
/// material's `host_vm`).
pub fn from_surface(vm: &mut ScriptVm, surface: ScriptObject, params: Vec4f) -> Result<CustomMaterial, String> {
if !vm.is_fn(surface) { return Err("surface is not a shader function".into()); }
let hooks = HookSet::new().with(material::Hook::Surface, surface);
Self::build(vm, &MaterialDesc::default(), &hooks, HookMask::ALBEDO, params)
.map_err(|e| format!("surface shader failed frontend compilation: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_draw::makepad_platform::makepad_script::script_eval;
use makepad_render_material::{frontend_errors_for, Hook, ShaderBackend};
use makepad_scene::LightingModel;
fn with_vm(f: impl FnOnce(&mut ScriptVm)) {
let mut cx = Cx::new(Box::new(|_, _| {}));
cx.with_vm(|vm| {
vm.capture_errors();
makepad_draw::script_mod(vm);
vm.heap_mut().new_module(id!(prelude));
script_eval!(vm, { mod.prelude.widgets_internal = { ..mod.std, ..mod.pod, ..mod.math, ..mod.sdf, ..mod.shader, draw:mod.draw } });
vm.heap_mut().new_module(id!(widgets));
register(vm);
f(vm);
});
}
/// A material document's hooks, evaluated from Splash source under a
/// file name (so errors can name its lines).
fn spec(vm: &mut ScriptVm, file: &str, code: &str) -> ScriptObject {
let code = format!("use mod.prelude.widgets_internal.*\n{{\n{code}\n}}");
// Hosts evaluate documents with `line: 1`, so rows report 1-based.
let value = vm.eval(ScriptMod::new("", "", &file, 1, 0, &code, &[]));
value.as_object().unwrap_or_else(|| panic!("spec did not evaluate: {:?}", vm.take_errors()))
}
const EVERY_HOOK: &str = r#"
vertex: fn(p: vec3, n: vec3, uv: vec2) -> vec3 { return p + n * (0.05 * sin(p.y * 8.0 + self.mat_vtime())) }
surface: fn(base: vec4) -> vec4 { return vec4(base.xyz * vec3(1.0, 0.5, 0.2), base.w) }
emission: fn(e: vec3) -> vec3 { return e + vec3(self.params.x, 0.0, 0.0) }
normal: fn(n: vec3) -> vec3 { return normalize(n + vec3(0.0, 0.1, 0.0)) }
metal_rough: fn(mr: vec2) -> vec2 { return vec2(mr.x, clamp(mr.y * 0.5, 0.05, 1.0)) }
light: fn(radiance: vec3, l: vec3, n: vec3, v: vec3, brdf: vec3) -> vec3 { return radiance * step(0.3, dot(n, l)) }
lighting: fn(direct: vec3, ambient: vec3) -> vec3 { return direct * 0.8 + ambient }
finish: fn(c: vec4) -> vec4 { return vec4(c.xyz + vec3(0.02, 0.0, 0.0) * fract(self.mat_pos().x), c.w) }
bounds_pad: 0.05
"#;
#[test]
fn every_hook_compiles_on_every_backend_with_its_variants() {
with_vm(|vm| {
let obj = spec(vm, "test://every.splash", EVERY_HOOK);
let hooks = HookSet::from_spec(vm, obj, HookMask::ALL).unwrap();
assert_eq!(hooks.bounds_pad, 0.05);
for h in [Hook::Vertex, Hook::Surface, Hook::Emission, Hook::Normal, Hook::MetalRough, Hook::Light, Hook::Lighting, Hook::Finish] {
assert!(hooks.has(h), "{h:?}");
}
let desc = MaterialDesc { ibl: true, blend: Blend::Mask { cutoff: 0.4 }, ..Default::default() };
let m = DrawSceneCustom::build(vm, &desc, &hooks, HookMask::ALL, vec4(0.5, 0.0, 0.0, 0.0)).unwrap_or_else(|e| panic!("{e}"));
assert!(m.draw.draw_vars.can_instance());
assert_eq!(m.plan.builtins, vec![Builtin::HookedCompose, Builtin::Ibl]);
assert!(m.ibl && !m.cuts_no_pixel(vm.cx()), "a surface hook can cut pixels");
assert!(m.shadow.is_some(), "displaced, masked caster: {:?}", m.warnings);
assert_eq!((m.cutoff, m.alpha_mode(vm.cx()), m.bounds_pad), (0.4, 1.0, 0.05));
// The same program lowers for every backend, not only Metal.
let base = vm.heap().type_default_for_id(DrawSceneCustom::script_type_id_static()).unwrap();
let mut overrides = builtin::overrides(vm, Builtin::HookedCompose);
overrides.extend(builtin::overrides(vm, Builtin::Ibl));
let prog = material::build_program(vm, &material::ProgramRequest { base, kind: BaseKind::Pbr, hooks: &hooks, mask: HookMask::ALL, overrides: &overrides }).unwrap();
for backend in [ShaderBackend::Metal, ShaderBackend::Hlsl, ShaderBackend::Glsl, ShaderBackend::Wgsl] {
let errors = frontend_errors_for(vm, prog, backend);
assert!(errors.is_empty(), "{backend:?}: {errors:?}");
}
assert!(vm.take_errors().is_empty());
});
}
#[test]
fn a_plain_opaque_material_gets_a_discard_free_variant_with_the_stock_layout() {
with_vm(|vm| {
let obj = spec(vm, "test://finish.splash", "finish: fn(c: vec4) -> vec4 { return vec4(c.xyz * 0.5, c.w) }");
let hooks = HookSet::from_spec(vm, obj, HookMask::ALL).unwrap();
let m = DrawSceneCustom::build(vm, &MaterialDesc::default(), &hooks, HookMask::ALL, Vec4f::default()).unwrap();
assert!(m.cuts_no_pixel(vm.cx()), "no hook can cut");
let stock = m.stock_shader().unwrap();
let program = m.program.as_ref().unwrap().as_object();
// Every feature on but `clip`, and every feature off: each has
// the stock layout (build_variant checks it).
let all = Features { clip: false, gi: true, gi_debug: true, csm: true, csm_debug: true, cluster: true, local_shadows: true };
for features in [all, Features::default()] {
let variant = build_variant(vm, program, stock, features, true).expect("the stock layout");
assert_ne!(stock, variant);
}
assert_eq!(m.shadow, None, "no displacement, no mask: the stock caster");
assert!(m.plan.builtins.is_empty(), "no lighting hook: the stock composition");
});
}
#[test]
fn masks_arity_and_engine_owned_keys_are_refused_before_compiling() {
with_vm(|vm| {
let obj = spec(vm, "test://v.splash", "vertex: fn(p: vec3, n: vec3, uv: vec2) -> vec3 { return p }");
let e = HookSet::from_spec(vm, obj, HookMask::ALBEDO).unwrap_err();
assert!(e.to_string().contains("does not accept the `vertex` hook"), "{e}");
let obj = spec(vm, "test://p.splash", "pixel: fn() { return vec4(1.0) }");
let e = HookSet::from_spec(vm, obj, HookMask::ALL).unwrap_err();
assert!(e.to_string().contains("`pixel` is engine-owned"), "{e}");
let obj = spec(vm, "test://a.splash", "finish: fn(c: vec4, extra: float) -> vec4 { return c }");
let e = HookSet::from_spec(vm, obj, HookMask::ALL).unwrap_err();
assert!(e.to_string().contains("takes 1 parameter"), "{e}");
let obj = spec(vm, "test://u.splash", "lighting: fn(direct: vec3, ambient: vec3) -> vec3 { return direct }");
let hooks = HookSet::from_spec(vm, obj, HookMask::ALL).unwrap();
let desc = MaterialDesc { lighting: LightingModel::Unlit, ..Default::default() };
assert!(DrawSceneCustom::build(vm, &desc, &hooks, HookMask::ALL, Vec4f::default()).is_err());
let desc = MaterialDesc { blend: Blend::Add, ..Default::default() };
assert!(DrawSceneCustom::build(vm, &desc, &HookSet::new(), HookMask::ALL, Vec4f::default()).is_err());
});
}
#[test]
fn compile_errors_name_the_hook_and_the_document_line() {
with_vm(|vm| {
let obj = spec(vm, "test://broken.splash", "surface: fn(base: vec4) -> vec4 { return base }\nfinish: fn(c: vec4) -> vec4 {\n return no_such_function(c)\n}");
let hooks = HookSet::from_spec(vm, obj, HookMask::ALL).unwrap();
let e = match DrawSceneCustom::build(vm, &MaterialDesc::default(), &hooks, HookMask::ALL, Vec4f::default()) {
Err(e) => e,
Ok(_) => panic!("a call to an undefined function must not build"),
};
let d = e.diagnostics.iter().find(|d| d.file.is_some()).unwrap_or_else(|| panic!("no located diagnostic: {e}"));
assert_eq!(d.file.as_deref(), Some("test://broken.splash"), "{e}");
assert_eq!(d.line, 5, "the call's line (the spec opens with two wrapper lines): {e}");
assert_eq!(d.hook, Some(Hook::Finish), "{e}");
let _ = vm.take_errors();
});
}
#[test]
fn unlit_error_and_albedo_materials_build() {
with_vm(|vm| {
let unlit = DrawSceneCustom::unlit(vm, &HookSet::new(), HookMask::ALL, 2.0).unwrap();
assert_eq!(unlit.plan.builtins, vec![Builtin::Unlit]);
assert_eq!(unlit.draw.params(vm.cx()).w, 2.0);
let error = DrawSceneCustom::error_material(vm).unwrap();
assert!(error.draw.draw_vars.can_instance());
assert_ne!(error.draw.draw_vars.draw_shader_id, unlit.draw.draw_vars.draw_shader_id);
let obj = spec(vm, "test://albedo.splash", "surface: fn(base: vec4) -> vec4 { return vec4(base.xyz * 0.5, base.w) }");
let surface = HookSet::from_spec(vm, obj, HookMask::ALBEDO).unwrap().get(Hook::Surface).unwrap();
let albedo = DrawSceneCustom::from_surface(vm, surface, vec4(1.0, 2.0, 3.0, 4.0)).unwrap();
assert_eq!(albedo.draw.params(vm.cx()), vec4(1.0, 2.0, 3.0, 4.0));
let stock = DrawSceneCustom::script_new_with_default(vm).draw_vars.draw_shader_id;
assert_ne!(albedo.draw.draw_vars.draw_shader_id, stock);
// The unlit programs' variants drop the lighting with the
// features; the lit one keeps it.
assert!(!unlit.lit() && !error.lit() && albedo.lit());
for m in [&unlit, &error] {
let program = m.program.as_ref().unwrap().as_object();
let lit_on = Features { clip: true, csm: true, cluster: true, local_shadows: true, ..Default::default() };
assert!(build_variant(vm, program, m.stock_shader().unwrap(), lit_on, m.lit()).is_some_and(|v| Some(v) != m.stock_shader()));
}
assert!(vm.take_errors().is_empty());
});
}
}