From 6e30d77166cab4051b87f2fefab94e3e1a636ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 15:21:56 +0100 Subject: [PATCH 1/3] renderer: a mobile scene shading, the default on Android On mobile GPUs the scene shader's shade_main_scene is replaced by shaders/scene_mobile.wgsl, a lighter forward base pass like Unreal's mobile renderer and Unity URP have. Same inputs, outputs, lights, shadows and textures. Left out: the normal-variance roughness (Toksvig / LEADR / screen-space kernel), coverage-dithered alpha masks (a plain alpha test), cloud shadows, the specular firefly caps and the spec-gloss conversion for the SSR buffers; the BRDF LUT becomes Karis's analytic environment BRDF (Unreal mobile's), keeping multi-scatter compensation and specular occlusion. The sun shadow takes the 4-tap tent alone, chosen when the shader is built: the 16-tap Poisson code behind the runtime filter switch cost 10 ms of a 36 ms main pass by being compiled in. Applied to the main scene shader before its froxel and VSM specializations (the mobile function keeps their markers), so the scene pipelines, the prepassed variant and the GPU-driven variant shade with it; the layered shaders keep the full shading. On by default on Android; BLOOM_MOBILE_SHADING=0|1 overrides it. Pixel 4a (Adreno 618), tuichess at 0.5 render scale: main pass 33 -> 26 ms; the image differs from the full shading by 1.5/255 on average. --- native/shared/src/renderer/mobile_shading.rs | 131 ++++++++++++ native/shared/src/renderer/mod.rs | 10 +- .../src/renderer/shaders/scene_mobile.wgsl | 192 ++++++++++++++++++ 3 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 native/shared/src/renderer/mobile_shading.rs create mode 100644 native/shared/src/renderer/shaders/scene_mobile.wgsl diff --git a/native/shared/src/renderer/mobile_shading.rs b/native/shared/src/renderer/mobile_shading.rs new file mode 100644 index 00000000..ea4be0dd --- /dev/null +++ b/native/shared/src/renderer/mobile_shading.rs @@ -0,0 +1,131 @@ +//! The mobile scene shading: on mobile GPUs the scene shader's +//! `shade_main_scene` is replaced by `shaders/scene_mobile.wgsl`, a lighter +//! forward base pass (as Unreal's mobile renderer and Unity URP have). Same +//! inputs, outputs, lights and shadows (the sun's with the 4-tap tent +//! filter only, chosen when the shader is built); the full shader's normal-variance +//! roughness, BRDF LUT (an analytic fit instead), coverage-dithered masks, cloud +//! shadows and firefly caps are left out. Those cost a mobile GPU most of the +//! main pass through register pressure, not arithmetic (Pixel 4a, Adreno +//! 618). +//! +//! Applied to the main scene shader before its froxel and virtual-shadow +//! specializations (the mobile function keeps their markers), so the scene +//! pipelines, their prepassed variant and the GPU-driven variant all shade +//! with it. The layered-material and layered-refraction shaders keep the full +//! shading. + +use std::borrow::Cow; + +const MOBILE_SHADE_MAIN_SCENE: &str = include_str!("shaders/scene_mobile.wgsl"); +const SHADE_MAIN_SCENE: &str = + "fn shade_main_scene(in: VertexOutputScene, front_facing: bool) -> SceneOut {"; +const AFTER_SHADE_MAIN_SCENE: &str = "@fragment\nfn fs_main_scene("; +const SAMPLE_CASCADE: &str = + "fn sample_cascade(cascade: i32, shadow_uv: vec2, depth_ref: f32) -> f32 {"; +const MOBILE_SAMPLE_CASCADE: &str = + "fn sample_cascade(cascade: i32, shadow_uv: vec2, depth_ref: f32) -> f32 { + let texel = 1.0 / vec2(textureDimensions(shadow_tex)); + return pcf_tent4(cascade, shadow_uv, texel, depth_ref) * 0.25; +} +"; + +/// Mobile shading on Android by default; `BLOOM_MOBILE_SHADING=0|1` overrides +/// it (to compare the two on a device, or to try the mobile shading on a +/// desktop GPU). +pub(super) fn mobile_shading_enabled() -> bool { + match std::env::var("BLOOM_MOBILE_SHADING").as_deref() { + Ok("1") => true, + Ok("0") => false, + _ => cfg!(target_os = "android"), + } +} + +/// `source` with the mobile `shade_main_scene` in place of the full one, when +/// mobile shading is on. +pub(super) fn scene_shader(source: &'static str) -> Cow<'static, str> { + if !mobile_shading_enabled() { + return source.into(); + } + with_mobile_shading(source).into() +} + +fn with_mobile_shading(source: &str) -> String { + let start = source + .find(SHADE_MAIN_SCENE) + .expect("scene shader missing shade_main_scene"); + let end = start + + source[start..] + .find(AFTER_SHADE_MAIN_SCENE) + .expect("scene shader missing fs_main_scene after shade_main_scene"); + let source = format!( + "{}{}{}", + &source[..start], + MOBILE_SHADE_MAIN_SCENE, + &source[end..] + ); + // The sun shadow takes the 4-tap tent only. The 16-tap Poisson disc + // behind the runtime filter switch costs a mobile GPU even when it never + // runs (its code is the pass's register budget): 10 ms of a 36 ms main + // pass on a Pixel 4a. + let start = source + .find(SAMPLE_CASCADE) + .expect("scene shader missing sample_cascade"); + let end = start + + source[start..] + .find("\n}\n") + .expect("sample_cascade without an end") + + "\n}\n".len(); + format!( + "{}{}{}", + &source[..start], + MOBILE_SAMPLE_CASCADE, + &source[end..] + ) +} + +#[cfg(test)] +mod tests { + use super::super::shaders::SCENE_SHADER; + use super::*; + + #[test] + fn mobile_scene_shader_validates_and_replaces_the_full_shading() { + let mobile = with_mobile_shading(SCENE_SHADER); + let module = wgpu::naga::front::wgsl::parse_str(&mobile) + .unwrap_or_else(|error| panic!("mobile scene WGSL failed: {error:?}")); + wgpu::naga::valid::Validator::new( + wgpu::naga::valid::ValidationFlags::all(), + wgpu::naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap_or_else(|error| panic!("mobile scene WGSL invalid: {error:?}")); + assert_eq!(mobile.matches(SHADE_MAIN_SCENE).count(), 1); + // the full shading's LUT and variance terms are gone + assert!(!mobile.contains("brdf_lut_tex, brdf_lut_samp, vec2(n_dot_v_ibl")); + assert!(!mobile.contains("sigma2_toksvig")); + // the shadow filter is the tent alone + assert!(!mobile.contains("pcf_poisson16(cascade")); + assert_eq!(mobile.matches(SAMPLE_CASCADE).count(), 1); + // the markers the prepass, froxel and VSM variants rely on + for marker in [ + "//PREPASS_STRIP_BEGIN", + "//PREPASS_STRIP_END", + "// BEGIN-POINT-LIGHT-LOOP", + "// END-POINT-LIGHT-LOOP", + "pl.color.w * atten2", + ] { + assert_eq!(mobile.matches(marker).count(), 1, "{marker}"); + } + } + + #[test] + fn mobile_scene_shader_takes_the_froxel_and_gpu_driven_variants() { + let mobile = with_mobile_shading(SCENE_SHADER); + let clustered = super::super::froxel::clustered_scene_shader(&mobile); + wgpu::naga::front::wgsl::parse_str(&clustered) + .unwrap_or_else(|error| panic!("clustered mobile WGSL failed: {error:?}")); + let gpu = super::super::gpu_driven::make_gpu_scene_shader(&mobile); + wgpu::naga::front::wgsl::parse_str(&gpu) + .unwrap_or_else(|error| panic!("GPU-driven mobile WGSL failed: {error:?}")); + } +} diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 45337402..1102fda5 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -84,8 +84,9 @@ mod lighting_upload; mod material_api; pub mod material_indirection; mod material_instancing; -mod model_draw; #[cfg(feature = "models3d")] +mod mobile_shading; +mod model_draw; mod model_material; mod occlusion; mod opaque_material_pass; @@ -3452,8 +3453,11 @@ impl Renderer { // SH-055 diag — "ibl" in BLOOM_SKIP swaps the split-sum IBL chain // (equirect env samples, BRDF LUT, multi-scatter math) for a flat // ambient, to measure its per-fragment cost. Markers in core.rs. - let scene_shader_source = - specialized_scene_shader_source(froxel.is_some(), shadow_map.virtual_map.requested()); + let scene_shader_source = specialized_scene_shader_source_from( + mobile_shading::scene_shader(SCENE_SHADER), + froxel.is_some(), + shadow_map.virtual_map.requested(), + ); // SH-055 — a second module for the PREPASSED pipeline with the cutout // `discard` stripped (between the //PREPASS_STRIP markers in core.rs). // The prepassed pipeline runs depth_write=false + Equal against the diff --git a/native/shared/src/renderer/shaders/scene_mobile.wgsl b/native/shared/src/renderer/shaders/scene_mobile.wgsl new file mode 100644 index 00000000..4ffb6d33 --- /dev/null +++ b/native/shared/src/renderer/shaders/scene_mobile.wgsl @@ -0,0 +1,192 @@ +// The mobile scene shading (renderer/mobile_shading.rs): replaces +// shade_main_scene in the scene shader on mobile GPUs, the way Unreal's mobile +// renderer and Unity URP have their own forward base pass. Same inputs, +// outputs and lights; dropped: normal-map variance and curvature roughness +// (Toksvig / LEADR / screen-space kernel), the BRDF LUT (Karis's analytic +// environment BRDF instead, as Unreal mobile), +// coverage-dithered alpha masks (a plain alpha test), cloud shadows and the +// specular firefly caps. The texture reads are the canonical ones, so the +// GPU-driven variant rewrites them like the full shader's. +fn shade_main_scene(in: VertexOutputScene, front_facing: bool) -> SceneOut { + var n = normalize(in.normal); + let base_uv = material_uv( + in.uv, material.uv_transforms[0], material.uv_transforms[1], + ); + let normal_uv = material_uv( + in.uv, material.uv_transforms[2], material.uv_transforms[3], + ); + let mr_uv = material_uv( + in.uv, material.uv_transforms[4], material.uv_transforms[5], + ); + let emissive_uv = material_uv( + in.uv, material.uv_transforms[6], material.uv_transforms[7], + ); + let occlusion_uv = material_uv( + in.uv, material.uv_transforms[8], material.uv_transforms[9], + ); + + let lod_bias = lighting.shadow_cascade_splits.w; + let nm_sample4 = textureSampleBias(normal_tex, normal_samp, normal_uv, 0.25 + lod_bias); + let nm_sample = normalize((nm_sample4.xyz * 2.0 - 1.0) * vec3( + material.uv_transforms[2].w, + material.uv_transforms[3].w, + 1.0, + )); + if (dot(in.tangent.xyz, in.tangent.xyz) > 0.0001) { + let t = normalize(in.tangent.xyz); + let t_ortho = normalize(t - n * dot(n, t)); + let b = cross(n, t_ortho) * in.tangent.w; + n = normalize(t_ortho * nm_sample.x + b * nm_sample.y + n * nm_sample.z); + } else { + let tbn = compute_tbn( + dpdx(in.world_pos), dpdy(in.world_pos), dpdx(normal_uv), dpdy(normal_uv), n, + ); + n = normalize(tbn * nm_sample); + } + + let base_tex = textureSampleBias(base_color_tex, base_color_samp, base_uv, lod_bias); + let base_color = srgb_to_linear_v(base_tex.rgb) * in.color.rgb; + let base_alpha = base_tex.a * in.color.a; + let alpha_cutoff = material.metal_rough.w; + //PREPASS_STRIP_BEGIN — removed in the prepassed-pipeline variant. + if (alpha_cutoff > 0.0 && base_alpha < alpha_cutoff) { + discard; + } + //PREPASS_STRIP_END + if (alpha_cutoff > 0.0 && dot(n, lighting.camera_pos.xyz - in.world_pos) < 0.0) { + n = -n; + } + if (alpha_cutoff < 0.0 && !front_facing) { + n = -n; + } + + let mr_tex_sample = textureSample(mr_tex, mr_samp, mr_uv); + let has_mr = material.metal_rough.z > 0.5 && material.metal_rough.z < 1.5; + let has_spec_gloss = material.metal_rough.z > 1.5; + let metallic = select( + select( + clamp(material.metal_rough.x, 0.0, 1.0), + clamp(mr_tex_sample.b * material.metal_rough.x, 0.0, 1.0), + has_mr, + ), + 0.0, + has_spec_gloss, + ); + let roughness_raw = select( + select( + clamp(material.metal_rough.y, 0.045, 1.0), + clamp(mr_tex_sample.g * material.metal_rough.y, 0.045, 1.0), + has_mr, + ), + clamp(1.0 - mr_tex_sample.a * material.spec_gloss.a, 0.045, 1.0), + has_spec_gloss, + ); + let roughness = max(roughness_raw, select(0.15, 0.045, has_spec_gloss) * (1.0 - metallic)); + // The lights' view of either workflow (see shade_pbr_f0). + let authored_specular = srgb_to_linear_v(mr_tex_sample.rgb) * material.spec_gloss.rgb; + let light_diffuse = select(base_color * (1.0 - metallic), base_color, has_spec_gloss); + let light_f0 = select( + mix(vec3(0.04), base_color, metallic), + authored_specular, + has_spec_gloss, + ); + + let em_tex_sample = textureSample(em_tex, em_samp, emissive_uv); + let emissive = srgb_to_linear_v(em_tex_sample.rgb) * material.emissive.rgb; + let sampled_occlusion = textureSample(occ_tex, occ_samp, occlusion_uv).r; + let occlusion = mix(1.0, sampled_occlusion, material.uv_transforms[8].w); + + // --- Direct lighting --- + let v = normalize(lighting.camera_pos.xyz - in.world_pos); + var lit = lighting.ambient.rgb * lighting.ambient.a * base_color; + let shadow_factor = sample_shadow(in.world_pos, normalize(in.normal), in.clip_position.xy); + let direct_shadow = mix(0.03, 1.0, shadow_factor); + let legacy_dir = normalize(lighting.light_dir.xyz); + if (alpha_cutoff > 0.0) { + // Foliage: wrap diffuse plus transmission, no specular. + let wrap = 0.45; + let ndl_wrap = clamp((dot(n, legacy_dir) + wrap) / ((1.0 + wrap) * (1.0 + wrap)), 0.0, 1.0); + let trans = pow(max(dot(v, -legacy_dir), 0.0), 3.0) * 0.85; + lit += base_color * lighting.light_color.rgb * lighting.light_dir.w + * (ndl_wrap / PI + trans) * direct_shadow; + } else { + lit += shade_pbr_f0(n, v, legacy_dir, lighting.light_color.rgb, + lighting.light_dir.w, light_diffuse, light_f0, roughness) + * direct_shadow; + } + let dir_count = u32(lighting.dir_light_count.x); + for (var i = 0u; i < dir_count; i++) { + let dl = lighting.dir_lights[i]; + let l = normalize(dl.direction.xyz); + lit += shade_pbr_f0(n, v, l, dl.color.rgb, dl.direction.w, + light_diffuse, light_f0, roughness); + } + // BEGIN-POINT-LIGHT-LOOP (the froxel-clustered variant replaces this + // loop, as in the full shader) + let pt_count = u32(lighting.point_light_count.x); + for (var i = 0u; i < pt_count; i++) { + let pl = lighting.point_lights[i]; + let to_light = pl.position.xyz - in.world_pos; + let dist = length(to_light); + let range = pl.position.w; + if (dist < range && dist > 0.0) { + let l = to_light / dist; + let atten = 1.0 - (dist / range); + let atten2 = atten * atten; + lit += shade_pbr_f0(n, v, l, pl.color.rgb, pl.color.w * atten2, + light_diffuse, light_f0, roughness); + } + } + // END-POINT-LIGHT-LOOP + + // --- Image-based lighting --- + // Karis's analytic fit of the split-sum environment BRDF ("Physically + // Based Shading on Mobile", the one Unreal's mobile renderer uses) in + // place of the BRDF LUT. + let n_dot_v = max(dot(n, v), 0.0); + let c0 = vec4(-1.0, -0.0275, -0.572, 0.022); + let c1 = vec4(1.0, 0.0425, 1.04, -0.04); + let rc = roughness * c0 + c1; + let a004 = min(rc.x * rc.x, exp2(-9.28 * n_dot_v)) * rc.x + rc.y; + let env_ab = vec2(-1.04, 1.04) * a004 + rc.zw; + let env_brdf = light_f0 * env_ab.x + vec3(env_ab.y); + // Multi-scatter compensation from the fit's energy, as the full shader + // does from the LUT's (Fdez-Aguera 2019). + let ess = env_ab.x + env_ab.y; + let f_avg = light_f0 + (vec3(1.0) - light_f0) * (1.0 / 21.0); + let ms = f_avg * ess / (vec3(1.0) - f_avg * (1.0 - ess)) * (1.0 - ess); + + let irradiance = textureSampleLevel( + env_diffuse_tex, env_samp, seamless_equirect_uv(dir_to_equirect_uv(n)), 0.0, + ).rgb * lighting.camera_pos.w; + let ibl_diffuse = irradiance * light_diffuse * (vec3(1.0) - env_brdf) * occlusion; + let max_spec_mip = max(f32(textureNumLevels(env_tex)) - 1.0, 0.0); + let prefiltered_env = env_sample_lod(reflect(-v, n), roughness * max_spec_mip); + // SSR owns the smooth lobes when it runs (dir_light_count.z), as in the + // full shader. + let ssr_own = clamp( + lighting.dir_light_count.z * (1.0 - smoothstep(0.45, 0.70, roughness)), + 0.0, 1.0); + let spec_occ = clamp( + pow(n_dot_v + occlusion, exp2(-16.0 * roughness - 1.0)) - 1.0 + occlusion, + 0.0, 1.0, + ); + let ibl_spec = prefiltered_env * (env_brdf + ms) * spec_occ * (1.0 - ssr_own); + let indirect_shadow = mix(0.35, 1.0, shadow_factor); + + let hdr_raw = lit + (ibl_diffuse + ibl_spec) * indirect_shadow + emissive; + let hdr = select(vec3(0.0), hdr_raw, hdr_raw == hdr_raw); + + let curr_ndc = in.curr_clip.xy / in.curr_clip.w; + let prev_ndc = in.prev_clip.xy / in.prev_clip.w; + let vel = (curr_ndc - prev_ndc) * 0.5; + let non_opaque_alpha = select(base_alpha, 1.0, alpha_cutoff > 0.0); + let out_alpha = select(in.color.a, non_opaque_alpha, alpha_cutoff != 0.0); + return SceneOut( + vec4(hdr, out_alpha), + vec2(metallic, roughness), + vel, + vec4(base_color, 1.0 - shadow_factor), + ); +} + From 70c0567c710f2ddd3d92cbf96ab2b547dc42601b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 16:11:28 +0100 Subject: [PATCH 2/3] mobile_shading: declare the module unconditionally (model_material keeps its models3d gate) --- native/shared/src/renderer/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 1102fda5..d13fd1d1 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -84,9 +84,9 @@ mod lighting_upload; mod material_api; pub mod material_indirection; mod material_instancing; -#[cfg(feature = "models3d")] mod mobile_shading; mod model_draw; +#[cfg(feature = "models3d")] mod model_material; mod occlusion; mod opaque_material_pass; From 66168bae1cf372fa304223ffddb016b2aa3a8232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 16:14:53 +0100 Subject: [PATCH 3/3] mobile shading: IBL strip markers for the BLOOM_SKIP=ibl diagnostic --- native/shared/src/renderer/mobile_shading.rs | 18 ++++++++++++++++++ .../src/renderer/shaders/scene_mobile.wgsl | 3 +++ 2 files changed, 21 insertions(+) diff --git a/native/shared/src/renderer/mobile_shading.rs b/native/shared/src/renderer/mobile_shading.rs index ea4be0dd..3b10d972 100644 --- a/native/shared/src/renderer/mobile_shading.rs +++ b/native/shared/src/renderer/mobile_shading.rs @@ -110,6 +110,8 @@ mod tests { for marker in [ "//PREPASS_STRIP_BEGIN", "//PREPASS_STRIP_END", + "//IBL_STRIP_BEGIN", + "//IBL_STRIP_END", "// BEGIN-POINT-LIGHT-LOOP", "// END-POINT-LIGHT-LOOP", "pl.color.w * atten2", @@ -118,6 +120,22 @@ mod tests { } } + #[test] + fn mobile_scene_shader_takes_the_ibl_strip_diagnostic() { + // As BLOOM_SKIP=ibl does in specialized_scene_shader_source_from. + let mobile = with_mobile_shading(SCENE_SHADER); + let begin = mobile.find("//IBL_STRIP_BEGIN").unwrap(); + let end = mobile.find("//IBL_STRIP_END").unwrap() + "//IBL_STRIP_END".len(); + let stripped = format!( + "{}\n let ibl_diffuse = base_color * (1.0 - metallic) * occlusion\n * lighting.camera_pos.w * 0.35;\n let ibl_spec = vec3(0.0);\n let ibl_clearcoat = vec3(0.0);\n{}", + &mobile[..begin], + &mobile[end..] + ); + assert!(!stripped.contains("env_sample_lod(reflect(-v, n)")); + wgpu::naga::front::wgsl::parse_str(&stripped) + .unwrap_or_else(|error| panic!("IBL-stripped mobile WGSL failed: {error:?}")); + } + #[test] fn mobile_scene_shader_takes_the_froxel_and_gpu_driven_variants() { let mobile = with_mobile_shading(SCENE_SHADER); diff --git a/native/shared/src/renderer/shaders/scene_mobile.wgsl b/native/shared/src/renderer/shaders/scene_mobile.wgsl index 4ffb6d33..0d6f39fe 100644 --- a/native/shared/src/renderer/shaders/scene_mobile.wgsl +++ b/native/shared/src/renderer/shaders/scene_mobile.wgsl @@ -140,6 +140,8 @@ fn shade_main_scene(in: VertexOutputScene, front_facing: bool) -> SceneOut { // END-POINT-LIGHT-LOOP // --- Image-based lighting --- + //IBL_STRIP_BEGIN — BLOOM_SKIP=ibl replaces this block with a flat ambient + // (see mod.rs); only ibl_diffuse/ibl_spec escape it. // Karis's analytic fit of the split-sum environment BRDF ("Physically // Based Shading on Mobile", the one Unreal's mobile renderer uses) in // place of the BRDF LUT. @@ -172,6 +174,7 @@ fn shade_main_scene(in: VertexOutputScene, front_facing: bool) -> SceneOut { 0.0, 1.0, ); let ibl_spec = prefiltered_env * (env_brdf + ms) * spec_occ * (1.0 - ssr_own); + //IBL_STRIP_END let indirect_shadow = mix(0.35, 1.0, shadow_factor); let hdr_raw = lit + (ibl_diffuse + ibl_spec) * indirect_shadow + emissive;