From 21551847d8c53edf91e435ca98d39a03329c90e1 Mon Sep 17 00:00:00 2001 From: Baplar Date: Tue, 2 Jun 2026 08:13:02 +0200 Subject: [PATCH 1/5] Add support for non-unlit materials Texture and color always used if available. Alpha clip only used if requested by the original material. Simple diffuse lighting computed (on Unity it does not seem affected by normal maps, metallics or occlusion, so it seems to be enough). --- .../shaders/materials/billboardunlit.wgsl | 28 ++++- crates/renderide/src/materials.rs | 3 +- .../src/materials/render_buffer_shader.rs | 104 +++++++++++++++--- .../world_mesh_forward/material_batch.rs | 21 ++-- 4 files changed, 126 insertions(+), 30 deletions(-) diff --git a/crates/renderide/shaders/materials/billboardunlit.wgsl b/crates/renderide/shaders/materials/billboardunlit.wgsl index 402fe4bf4..8ade2d5c2 100644 --- a/crates/renderide/shaders/materials/billboardunlit.wgsl +++ b/crates/renderide/shaders/materials/billboardunlit.wgsl @@ -29,6 +29,7 @@ #import renderide::frame::fog as rfog #import renderide::draw::per_draw as pd #import renderide::draw::types as dt +#import renderide::lighting::diffuse as dl #import renderide::material::alpha as ma #import renderide::material::variant_bits as vb #import renderide::material::vertex_color as vc @@ -71,6 +72,7 @@ const BILLBOARDUNLIT_KW_VERTEX_LINEAR_COLOR: u32 = 1u << 15u; const BILLBOARDUNLIT_KW_VERTEX_SRGB_COLOR: u32 = 1u << 16u; const BILLBOARDUNLIT_KW_VERTEXCOLORS: u32 = 1u << 17u; const BILLBOARDUNLIT_KW_RENDER_BUFFER: u32 = 1u << 18u; +const BILLBOARDUNLIT_KW_SIMPLE_LIT: u32 = 1u << 19u; @group(1) @binding(0) var mat: BillboardUnlitMaterial; @group(1) @binding(1) var _Tex: texture_2d; @@ -152,12 +154,18 @@ fn kw_VERTEXCOLORS() -> bool { return bb_kw(BILLBOARDUNLIT_KW_VERTEXCOLORS); } +fn kw_SIMPLE_LIT() -> bool { + return bb_kw(BILLBOARDUNLIT_KW_SIMPLE_LIT); +} + struct VertexOutput { @builtin(position) clip_pos: vec4, @location(0) uv: vec2, @location(1) color: vec4, @location(2) @interpolate(flat) view_layer: u32, @location(3) fog_coord: f32, + @location(4) world_p: vec3, + @location(5) n: vec3, } struct RenderBufferBillboardBasis { @@ -354,6 +362,10 @@ fn vs_main( out.color = color; out.view_layer = layer; out.fog_coord = rfog::coord_from_world_pos(world_p, layer); + if (kw_SIMPLE_LIT()) { + out.world_p = world_p; + out.n = rmath::safe_normalize(cross(axes.right, axes.up), vec3(0.0, 0.0, 1.0)); + } return out; } @@ -412,9 +424,17 @@ fn vertex_color(color: vec4) -> vec4 { return color; } +fn two_sided_geometric_normal(world_n: vec3, front_facing: bool) -> vec3 { + let n = normalize(world_n); + return select(-n, n, front_facing); +} + //#pass type=forward name=forward_billboard blend=material_filter offset=0,0 @fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { +fn fs_main( + in: VertexOutput, + @builtin(front_facing) front_facing: bool, +) -> @location(0) vec4 { let use_texture = kw_TEXTURE(); let use_color = kw_COLOR(); @@ -463,5 +483,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { col = vec4(col.rgb, ma::alpha_intensity(col.a, col.rgb)); } + if (kw_SIMPLE_LIT()) { + let n = two_sided_geometric_normal(in.n, front_facing); + let lit = dl::shade_clustered_diffuse(in.clip_pos.xy, in.world_p, n, col.rgb, in.view_layer); + col = vec4(lit, col.a); + } + return rg::retain_globals_additive(rfog::apply_rgba(col, in.fog_coord)); } diff --git a/crates/renderide/src/materials.rs b/crates/renderide/src/materials.rs index 736b3cc2e..bdda97696 100644 --- a/crates/renderide/src/materials.rs +++ b/crates/renderide/src/materials.rs @@ -159,8 +159,7 @@ pub(crate) use pipeline_property_resolver::PipelinePropertyResolver; /// Shader route table, optional material asset registry, and WGSL composition patches. pub(crate) use registry::{MaterialPipelineResolution, MaterialRegistry}; pub(crate) use render_buffer_shader::{ - ensure_render_buffer_billboard_variant_bits, remap_unlit_variant_bits_for_billboard, - should_remap_unlit_variant_bits_for_billboard_draw, + ensure_render_buffer_billboard_variant_bits, remap_variant_bits_for_billboard, }; pub(crate) use router::{MaterialRouter, resolve_raster_pipeline}; diff --git a/crates/renderide/src/materials/render_buffer_shader.rs b/crates/renderide/src/materials/render_buffer_shader.rs index dbf29fa65..11bcc0241 100644 --- a/crates/renderide/src/materials/render_buffer_shader.rs +++ b/crates/renderide/src/materials/render_buffer_shader.rs @@ -7,21 +7,22 @@ pub(crate) const BILLBOARD_RENDER_BUFFER_ABSOLUTE_SIZE_BIT: u32 = 1u32 << 18; /// Billboard/Unlit variant bit that enables per-particle color and alpha. pub(crate) const BILLBOARD_RENDER_BUFFER_VERTEX_COLORS_BIT: u32 = 1u32 << 17; +/// Billboard/Unlit variant bit that enables texture sampling. +pub(crate) const BILLBOARD_RENDER_BUFFER_TEXTURE_BIT: u32 = 1u32 << 12; +/// Billboard/Unlit variant bit that enables base color. +pub(crate) const BILLBOARD_RENDER_BUFFER_COLOR_BIT: u32 = 1u32 << 1; +/// Billboard variant bit that enables simple lighting for non-unlit source materials. +pub(crate) const BILLBOARD_RENDER_BUFFER_SIMPLE_LIT_BIT: u32 = 1u32 << 19; -/// Returns whether `stem` names an embedded Unlit-family shader other than Billboard/Unlit. -pub(crate) fn is_unlit_family_embedded_stem(stem: &str) -> bool { - let lower = stem.to_ascii_lowercase(); - (lower.starts_with("unlit") || lower.contains("_unlit") || lower.contains("unlit_")) - && !lower.contains("billboard") -} - -/// Returns whether an embedded draw should remap Unlit keyword bits to Billboard/Unlit bits. -pub(crate) fn should_remap_unlit_variant_bits_for_billboard_draw( - draw_stem: &str, - source_shader_stem: Option<&str>, -) -> bool { - draw_stem.starts_with("billboardunlit") - && source_shader_stem.is_some_and(is_unlit_family_embedded_stem) +pub(crate) fn remap_variant_bits_for_billboard(stem: &str, source_bits: u32) -> u32 { + if is_unlit_family_embedded_stem(stem) { + remap_unlit_variant_bits_for_billboard(source_bits) + } else { + BILLBOARD_RENDER_BUFFER_TEXTURE_BIT + | BILLBOARD_RENDER_BUFFER_COLOR_BIT + | BILLBOARD_RENDER_BUFFER_SIMPLE_LIT_BIT + | map_billboard_alpha_clip_variant_bits(stem, source_bits) + } } /// Enables render-buffer sizing and particle color semantics for synthetic billboard draws. @@ -29,8 +30,15 @@ pub(crate) fn ensure_render_buffer_billboard_variant_bits(bits: u32) -> u32 { bits | BILLBOARD_RENDER_BUFFER_ABSOLUTE_SIZE_BIT | BILLBOARD_RENDER_BUFFER_VERTEX_COLORS_BIT } +/// Returns whether `stem` names an embedded Unlit-family shader other than Billboard/Unlit. +fn is_unlit_family_embedded_stem(stem: &str) -> bool { + let lower = stem.to_ascii_lowercase(); + (lower.starts_with("unlit") || lower.contains("_unlit") || lower.contains("unlit_")) + && !lower.contains("billboard") +} + /// Remaps Froox Unlit keyword bits to Billboard/Unlit keyword bits for material binding. -pub(crate) fn remap_unlit_variant_bits_for_billboard(unlit_bits: u32) -> u32 { +fn remap_unlit_variant_bits_for_billboard(unlit_bits: u32) -> u32 { const PAIRS: &[(u32, u32)] = &[ (0, 0), (1, 1), @@ -55,6 +63,43 @@ pub(crate) fn remap_unlit_variant_bits_for_billboard(unlit_bits: u32) -> u32 { out } +/// Remaps Froox AlphaClip or Cutoff keyword bits to Billboard/Unlit keyword bits +/// for material binding with non-Unlit materials. +fn map_billboard_alpha_clip_variant_bits(stem: &str, source_bits: u32) -> u32 { + let lower = stem.to_ascii_lowercase(); + const ALPHA_CLIP_ZERO: &[&str] = &["fresnel_", "pbsmultiuv", "reflection"]; + if ALPHA_CLIP_ZERO + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return source_bits & 1; + } + const ALPHA_CLIP_ONE: &[&str] = &[ + "pbsdisplace", + "pbsdualsided_", + "pbsdualsidedspecular_", + "pbslerp", + "pbsslice_", + "pbsslicespecular_", + "pbsvertexcolortransparent", + "xstoon", + ]; + if ALPHA_CLIP_ONE + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return (source_bits >> 1) & 1; + } + const ALPHA_CLIP_TWO: &[&str] = &["pbsmetallic", "pbsspecular"]; + if ALPHA_CLIP_TWO + .iter() + .any(|prefix| lower.starts_with(prefix)) + { + return (source_bits >> 2) & 1; + } + 0u32 +} + #[cfg(test)] mod tests { use super::*; @@ -69,13 +114,40 @@ mod tests { #[test] fn remaps_unlit_texture_and_color_bits() { let unlit = (1u32 << 1) | (1u32 << 9); - let billboard = remap_unlit_variant_bits_for_billboard(unlit); + let billboard = remap_variant_bits_for_billboard("unlit_default", unlit); assert_eq!(billboard & (1u32 << 1), 1u32 << 1); assert_eq!(billboard & (1u32 << 12), 1u32 << 12); assert_eq!(billboard & (1u32 << 11), 0); } + #[test] + fn remaps_pbs_alphaclip_bits() { + let pbs = (1u32 << 2) | (1u32 << 7); + let billboard = remap_variant_bits_for_billboard("pbsmetallic_default", pbs); + + assert_eq!( + billboard, + BILLBOARD_RENDER_BUFFER_COLOR_BIT + | BILLBOARD_RENDER_BUFFER_TEXTURE_BIT + | BILLBOARD_RENDER_BUFFER_SIMPLE_LIT_BIT + | 1u32 + ); + } + + #[test] + fn remaps_pbs_no_alphaclip_bits() { + let pbs = 1u32 << 7; + let billboard = remap_variant_bits_for_billboard("pbsmetallic_default", pbs); + + assert_eq!( + billboard, + BILLBOARD_RENDER_BUFFER_COLOR_BIT + | BILLBOARD_RENDER_BUFFER_TEXTURE_BIT + | BILLBOARD_RENDER_BUFFER_SIMPLE_LIT_BIT + ); + } + #[test] fn render_buffer_variant_bit_is_reserved() { assert_eq!( diff --git a/crates/renderide/src/passes/world_mesh_forward/material_batch.rs b/crates/renderide/src/passes/world_mesh_forward/material_batch.rs index aa46b3c47..7fe2326d9 100644 --- a/crates/renderide/src/passes/world_mesh_forward/material_batch.rs +++ b/crates/renderide/src/passes/world_mesh_forward/material_batch.rs @@ -23,8 +23,7 @@ use crate::materials::{ MaterialBlendMode, MaterialPipelineDesc, MaterialPipelineResolution, MaterialPipelineSet, MaterialPipelineVariantSpec, MaterialRegistry, MaterialRenderState, MaterialShaderSpecializationKey, RasterFrontFace, RasterPipelineKind, RasterPrimitiveTopology, - ensure_render_buffer_billboard_variant_bits, remap_unlit_variant_bits_for_billboard, - should_remap_unlit_variant_bits_for_billboard_draw, + ensure_render_buffer_billboard_variant_bits, remap_variant_bits_for_billboard, }; use crate::passes::WorldMeshForwardEncodeRefs; use crate::render_graph::frame_upload_batch::GraphUploadSink; @@ -483,17 +482,17 @@ impl<'a> MaterialDrawResolver<'a> { let source_bits = self .registry .and_then(|registry| registry.variant_bits_for_shader_asset(batch_key.shader_asset_id)); - let mut bits = source_bits.unwrap_or(0); - if should_remap_unlit_variant_bits_for_billboard_draw( - stem, - self.registry - .and_then(|registry| registry.stem_for_shader_asset(batch_key.shader_asset_id)), - ) { - bits = remap_unlit_variant_bits_for_billboard(bits); + if !stem.starts_with("billboardunlit") { + return source_bits; } - if crate::particles::is_generated_billboard_mesh_asset_id(item.mesh_asset_id) - && stem.starts_with("billboardunlit") + let mut bits = source_bits.unwrap_or(0); + if let Some(source_stem) = self + .registry + .and_then(|registry| registry.stem_for_shader_asset(batch_key.shader_asset_id)) { + bits = remap_variant_bits_for_billboard(source_stem, bits); + } + if crate::particles::is_generated_billboard_mesh_asset_id(item.mesh_asset_id) { Some(ensure_render_buffer_billboard_variant_bits(bits)) } else { source_bits.map(|_| bits) From 9e17e15a1b644f1e70a24431712fcf05126c9148 Mon Sep 17 00:00:00 2001 From: Baplar Date: Tue, 2 Jun 2026 10:57:33 +0200 Subject: [PATCH 2/5] Only set vertex color mode on materials supporting it The only remaining issue is that AlphaBlend is not respected on PBSVertexColor --- .../shaders/materials/billboardunlit.wgsl | 3 +- .../src/materials/render_buffer_shader.rs | 29 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/renderide/shaders/materials/billboardunlit.wgsl b/crates/renderide/shaders/materials/billboardunlit.wgsl index 8ade2d5c2..91506e1e0 100644 --- a/crates/renderide/shaders/materials/billboardunlit.wgsl +++ b/crates/renderide/shaders/materials/billboardunlit.wgsl @@ -485,7 +485,8 @@ fn fs_main( if (kw_SIMPLE_LIT()) { let n = two_sided_geometric_normal(in.n, front_facing); - let lit = dl::shade_clustered_diffuse(in.clip_pos.xy, in.world_p, n, col.rgb, in.view_layer); + let base = saturate(col.rgb); + let lit = dl::shade_clustered_diffuse(in.clip_pos.xy, in.world_p, n, base, in.view_layer); col = vec4(lit, col.a); } diff --git a/crates/renderide/src/materials/render_buffer_shader.rs b/crates/renderide/src/materials/render_buffer_shader.rs index 11bcc0241..0ca605dbc 100644 --- a/crates/renderide/src/materials/render_buffer_shader.rs +++ b/crates/renderide/src/materials/render_buffer_shader.rs @@ -16,18 +16,20 @@ pub(crate) const BILLBOARD_RENDER_BUFFER_SIMPLE_LIT_BIT: u32 = 1u32 << 19; pub(crate) fn remap_variant_bits_for_billboard(stem: &str, source_bits: u32) -> u32 { if is_unlit_family_embedded_stem(stem) { - remap_unlit_variant_bits_for_billboard(source_bits) + BILLBOARD_RENDER_BUFFER_VERTEX_COLORS_BIT + | remap_unlit_variant_bits_for_billboard(source_bits) } else { BILLBOARD_RENDER_BUFFER_TEXTURE_BIT | BILLBOARD_RENDER_BUFFER_COLOR_BIT | BILLBOARD_RENDER_BUFFER_SIMPLE_LIT_BIT + | map_billboard_vertex_color_variant_bits(stem, source_bits) | map_billboard_alpha_clip_variant_bits(stem, source_bits) } } /// Enables render-buffer sizing and particle color semantics for synthetic billboard draws. pub(crate) fn ensure_render_buffer_billboard_variant_bits(bits: u32) -> u32 { - bits | BILLBOARD_RENDER_BUFFER_ABSOLUTE_SIZE_BIT | BILLBOARD_RENDER_BUFFER_VERTEX_COLORS_BIT + bits | BILLBOARD_RENDER_BUFFER_ABSOLUTE_SIZE_BIT } /// Returns whether `stem` names an embedded Unlit-family shader other than Billboard/Unlit. @@ -81,7 +83,7 @@ fn map_billboard_alpha_clip_variant_bits(stem: &str, source_bits: u32) -> u32 { "pbslerp", "pbsslice_", "pbsslicespecular_", - "pbsvertexcolortransparent", + "pbsvertexcolor", "xstoon", ]; if ALPHA_CLIP_ONE @@ -100,6 +102,25 @@ fn map_billboard_alpha_clip_variant_bits(stem: &str, source_bits: u32) -> u32 { 0u32 } +/// Remaps Froox Vertex Colors keyword bits to Billboard/Unlit keyword bits +/// for material binding with non-Unlit materials. +fn map_billboard_vertex_color_variant_bits(stem: &str, source_bits: u32) -> u32 { + let lower = stem.to_ascii_lowercase(); + if lower.starts_with("fresnel_") { + return ((source_bits >> 10) & 1) << 17; + } + if lower.starts_with("pbsdualsidedtransparent") { + return ((source_bits >> 5) & 1) << 17; + } + if lower.starts_with("pbsvertexcolor") || lower.starts_with("pbsdualsided") { + return ((source_bits >> 6) & 1) << 17; + } + if lower.starts_with("xstoon") { + return ((source_bits >> 8) & 1) << 17; + } + 0u32 +} + #[cfg(test)] mod tests { use super::*; @@ -152,7 +173,7 @@ mod tests { fn render_buffer_variant_bit_is_reserved() { assert_eq!( ensure_render_buffer_billboard_variant_bits(0), - BILLBOARD_RENDER_BUFFER_ABSOLUTE_SIZE_BIT | BILLBOARD_RENDER_BUFFER_VERTEX_COLORS_BIT + BILLBOARD_RENDER_BUFFER_ABSOLUTE_SIZE_BIT ); } } From a38c3aa3ada3bdf035da528ef2c089121e132501 Mon Sep 17 00:00:00 2001 From: Baplar Date: Tue, 2 Jun 2026 17:09:42 +0200 Subject: [PATCH 3/5] Fix transparency fallback used for billboard material conversion --- crates/renderide/src/world_mesh/materials/resolve.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/renderide/src/world_mesh/materials/resolve.rs b/crates/renderide/src/world_mesh/materials/resolve.rs index be1279105..a23a7df29 100644 --- a/crates/renderide/src/world_mesh/materials/resolve.rs +++ b/crates/renderide/src/world_mesh/materials/resolve.rs @@ -388,6 +388,12 @@ pub(crate) fn apply_render_buffer_mesh_pipeline_override( uses_blended_depth_write: features.uses_blended_depth_write, uses_two_sided_transparency: features.uses_two_sided_transparency, }); + if batch_key.blend_mode == MaterialBlendMode::StemDefault + && batch_key.transparent_class == TransparentMaterialClass::OrderedAlpha + { + // Enforce transparent blend mode to match source material transparency + batch_key.blend_mode = MaterialBlendMode::UnityBlend { src: 5, dst: 10 } + } } /// Assembles a [`MaterialDrawBatchKey`] from a pre-resolved [`ResolvedMaterialBatch`] entry. From a6112edf3c576c597cc2287a5b700420f71ccae0 Mon Sep 17 00:00:00 2001 From: Baplar Date: Tue, 2 Jun 2026 19:42:08 +0200 Subject: [PATCH 4/5] Force cutout variant bit for xiexe cutout stems --- .../src/materials/render_buffer_shader.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/renderide/src/materials/render_buffer_shader.rs b/crates/renderide/src/materials/render_buffer_shader.rs index 0ca605dbc..741d970b1 100644 --- a/crates/renderide/src/materials/render_buffer_shader.rs +++ b/crates/renderide/src/materials/render_buffer_shader.rs @@ -69,12 +69,15 @@ fn remap_unlit_variant_bits_for_billboard(unlit_bits: u32) -> u32 { /// for material binding with non-Unlit materials. fn map_billboard_alpha_clip_variant_bits(stem: &str, source_bits: u32) -> u32 { let lower = stem.to_ascii_lowercase(); - const ALPHA_CLIP_ZERO: &[&str] = &["fresnel_", "pbsmultiuv", "reflection"]; - if ALPHA_CLIP_ZERO + if lower.starts_with("xstoon2.0-cutout") { + return 1; + } + const ALPHA_CLIP_TWO: &[&str] = &["pbsmetallic", "pbsspecular"]; + if ALPHA_CLIP_TWO .iter() .any(|prefix| lower.starts_with(prefix)) { - return source_bits & 1; + return (source_bits >> 2) & 1; } const ALPHA_CLIP_ONE: &[&str] = &[ "pbsdisplace", @@ -92,12 +95,12 @@ fn map_billboard_alpha_clip_variant_bits(stem: &str, source_bits: u32) -> u32 { { return (source_bits >> 1) & 1; } - const ALPHA_CLIP_TWO: &[&str] = &["pbsmetallic", "pbsspecular"]; - if ALPHA_CLIP_TWO + const ALPHA_CLIP_ZERO: &[&str] = &["fresnel_", "pbsmultiuv", "reflection"]; + if ALPHA_CLIP_ZERO .iter() .any(|prefix| lower.starts_with(prefix)) { - return (source_bits >> 2) & 1; + return source_bits & 1; } 0u32 } From 08f9420fb2279f3e09932458ff133a30d3b5b2d8 Mon Sep 17 00:00:00 2001 From: Baplar Date: Wed, 3 Jun 2026 20:53:07 +0200 Subject: [PATCH 5/5] Add assertions for simple lit shading --- crates/renderide/tests/shader_module_audit.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/renderide/tests/shader_module_audit.rs b/crates/renderide/tests/shader_module_audit.rs index 2cbbded04..167b57921 100644 --- a/crates/renderide/tests/shader_module_audit.rs +++ b/crates/renderide/tests/shader_module_audit.rs @@ -405,8 +405,12 @@ fn billboard_render_buffer_uses_indexed_corner_separate_from_sample_uv() -> io:: ); } assert!( - src.contains("const BILLBOARDUNLIT_KW_UNLIT_MASK_TEXTURE_CLIP: u32 = 1u << 17u;") - && src.contains("const BILLBOARDUNLIT_KW_UNLIT_MASK_TEXTURE_MUL: u32 = 1u << 18u;"), + src.contains("const BILLBOARDUNLIT_KW_SIMPLE_LIT: u32 = 1u << 17u;"), + "Non-Unlit shading support for render-buffer billboards must use compatibility bit after native Billboard/Unlit keywords" + ); + assert!( + src.contains("const BILLBOARDUNLIT_KW_UNLIT_MASK_TEXTURE_CLIP: u32 = 1u << 18u;") + && src.contains("const BILLBOARDUNLIT_KW_UNLIT_MASK_TEXTURE_MUL: u32 = 1u << 19u;"), "Unlit mask support for render-buffer billboards must use compatibility bits after native Billboard/Unlit keywords" ); assert!( @@ -440,6 +444,12 @@ fn billboard_render_buffer_uses_indexed_corner_separate_from_sample_uv() -> io:: && src.contains("rfog::apply_rgba(col, in.fog_coord)"), "Billboard/Unlit must preserve the source-authored UNITY_APPLY_FOG hook" ); + assert!( + src.contains("if (kw_SIMPLE_LIT())") + && src.contains("out.n = ") + && src.contains("dl::shade_clustered_diffuse"), + "Billboard/Unlit must offer simple shading capabilities for non-Unlit source materials" + ); Ok(()) }