Skip to content

Commit bf72875

Browse files
authored
Solari: Fix mirror artifacts (#22468)
# Objective - Fix artifacts appearing in perfect mirrors ## Solution - Rather than trying to hack the microfacet BRDF to work at low roughness, replace it with a separate mirror BRDF - The cos_theta part of the rendering equation has been moved into the BRDF, since the mirror BRDF does not use it (it cancels out) ## Showcase Before - artifacts (black lines) <img width="2564" height="1500" alt="image" src="https://github.com/user-attachments/assets/22c896a2-4411-4ca1-83a1-033d7fd3fcb4" /> After - no artifacts <img width="3206" height="1875" alt="image" src="https://github.com/user-attachments/assets/414128cc-ba2f-48dd-a6fb-4ab09aa67e67" />
1 parent 51fc15b commit bf72875

File tree

9 files changed

+47
-37
lines changed

9 files changed

+47
-37
lines changed

crates/bevy_solari/src/pathtracer/pathtracer.wgsl

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
#import bevy_render::view::View
66
#import bevy_solari::brdf::evaluate_brdf
77
#import bevy_solari::sampling::{sample_random_light, random_emissive_light_pdf, sample_ggx_vndf, ggx_vndf_pdf, power_heuristic}
8-
#import bevy_solari::scene_bindings::{trace_ray, resolve_ray_hit_full, ResolvedRayHitFull, RAY_T_MIN, RAY_T_MAX}
8+
#import bevy_solari::scene_bindings::{trace_ray, resolve_ray_hit_full, ResolvedRayHitFull, RAY_T_MIN, RAY_T_MAX, MIRROR_ROUGHNESS_THRESHOLD}
99

1010
@group(1) @binding(0) var accumulation_texture: texture_storage_2d<rgba32float, read_write>;
1111
@group(1) @binding(1) var view_output: texture_storage_2d<rgba16float, write>;
@@ -54,7 +54,7 @@ fn pathtrace(@builtin(global_invocation_id) global_id: vec3<u32>) {
5454
radiance += mis_weight * throughput * ray_hit.material.emissive;
5555

5656
// Sample direct lighting, but only if the surface is not mirror-like
57-
let is_perfectly_specular = ray_hit.material.roughness <= 0.001 && ray_hit.material.metallic > 0.9999;
57+
let is_perfectly_specular = ray_hit.material.roughness <= MIRROR_ROUGHNESS_THRESHOLD && ray_hit.material.metallic > 0.9999;
5858
if !is_perfectly_specular {
5959
let direct_lighting = sample_random_light(ray_hit.world_position, ray_hit.world_normal, &rng);
6060

@@ -79,8 +79,7 @@ fn pathtrace(@builtin(global_invocation_id) global_id: vec3<u32>) {
7979

8080
// Update throughput for next bounce
8181
let brdf = evaluate_brdf(ray_hit.world_normal, wo, next_bounce.wi, ray_hit.material);
82-
let cos_theta = dot(next_bounce.wi, ray_hit.world_normal);
83-
throughput *= (brdf * cos_theta) / next_bounce.pdf;
82+
throughput *= brdf / next_bounce.pdf;
8483

8584
// Russian roulette for early termination
8685
let p = luminance(throughput);
@@ -105,7 +104,7 @@ struct NextBounce {
105104
}
106105

107106
fn importance_sample_next_bounce(wo: vec3<f32>, ray_hit: ResolvedRayHitFull, rng: ptr<function, u32>) -> NextBounce {
108-
let is_perfectly_specular = ray_hit.material.roughness <= 0.001 && ray_hit.material.metallic > 0.9999;
107+
let is_perfectly_specular = ray_hit.material.roughness <= MIRROR_ROUGHNESS_THRESHOLD && ray_hit.material.metallic > 0.9999;
109108
if is_perfectly_specular {
110109
return NextBounce(reflect(-wo, ray_hit.world_normal), 1.0, true);
111110
}

crates/bevy_solari/src/realtime/gbuffer_utils.wgsl

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ fn gpixel_resolve(gpixel: vec4<u32>, depth: f32, pixel_id: vec2<u32>, view_size:
1818

1919
let base_rough = unpack4x8unorm(gpixel.r);
2020
let base_color = pow(base_rough.rgb, vec3(2.2));
21-
// Clamp roughness to prevent NaNs
22-
let perceptual_roughness = clamp(base_rough.a, 0.0316227766, 1.0); // Clamp roughness to 0.001
21+
let perceptual_roughness = base_rough.a;
2322
let roughness = perceptual_roughness * perceptual_roughness;
2423
let props = unpack4x8unorm(gpixel.b);
2524
let reflectance = vec3(props.r);

crates/bevy_solari/src/realtime/restir_di.wgsl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ fn spatial_and_shade(@builtin(global_invocation_id) global_id: vec3<u32>) {
8383
var brdf: vec3<f32>;
8484
// If the surface is very smooth, let specular GI handle the specular lobe
8585
if surface.material.roughness <= SPECULAR_GI_FOR_DI_ROUGHNESS_THRESHOLD {
86-
brdf = evaluate_diffuse_brdf(surface.material.base_color, surface.material.metallic);
86+
brdf = evaluate_diffuse_brdf(surface.world_normal, merge_result.wi, surface.material.base_color, surface.material.metallic);
8787
} else {
8888
brdf = evaluate_brdf(surface.world_normal, wo, merge_result.wi, surface.material);
8989
}
@@ -111,7 +111,7 @@ fn generate_initial_reservoir(world_position: vec3<f32>, world_normal: vec3<f32>
111111
let resolved_light_sample = unpack_resolved_light_sample(light_tile_resolved_samples[tile_sample], view.exposure);
112112
let light_contribution = calculate_resolved_light_contribution(resolved_light_sample, world_position, world_normal);
113113

114-
let target_function = luminance(light_contribution.radiance * diffuse_brdf);
114+
let target_function = luminance(light_contribution.radiance * diffuse_brdf * saturate(dot(light_contribution.wi, world_normal)));
115115
let resampling_weight = mis_weight * (target_function * light_contribution.inverse_pdf);
116116

117117
weight_sum += resampling_weight;
@@ -329,6 +329,6 @@ struct ReservoirContribution {
329329
fn reservoir_contribution(reservoir: Reservoir, world_position: vec3<f32>, world_normal: vec3<f32>, diffuse_brdf: vec3<f32>) -> ReservoirContribution {
330330
if !reservoir_valid(reservoir) { return ReservoirContribution(vec3(0.0), 0.0, vec3(0.0)); }
331331
let light_contribution = resolve_and_calculate_light_contribution(reservoir.sample, world_position, world_normal);
332-
let target_function = luminance(light_contribution.radiance * diffuse_brdf);
332+
let target_function = luminance(light_contribution.radiance * diffuse_brdf * saturate(dot(light_contribution.wi, world_normal)));
333333
return ReservoirContribution(light_contribution.radiance, target_function, light_contribution.wi);
334334
}

crates/bevy_solari/src/realtime/restir_gi.wgsl

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ fn spatial_and_shade(@builtin(global_invocation_id) global_id: vec3<u32>) {
6969
gi_reservoirs_a[pixel_index] = combined_reservoir;
7070
#endif
7171

72-
let brdf = evaluate_diffuse_brdf(surface.material.base_color, surface.material.metallic);
72+
let brdf = evaluate_diffuse_brdf(surface.world_normal, merge_result.wi, surface.material.base_color, surface.material.metallic);
7373

7474
var pixel_color = textureLoad(view_output, global_id.xy);
7575
pixel_color += vec4(merge_result.selected_sample_radiance * combined_reservoir.unbiased_contribution_weight * view.exposure * brdf, 0.0);
@@ -98,7 +98,7 @@ fn generate_initial_reservoir(world_position: vec3<f32>, world_normal: vec3<f32>
9898

9999
#ifdef NO_WORLD_CACHE
100100
let direct_lighting = sample_random_light(sample_point.world_position, sample_point.world_normal, rng);
101-
reservoir.radiance = direct_lighting.radiance;
101+
reservoir.radiance = direct_lighting.radiance * saturate(dot(direct_lighting.wi, sample_point.world_normal));
102102
reservoir.unbiased_contribution_weight = direct_lighting.inverse_pdf * uniform_hemisphere_inverse_pdf();
103103
#else
104104
reservoir.radiance = query_world_cache(sample_point.world_position, sample_point.geometric_world_normal, view.world_position, ray.t, WORLD_CACHE_CELL_LIFETIME, rng);
@@ -219,6 +219,7 @@ fn empty_reservoir() -> Reservoir {
219219
struct ReservoirMergeResult {
220220
merged_reservoir: Reservoir,
221221
selected_sample_radiance: vec3<f32>,
222+
wi: vec3<f32>,
222223
}
223224

224225
fn merge_reservoirs(
@@ -233,8 +234,10 @@ fn merge_reservoirs(
233234
rng: ptr<function, u32>,
234235
) -> ReservoirMergeResult {
235236
// Radiances for resampling
236-
let canonical_sample_radiance = canonical_reservoir.radiance * saturate(dot(normalize(canonical_reservoir.sample_point_world_position - canonical_world_position), canonical_world_normal));
237-
let other_sample_radiance = other_reservoir.radiance * saturate(dot(normalize(other_reservoir.sample_point_world_position - canonical_world_position), canonical_world_normal));
237+
let canonical_sample_wi = normalize(canonical_reservoir.sample_point_world_position - canonical_world_position);
238+
let other_sample_wi = normalize(other_reservoir.sample_point_world_position - canonical_world_position);
239+
let canonical_sample_radiance = canonical_reservoir.radiance * saturate(dot(canonical_sample_wi, canonical_world_normal));
240+
let other_sample_radiance = other_reservoir.radiance * saturate(dot(other_sample_wi, canonical_world_normal));
238241

239242
// Target functions for resampling and MIS
240243
let canonical_target_function_canonical_sample = luminance(canonical_sample_radiance * canonical_diffuse_brdf);
@@ -264,7 +267,7 @@ fn merge_reservoirs(
264267

265268
// Don't merge samples with huge jacobians, as it explodes the variance
266269
if canonical_target_function_other_sample_jacobian > 1.2 {
267-
return ReservoirMergeResult(canonical_reservoir, canonical_sample_radiance);
270+
return ReservoirMergeResult(canonical_reservoir, canonical_sample_radiance, canonical_sample_wi);
268271
}
269272

270273
// Resampling weight for canonical sample
@@ -294,7 +297,7 @@ fn merge_reservoirs(
294297
let inverse_target_function = select(0.0, 1.0 / canonical_target_function_other_sample, canonical_target_function_other_sample > 0.0);
295298
combined_reservoir.unbiased_contribution_weight = combined_reservoir.weight_sum * inverse_target_function;
296299

297-
return ReservoirMergeResult(combined_reservoir, other_sample_radiance);
300+
return ReservoirMergeResult(combined_reservoir, other_sample_radiance, other_sample_wi);
298301
} else {
299302
combined_reservoir.sample_point_world_position = canonical_reservoir.sample_point_world_position;
300303
combined_reservoir.sample_point_world_normal = canonical_reservoir.sample_point_world_normal;
@@ -303,6 +306,6 @@ fn merge_reservoirs(
303306
let inverse_target_function = select(0.0, 1.0 / canonical_target_function_canonical_sample, canonical_target_function_canonical_sample > 0.0);
304307
combined_reservoir.unbiased_contribution_weight = combined_reservoir.weight_sum * inverse_target_function;
305308

306-
return ReservoirMergeResult(combined_reservoir, canonical_sample_radiance);
309+
return ReservoirMergeResult(combined_reservoir, canonical_sample_radiance, canonical_sample_wi);
307310
}
308311
}

crates/bevy_solari/src/realtime/specular_gi.wgsl

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
#import bevy_solari::brdf::{evaluate_brdf, evaluate_specular_brdf}
88
#import bevy_solari::gbuffer_utils::{gpixel_resolve, ResolvedGPixel}
99
#import bevy_solari::sampling::{sample_random_light, random_emissive_light_pdf, sample_ggx_vndf, ggx_vndf_pdf, power_heuristic}
10-
#import bevy_solari::scene_bindings::{trace_ray, resolve_ray_hit_full, ResolvedRayHitFull, RAY_T_MIN, RAY_T_MAX}
10+
#import bevy_solari::scene_bindings::{trace_ray, resolve_ray_hit_full, ResolvedRayHitFull, RAY_T_MIN, RAY_T_MAX, MIRROR_ROUGHNESS_THRESHOLD}
1111
#import bevy_solari::world_cache::{query_world_cache, get_cell_size, WORLD_CACHE_CELL_LIFETIME}
1212
#import bevy_solari::realtime_bindings::{view_output, gi_reservoirs_a, gbuffer, depth_buffer, view, constants}
1313
#ifdef DLSS_RR_GUIDE_BUFFERS
@@ -63,8 +63,7 @@ fn specular_gi(@builtin(global_invocation_id) global_id: vec3<u32>) {
6363

6464
let brdf = evaluate_specular_brdf(surface.world_normal, wo, wi, surface.material.base_color, surface.material.metallic,
6565
surface.material.reflectance, surface.material.perceptual_roughness, surface.material.roughness);
66-
let cos_theta = saturate(dot(wi, surface.world_normal));
67-
radiance *= brdf * cos_theta * view.exposure;
66+
radiance *= brdf * view.exposure;
6867

6968
var pixel_color = textureLoad(view_output, global_id.xy);
7069
pixel_color += vec4(radiance, 0.0);
@@ -110,15 +109,15 @@ fn trace_glossy_path(pixel_id: vec2<u32>, primary_surface: ResolvedGPixel, initi
110109
radiance += throughput * mis_weight * ray_hit.material.emissive;
111110

112111
// Should not perform NEE for mirror-like surfaces
113-
surface_perfect_mirror = ray_hit.material.roughness <= 0.001 && ray_hit.material.metallic > 0.9999;
112+
surface_perfect_mirror = ray_hit.material.roughness <= MIRROR_ROUGHNESS_THRESHOLD && ray_hit.material.metallic > 0.9999;
114113

115114
// https://d1qx31qr3h6wln.cloudfront.net/publications/mueller21realtime.pdf#subsection.3.4, equation (3)
116115
path_spread += sqrt((ray.t * ray.t) / (p_bounce * wo_tangent.z));
117116

118117
// Primary surface replacement for perfect mirrors
119118
// https://developer.nvidia.com/blog/rendering-perfect-reflections-and-refractions-in-path-traced-games/#primary_surface_replacement
120119
#ifdef DLSS_RR_GUIDE_BUFFERS
121-
if !psr_finished && primary_surface.material.roughness <= 0.001 && primary_surface.material.metallic > 0.9999 {
120+
if !psr_finished && primary_surface.material.roughness <= MIRROR_ROUGHNESS_THRESHOLD && primary_surface.material.metallic > 0.9999 {
122121
if surface_perfect_mirror {
123122
mirror_rotations = mirror_rotations * reflection_matrix(ray_hit.world_normal);
124123
} else {

crates/bevy_solari/src/realtime/world_cache_update.wgsl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ fn sample_random_light_ris(world_position: vec3<f32>, world_normal: vec3<f32>, w
9999
let resolved_light_sample = unpack_resolved_light_sample(light_tile_resolved_samples[tile_sample], view.exposure);
100100
let light_contribution = calculate_resolved_light_contribution(resolved_light_sample, world_position, world_normal);
101101

102-
let target_function = luminance(light_contribution.radiance);
102+
let target_function = luminance(light_contribution.radiance * saturate(dot(light_contribution.wi, world_normal)));
103103
let resampling_weight = mis_weight * (target_function * light_contribution.inverse_pdf);
104104

105105
weight_sum += resampling_weight;

crates/bevy_solari/src/scene/brdf.wgsl

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
#import bevy_pbr::lighting::{F_AB, D_GGX, V_SmithGGXCorrelated, fresnel, specular_multiscatter}
44
#import bevy_pbr::pbr_functions::{calculate_diffuse_color, calculate_F0}
55
#import bevy_render::maths::PI
6-
#import bevy_solari::scene_bindings::ResolvedMaterial
6+
#import bevy_solari::scene_bindings::{ResolvedMaterial, MIRROR_ROUGHNESS_THRESHOLD}
77

88
fn evaluate_brdf(
99
world_normal: vec3<f32>,
1010
wo: vec3<f32>,
1111
wi: vec3<f32>,
1212
material: ResolvedMaterial,
1313
) -> vec3<f32> {
14-
let diffuse_brdf = evaluate_diffuse_brdf(material.base_color, material.metallic);
14+
let diffuse_brdf = evaluate_diffuse_brdf(world_normal, wi, material.base_color, material.metallic);
1515
let specular_brdf = evaluate_specular_brdf(
1616
world_normal,
1717
wo,
@@ -25,9 +25,9 @@ fn evaluate_brdf(
2525
return diffuse_brdf + specular_brdf;
2626
}
2727

28-
fn evaluate_diffuse_brdf(base_color: vec3<f32>, metallic: f32) -> vec3<f32> {
29-
let diffuse_color = calculate_diffuse_color(base_color, metallic, 0.0, 0.0);
30-
return diffuse_color / PI;
28+
fn evaluate_diffuse_brdf(N: vec3<f32>, L: vec3<f32>, base_color: vec3<f32>, metallic: f32) -> vec3<f32> {
29+
let diffuse_color = calculate_diffuse_color(base_color, metallic, 0.0, 0.0) / PI;
30+
return diffuse_color * saturate(dot(N, L));
3131
}
3232

3333
fn evaluate_specular_brdf(
@@ -47,10 +47,14 @@ fn evaluate_specular_brdf(
4747
let NdotV = max(dot(N, V), 0.0001);
4848

4949
let F0 = calculate_F0(base_color, metallic, reflectance);
50-
let F_ab = F_AB(perceptual_roughness, NdotV);
50+
let F = fresnel(F0, LdotH);
51+
52+
if roughness <= MIRROR_ROUGHNESS_THRESHOLD {
53+
return F;
54+
}
5155

5256
let D = D_GGX(roughness, NdotH);
5357
let Vs = V_SmithGGXCorrelated(roughness, NdotV, NdotL);
54-
let F = fresnel(F0, LdotH);
55-
return specular_multiscatter(D, Vs, F, F0, F_ab, 1.0);
58+
let F_ab = F_AB(perceptual_roughness, NdotV);
59+
return specular_multiscatter(D, Vs, F, F0, F_ab, 1.0) * saturate(dot(N, L));
5660
}

crates/bevy_solari/src/scene/raytracing_scene_bindings.wgsl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ struct Material {
5353

5454
const TEXTURE_MAP_NONE = 0xFFFFFFFFu;
5555

56+
const MIRROR_ROUGHNESS_THRESHOLD = 0.001f;
57+
5658
struct LightSource {
5759
kind: u32, // 1 bit for kind, 31 bits for extra data
5860
id: u32,
@@ -145,8 +147,6 @@ fn resolve_material(material: Material, uv: vec2<f32>) -> ResolvedMaterial {
145147
m.metallic *= metallic_roughness.b;
146148
}
147149

148-
// Clamp roughness to prevent NaNs
149-
m.perceptual_roughness = clamp(m.perceptual_roughness, 0.0316227766, 1.0); // Clamp roughness to 0.001
150150
m.roughness = m.perceptual_roughness * m.perceptual_roughness;
151151

152152
return m;

crates/bevy_solari/src/scene/sampling.wgsl

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#import bevy_pbr::lighting::D_GGX
44
#import bevy_pbr::utils::{rand_f, rand_vec2f, rand_u, rand_range_u}
55
#import bevy_render::maths::{PI_2, orthonormalize}
6-
#import bevy_solari::scene_bindings::{trace_ray, RAY_T_MIN, RAY_T_MAX, light_sources, directional_lights, LightSource, LIGHT_SOURCE_KIND_DIRECTIONAL, resolve_triangle_data_full, ResolvedRayHitFull}
6+
#import bevy_solari::scene_bindings::{trace_ray, RAY_T_MIN, RAY_T_MAX, light_sources, directional_lights, LightSource, LIGHT_SOURCE_KIND_DIRECTIONAL, resolve_triangle_data_full, ResolvedRayHitFull, MIRROR_ROUGHNESS_THRESHOLD}
77

88
fn power_heuristic(f: f32, g: f32) -> f32 {
99
return balance_heuristic(f * f, g * g);
@@ -19,7 +19,8 @@ fn balance_heuristic(f: f32, g: f32) -> f32 {
1919

2020
// https://gpuopen.com/download/Bounded_VNDF_Sampling_for_Smith-GGX_Reflections.pdf (Listing 1)
2121
fn sample_ggx_vndf(wi_tangent: vec3<f32>, roughness: f32, rng: ptr<function, u32>) -> vec3<f32> {
22-
if roughness <= 0.001 {
22+
// Mirror BRDF case
23+
if roughness <= MIRROR_ROUGHNESS_THRESHOLD {
2324
return vec3(-wi_tangent.xy, wi_tangent.z);
2425
}
2526

@@ -43,6 +44,12 @@ fn sample_ggx_vndf(wi_tangent: vec3<f32>, roughness: f32, rng: ptr<function, u32
4344

4445
// https://gpuopen.com/download/Bounded_VNDF_Sampling_for_Smith-GGX_Reflections.pdf (Listing 2)
4546
fn ggx_vndf_pdf(wi_tangent: vec3<f32>, wo_tangent: vec3<f32>, roughness: f32) -> f32 {
47+
// Mirror BRDF case
48+
if roughness <= MIRROR_ROUGHNESS_THRESHOLD {
49+
let mirror_wo = vec3(-wi_tangent.xy, wi_tangent.z);
50+
return f32(all(abs(mirror_wo - wo_tangent) < vec3(0.0001)));
51+
}
52+
4653
let i = wi_tangent;
4754
let o = wo_tangent;
4855
let m = normalize(i + o);
@@ -171,11 +178,10 @@ fn calculate_resolved_light_contribution(resolved_light_sample: ResolvedLightSam
171178
let light_distance = length(ray);
172179
let wi = ray / light_distance;
173180

174-
let cos_theta_origin = saturate(dot(wi, origin_world_normal));
175181
let cos_theta_light = saturate(dot(-wi, resolved_light_sample.world_normal));
176182
let light_distance_squared = light_distance * light_distance;
177183

178-
let radiance = resolved_light_sample.radiance * cos_theta_origin * (cos_theta_light / light_distance_squared);
184+
let radiance = resolved_light_sample.radiance * (cos_theta_light / light_distance_squared);
179185

180186
return LightContribution(radiance, resolved_light_sample.inverse_pdf, wi, resolved_light_sample.world_position.w == 1.0);
181187
}

0 commit comments

Comments
 (0)