X Tutup
Skip to content

Commit 1108f27

Browse files
Fix energy loss in multi-scattering term (#23203)
# Objective With #23194 applied, the white furnace test passes for pure metals and dielectrics, but fails for anything in between. ## Solution The issue seems to be the multi-scattering term used in `environment_map_light`. The current code computes `FmsEms(mix(F0_dielectric, F0_metal, metalness))` when it should be computing `mix(FmsEms(F0_dielectric), FmsEms(F0_metal), metalness)`, which causes an issue as FmsEms is non-linear in F0. The bug is also present in the [blogpost](https://bruop.github.io/ibl/) that was used as inspiration for the implementation, where the author mentions that the results with multi-scattering are darker than they should be. ## Testing - Ran `cargo run --example testbed_white_furnace`. I've never been so happy to see a gray image :) - Ran `cargo run --example pbr`. --- ## Showcase White furnace test (with #23194 also applied): **Before:** <img width="1280" height="720" alt="fix_vndf" src="https://github.com/user-attachments/assets/03d93be5-7a7f-4015-9cd7-2d1f3b25f09d" /> **After:** <img width="1280" height="720" alt="fix_multiscatter+vndf" src="https://github.com/user-attachments/assets/2aa6fc31-f25c-420a-a759-44e83f47cf9f" /> PBR test [also on imgsli](https://imgsli.com/NDUzNjU2) **Before:** <img width="1280" height="720" alt="main" src="https://github.com/user-attachments/assets/2cd1622a-42a0-4595-ae26-5cc4f50a9221" /> **After:** <img width="1280" height="720" alt="this_pr" src="https://github.com/user-attachments/assets/d29773a0-8445-4942-b118-6905e01b2f0a" /> --------- Co-authored-by: François Mockers <francois.mockers@vleue.com>
1 parent a9192fa commit 1108f27

File tree

5 files changed

+81
-23
lines changed

5 files changed

+81
-23
lines changed

crates/bevy_pbr/src/light_probe/environment_map.wgsl

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ struct EnvironmentMapRadiances {
2323
radiance: vec3<f32>,
2424
}
2525

26+
struct MultiscatterResult {
27+
FssEss: vec3<f32>,
28+
FmsEms: vec3<f32>,
29+
Edss: vec3<f32>,
30+
}
31+
2632
// Computes the direction at which to sample the reflection probe.
2733
//
2834
// * `light_from_world` is the matrix that transforms world space into light
@@ -307,6 +313,24 @@ fn environment_map_light_clearcoat(
307313

308314
#endif // STANDARD_MATERIAL_CLEARCOAT
309315

316+
// Multiscattering approximation: https://www.jcgt.org/published/0008/01/03/paper.pdf
317+
//
318+
// We initially used this (https://bruop.github.io/ibl) reference with Roughness Dependent
319+
// Fresnel, but it made fresnel very bright so we reverted to the "typical" fresnel term.
320+
fn compute_multiscatter(
321+
F0: vec3<f32>,
322+
F_ab: vec2<f32>,
323+
Ems: f32,
324+
specular_occlusion: f32,
325+
) -> MultiscatterResult {
326+
let FssEss = (F0 * F_ab.x + F_ab.y) * specular_occlusion;
327+
let Favg = F0 + (1.0 - F0) / 21.0;
328+
let FmsEms = FssEss * Favg / (1.0 - Ems * Favg) * Ems;
329+
let Edss = 1.0 - (FssEss + FmsEms);
330+
331+
return MultiscatterResult(FssEss, FmsEms, Edss);
332+
}
333+
310334
fn environment_map_light(
311335
input: ptr<function, LightingInput>,
312336
clusterable_object_index_ranges: ptr<function, ClusterableObjectIndexRanges>,
@@ -315,9 +339,11 @@ fn environment_map_light(
315339
// Unpack.
316340
let roughness = (*input).layers[LAYER_BASE].roughness;
317341
let diffuse_color = (*input).diffuse_color;
342+
let metallic = (*input).metallic;
318343
let NdotV = (*input).layers[LAYER_BASE].NdotV;
319344
let F_ab = (*input).F_ab;
320-
let F0 = (*input).F0_;
345+
let F0_dielectric = (*input).F0_dielectric;
346+
let F0_metallic = (*input).F0_metallic;
321347
let world_position = (*input).P;
322348

323349
var out: EnvironmentMapLight;
@@ -338,18 +364,19 @@ fn environment_map_light(
338364
// No real world material has specular values under 0.02, so we use this range as a
339365
// "pre-baked specular occlusion" that extinguishes the fresnel term, for artistic control.
340366
// See: https://google.github.io/filament/Filament.html#specularocclusion
341-
let specular_occlusion = saturate(dot(F0, vec3(50.0 * 0.33)));
367+
let F0_surface = mix(F0_dielectric, F0_metallic, metallic);
368+
let specular_occlusion = saturate(dot(F0_surface, vec3(50.0 * 0.33)));
342369

343-
// Multiscattering approximation: https://www.jcgt.org/published/0008/01/03/paper.pdf
344-
// We initially used this (https://bruop.github.io/ibl) reference with Roughness Dependent
345-
// Fresnel, but it made fresnel very bright so we reverted to the "typical" fresnel term.
346-
let FssEss = (F0 * F_ab.x + F_ab.y) * specular_occlusion;
370+
// Compute per-material (dielectric and metallic separately) then mix the results.
371+
// We can't use F0 directly as the multiscattering term is nonlinear.
347372
let Ems = 1.0 - (F_ab.x + F_ab.y);
348-
let Favg = F0 + (1.0 - F0) / 21.0;
349-
let Fms = FssEss * Favg / (1.0 - Ems * Favg);
350-
let FmsEms = Fms * Ems;
351-
let Edss = 1.0 - (FssEss + FmsEms);
352-
let kD = diffuse_color * Edss;
373+
374+
let ms_dielectric = compute_multiscatter(F0_dielectric, F_ab, Ems, specular_occlusion);
375+
let ms_metallic = compute_multiscatter(F0_metallic, F_ab, Ems, specular_occlusion);
376+
377+
let FssEss = mix(ms_dielectric.FssEss, ms_metallic.FssEss, metallic);
378+
let FmsEms = mix(ms_dielectric.FmsEms, ms_metallic.FmsEms, metallic);
379+
let kD = diffuse_color * ms_dielectric.Edss;
353380

354381
if (!found_diffuse_indirect) {
355382
out.diffuse = (FmsEms + kD) * radiances.irradiance;

crates/bevy_pbr/src/render/pbr_functions.wgsl

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,15 @@ fn calculate_diffuse_color(
276276
(1.0 - diffuse_transmission);
277277
}
278278

279+
// Remapping [0,1] reflectance to F0 for dielectrics
280+
fn calculate_F0_dielectric(reflectance: vec3<f32>) -> vec3<f32> {
281+
return 0.16 * reflectance * reflectance;
282+
}
283+
279284
// Remapping [0,1] reflectance to F0
280285
// See https://google.github.io/filament/Filament.html#materialsystem/parameterization/remapping
281286
fn calculate_F0(base_color: vec3<f32>, metallic: f32, reflectance: vec3<f32>) -> vec3<f32> {
282-
return 0.16 * reflectance * reflectance * (1.0 - metallic) + base_color * metallic;
287+
return mix(calculate_F0_dielectric(reflectance), base_color, metallic);
283288
}
284289

285290
#ifdef DEPTH_PREPASS
@@ -388,7 +393,9 @@ fn apply_pbr_lighting(
388393
lighting_input.P = in.world_position.xyz;
389394
lighting_input.V = in.V;
390395
lighting_input.diffuse_color = diffuse_color;
391-
lighting_input.F0_ = F0;
396+
lighting_input.metallic = metallic;
397+
lighting_input.F0_dielectric = calculate_F0_dielectric(reflectance);
398+
lighting_input.F0_metallic = output_color.rgb;
392399
lighting_input.F_ab = F_ab;
393400
#ifdef STANDARD_MATERIAL_CLEARCOAT
394401
lighting_input.layers[LAYER_CLEARCOAT].NdotV = clearcoat_NdotV;
@@ -415,7 +422,9 @@ fn apply_pbr_lighting(
415422
transmissive_lighting_input.P = diffuse_transmissive_lobe_world_position.xyz;
416423
transmissive_lighting_input.V = -in.V;
417424
transmissive_lighting_input.diffuse_color = diffuse_transmissive_color;
418-
transmissive_lighting_input.F0_ = vec3(0.0);
425+
transmissive_lighting_input.metallic = 0.0;
426+
transmissive_lighting_input.F0_dielectric = vec3(0.0);
427+
transmissive_lighting_input.F0_metallic = vec3(0.0);
419428
transmissive_lighting_input.F_ab = vec2(0.1);
420429
#ifdef STANDARD_MATERIAL_CLEARCOAT
421430
transmissive_lighting_input.layers[LAYER_CLEARCOAT].NdotV = 0.0;
@@ -735,7 +744,7 @@ fn apply_pbr_lighting(
735744
// diffuse_color = vec3<f32>(1.0) // later we use `diffuse_transmissive_color` and `specular_transmissive_color`
736745
// NdotV = 1.0;
737746
// R = T // see definition below
738-
// F0 = vec3<f32>(1.0)
747+
// F0 = vec3<f32>(1.0) (using F0_dielectric = 1, F0_metallic = 0 and metallic = 0)
739748
// diffuse_occlusion = 1.0
740749
//
741750
// (This one is slightly different from the other light types above, because the environment
@@ -755,7 +764,9 @@ fn apply_pbr_lighting(
755764
transmissive_environment_light_input.layers[LAYER_BASE].R = T;
756765
transmissive_environment_light_input.layers[LAYER_BASE].perceptual_roughness = perceptual_roughness;
757766
transmissive_environment_light_input.layers[LAYER_BASE].roughness = roughness;
758-
transmissive_environment_light_input.F0_ = vec3<f32>(1.0);
767+
transmissive_environment_light_input.metallic = 0.0;
768+
transmissive_environment_light_input.F0_dielectric = vec3<f32>(1.0);
769+
transmissive_environment_light_input.F0_metallic = vec3<f32>(0.0);
759770
transmissive_environment_light_input.F_ab = vec2(0.1);
760771
#ifdef STANDARD_MATERIAL_CLEARCOAT
761772
// No clearcoat.

crates/bevy_pbr/src/render/pbr_lighting.wgsl

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,13 @@ struct LightingInput {
7878
// The diffuse color of the material.
7979
diffuse_color: vec3<f32>,
8080

81+
// The 0-1 metallic factor of the material.
82+
metallic: f32,
83+
8184
// Specular reflectance at the normal incidence angle.
82-
//
83-
// This should be read F₀, but due to Naga limitations we can't name it that.
84-
F0_: vec3<f32>,
85+
F0_dielectric: vec3<f32>,
86+
F0_metallic: vec3<f32>,
87+
8588
// Constants for the BRDF approximation.
8689
//
8790
// See `EnvBRDFApprox` in
@@ -400,7 +403,7 @@ fn specular(
400403
) -> vec3<f32> {
401404
// Unpack.
402405
let NdotV = (*input).layers[LAYER_BASE].NdotV;
403-
let F0 = (*input).F0_;
406+
let F0 = mix((*input).F0_dielectric, (*input).F0_metallic, (*input).metallic);
404407
let NdotL = (*derived_input).NdotL;
405408
let NdotH = (*derived_input).NdotH;
406409
let LdotH = (*derived_input).LdotH;
@@ -456,7 +459,7 @@ fn specular_anisotropy(
456459
// Unpack.
457460
let NdotV = (*input).layers[LAYER_BASE].NdotV;
458461
let V = (*input).V;
459-
let F0 = (*input).F0_;
462+
let F0 = mix((*input).F0_dielectric, (*input).F0_metallic, (*input).metallic);
460463
let anisotropy = (*input).anisotropy;
461464
let Ta = (*input).Ta;
462465
let Ba = (*input).Ba;

crates/bevy_pbr/src/ssr/ssr.wgsl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
263263
);
264264
let NdotV = max(dot(N, V), 0.0001);
265265
let F_ab = lighting::F_AB(perceptual_roughness, NdotV);
266-
let F0_env = pbr_functions::calculate_F0(base_color, metallic, reflectance);
266+
let F0_dielectric = pbr_functions::calculate_F0_dielectric(reflectance);
267267

268268
// Don't add stochastic noise to hits that sample the prefiltered env map.
269269
// The prefiltered env map already accounts for roughness.
@@ -279,7 +279,9 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
279279
lighting_input.P = world_position.xyz;
280280
lighting_input.V = V;
281281
lighting_input.diffuse_color = diffuse_color;
282-
lighting_input.F0_ = F0_env;
282+
lighting_input.metallic = metallic;
283+
lighting_input.F0_dielectric = F0_dielectric;
284+
lighting_input.F0_metallic = base_color;
283285
lighting_input.F_ab = F_ab;
284286
#ifdef STANDARD_MATERIAL_CLEARCOAT
285287
lighting_input.layers[LAYER_CLEARCOAT].NdotV = clearcoat_NdotV;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
title: White furnace test
3+
authors: ["@dylansechet"]
4+
pull_requests: [23194, 23203]
5+
---
6+
The [white furnace test](https://lousodrome.net/blog/light/2023/10/21/the-white-furnace-test/) is a classic sanity check for physically-based renderers. Place a perfectly reflective object inside a uniform white environment, and it should be indistinguishable from the background, no matter how metallic and rough. Any object that remains visible is a sign that the shader is creating or absorbing energy it shouldn't.
7+
8+
Bevy used to fail this test, meaning something was wrong with our shader math. Two bugs were responsible:
9+
10+
- Seams were visible when using `GeneratedEnvironmentMapLight` for certain surface orientations.
11+
- Partially metallic materials absorbed energy, appearing darker than they should be.
12+
13+
After fixing those, Bevy passes the test. That means your materials will behave more correctly under image-based lighting.
14+
15+
A gray image has never been so exciting!

0 commit comments

Comments
 (0)
X Tutup