Skip to content

Commit 3cc595c

Browse files
committed
[path_tracing] don't compute specular lobe beyond 0.9 roughness and use RT reflections
1 parent c9ca149 commit 3cc595c

16 files changed

Lines changed: 440 additions & 208 deletions

data/shaders/light.hlsl

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -320,11 +320,11 @@ void evaluate_light(
320320
Light light;
321321
light.Build(light_index, surface);
322322

323-
// when restir pt is enabled, only directional and area lights are owned by the restir nee
324-
// pool, point and spot lights are dirac/near-dirac and not part of the nee mixture so they
325-
// must keep evaluating analytically here, otherwise their direct contribution at the primary
326-
// disappears entirely in restir mode
327-
bool restir_owns_light = is_restir_pt_enabled() && (light.is_directional() || light.is_area());
323+
// when restir pt is enabled the initial ris pool now samples every light type
324+
// (directional, point, spot, area) so all direct lighting at the primary is owned by
325+
// restir, skip the analytical surface eval entirely to avoid double counting, volumetric
326+
// contributions still flow through the analytical path because restir does not own fog
327+
bool restir_owns_light = is_restir_pt_enabled();
328328
bool skip_surface_lighting = restir_owns_light;
329329

330330
float L_shadow = 1.0f;
@@ -472,9 +472,9 @@ void main_cs(uint3 thread_id : SV_DispatchThreadID)
472472
}
473473

474474
// clustered point, spot and area lights, only iterated for non sky pixels where surface shading runs
475-
// when restir is on the cluster loop still runs so point and spot lights can evaluate
476-
// analytically here, evaluate_light internally skips area lights to avoid double counting
477-
// the restir nee pool, area lights without restir keep their full analytical path
475+
// when restir is on the cluster loop still runs so volumetric contributions are
476+
// accumulated, evaluate_light internally short-circuits the surface lighting path so the
477+
// direct lighting at the primary remains owned by the restir nee pool
478478
if (!surface.is_sky() && total_lights > 1u)
479479
{
480480
// the cluster grid lives in the left eye (or mono camera) view-projection space, both vr eyes share it

data/shaders/light_composition.hlsl

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,14 @@ void main_cs(uint3 thread_id : SV_DispatchThreadID)
155155
// lost when the half res restir shading + upsample blurs the albedo into the gi
156156
// gi is at restir_pt_scale of render resolution, so use a join-bilateral
157157
// upsample (depth + normal aware) to avoid bleeding across edges
158-
// also multiply by surface.occlusion to recover contact shadows that
159-
// restir's spatial reuse and denoiser smear away at small scales
158+
// ssao occlusion is intentionally not multiplied in here, the path tracer already
159+
// accounts for indirect visibility through bounce ray tracing so applying ssao on top
160+
// double-counts occlusion in contact regions and produces the muddy contact shadow look
160161
if (is_restir_pt_enabled())
161162
{
162163
float depth_dst_lin = linearize_depth(surface.depth);
163164
light_gi = sample_gi_bilateral(surface.uv, depth_dst_lin, surface.normal);
164165
light_gi *= restir_albedo_demodulator(surface.albedo);
165-
light_gi *= surface.occlusion;
166166
}
167167
}
168168

data/shaders/ray_traced_reflections.hlsl

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,14 @@ void ray_gen()
5959
return;
6060
}
6161

62-
// skip rough surfaces
62+
// lobe split with restir pt: rt reflections owns the narrow specular lobe only, broader
63+
// glossy + diffuse indirect is handled by restir pt's path tracer. the threshold must
64+
// match RESTIR_SPECULAR_HANDOFF_ROUGHNESS in restir_reservoir.hlsl so the two pipelines
65+
// partition the lobe space cleanly with no overlap and no gap
6366
float4 material_sample = tex_material.SampleLevel(GET_SAMPLER(sampler_point_clamp), uv, 0);
6467
float roughness = material_sample.r;
65-
if (roughness >= 0.9f)
68+
const float specular_handoff_roughness = 0.4f;
69+
if (roughness >= specular_handoff_roughness)
6670
{
6771
tex_uav[launch_id] = float4(0, 0, 0, -1);
6872
tex_uav2[launch_id] = float4(0, 0, 0, 0);

data/shaders/restir_pt.hlsl

Lines changed: 121 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -85,18 +85,43 @@ float3 probe_emission_estimate(MaterialParameters mat)
8585
}
8686

8787
// power-proportional light pick weight, lin 2022 §6.1 / restir di reference
88-
// returns 0 for lights ineligible for the restir nee pool (point/spot, zero intensity), so the
89-
// total weight only counts the lights that sample_light_candidate can actually emit candidates
90-
// for, this keeps light_pick_pdf_for_index consistent with the picker
88+
// all four light types (directional, point, spot, area) are eligible, point/spot weights are
89+
// scaled by 1/distance_to_camera^2 approximation via 1/range^2 so a faraway lamp does not
90+
// dominate the picker for a primary near another light, this is the standard veach 1997
91+
// "important light" heuristic adapted for the dirac case where exact 1/r^2 at the primary
92+
// would require per-pixel weight evaluation (too expensive for restir's once-per-frame
93+
// light budget)
9194
float light_pick_weight(LightParameters l)
9295
{
96+
if (l.intensity <= 0.0f)
97+
return 0.0f;
98+
9399
bool is_directional = (l.flags & (1u << 0)) != 0;
100+
bool is_point = (l.flags & (1u << 1)) != 0;
101+
bool is_spot = (l.flags & (1u << 2)) != 0;
94102
bool is_area = (l.flags & (1u << 6)) != 0;
95-
if (l.intensity <= 0.0f || (!is_directional && !is_area))
96-
return 0.0f;
97103

98104
float lum = max(luminance(l.color.rgb), 1e-3f);
99-
return l.intensity * lum;
105+
if (is_directional || is_area)
106+
{
107+
return l.intensity * lum;
108+
}
109+
if (is_point || is_spot)
110+
{
111+
// dirac local light, weight by power proxy (intensity * radiated cone fraction)
112+
// proportional to range^2 so a bright lamp with long reach beats a dim short one,
113+
// spot lights are further weighted by their cone solid angle so a narrow cone gets
114+
// proportionally less budget than a uniform sphere of equal intensity
115+
float range_sq = max(l.range * l.range, 1e-2f);
116+
float cone_factor = 1.0f;
117+
if (is_spot)
118+
{
119+
float cos_outer = cos(l.angle);
120+
cone_factor = max(1.0f - cos_outer, 0.05f);
121+
}
122+
return l.intensity * lum * range_sq * cone_factor;
123+
}
124+
return 0.0f;
100125
}
101126

102127
// total nee pick weight, called once per evaluation, the loop is O(light_count) so this stays
@@ -125,8 +150,9 @@ float light_pick_pdf_for_index(uint light_idx, float total_weight)
125150
// computes the nee strategy density at a brdf-sampled candidate, in solid angle at primary
126151
// returns the sun cone density for sky candidates that fall inside the cone, zero otherwise
127152
// area lights are not in the bvh so the brdf strategy can never produce paths whose rc lies
128-
// on an area light rectangle, the area light loop that used to live here was a fictitious
129-
// strategy density that darkened the brdf branch by ~50% near walls aligned with area lights
153+
// on an area light rectangle, point/spot are dirac and likewise cannot be hit by a brdf
154+
// bounce, so the nee strategy contributes zero to the brdf-candidate mis denominator for
155+
// those light types and the brdf candidate gets its full single-strategy mis weight
130156
float light_nee_pdf_for_candidate(PathSample candidate, float3 primary_pos)
131157
{
132158
uint light_count = (uint)buffer_frame.restir_pt_light_count;
@@ -262,9 +288,15 @@ float sky_nee_pdf_at(float3 dir, float3 shading_normal)
262288

263289
// evaluates direct lighting (analytical lights + environment probe) at a surface vertex
264290
// returns the contribution in the direction of view_dir (i.e. back toward the previous vertex)
265-
// always uses the full brdf so metallic/glossy surfaces receive proper nee at every vertex
266-
// note: stored rc_radiance is therefore view-dependent at rc (w.r.t. src's incoming direction)
267-
// the bias introduced during reconnection shift is bounded by the rc roughness gate
291+
// when diffuse_only_brdf is false, the full brdf (diffuse + ggx specular) is used at this
292+
// vertex, this is correct for suffix vertices past rc which are baked into rc_L_post and not
293+
// individually reused across pixels
294+
// when diffuse_only_brdf is true, the specular lobe is dropped and only the lambert diffuse
295+
// term remains, this is the correct choice for the rc vertex itself because the resulting
296+
// nee radiance must be view-independent so the reconnection shift can reuse it at any dst
297+
// primary without re-evaluating the rc brdf against the dst-correct incoming direction, the
298+
// rc roughness gate at 0.3 makes the missing specular lobe a bounded, negligible energy loss
299+
// at the same vertex
268300
float3 direct_lighting_at_vertex(
269301
float3 shading_pos,
270302
float3 shading_normal,
@@ -273,6 +305,7 @@ float3 direct_lighting_at_vertex(
273305
float3 albedo,
274306
float roughness,
275307
float metallic,
308+
bool diffuse_only_brdf,
276309
inout uint seed)
277310
{
278311
float3 total = float3(0, 0, 0);
@@ -373,8 +406,9 @@ float3 direct_lighting_at_vertex(
373406

374407
// secondary vertex always uses the full brdf, primary specular routing only applies
375408
// at the primary vertex itself which is shaded by self_shift_evaluate or the shifts
409+
// at the rc vertex itself we use lambert only so the stored nee stays view-independent
376410
float brdf_pdf;
377-
float3 brdf = evaluate_brdf(albedo, roughness, metallic, shading_normal, view_dir, light_dir, brdf_pdf, false);
411+
float3 brdf = evaluate_brdf(albedo, roughness, metallic, shading_normal, view_dir, light_dir, brdf_pdf, diffuse_only_brdf);
378412

379413
// analytical lights are not in the bvh, the brdf bounce can never sample to their
380414
// direction so there is no second strategy to mis with, applying power_heuristic against
@@ -462,7 +496,7 @@ float3 direct_lighting_at_vertex(
462496
if (luminance(probe_emission) > 0.0f)
463497
{
464498
float brdf_pdf_probe;
465-
float3 brdf_probe = evaluate_brdf(albedo, roughness, metallic, shading_normal, view_dir, env_dir, brdf_pdf_probe, false);
499+
float3 brdf_probe = evaluate_brdf(albedo, roughness, metallic, shading_normal, view_dir, env_dir, brdf_pdf_probe, diffuse_only_brdf);
466500

467501
float mis_weight = power_heuristic(env_pdf, brdf_pdf_probe);
468502
total += brdf_probe * probe_emission * mis_weight / env_pdf;
@@ -475,7 +509,7 @@ float3 direct_lighting_at_vertex(
475509
env_radiance = clamp_sky_radiance(env_radiance);
476510

477511
float brdf_pdf_env;
478-
float3 brdf_env = evaluate_brdf(albedo, roughness, metallic, shading_normal, view_dir, env_dir, brdf_pdf_env, false);
512+
float3 brdf_env = evaluate_brdf(albedo, roughness, metallic, shading_normal, view_dir, env_dir, brdf_pdf_env, diffuse_only_brdf);
479513

480514
float mis_weight_env = power_heuristic(env_pdf, brdf_pdf_env);
481515
total += brdf_env * env_radiance * mis_weight_env / env_pdf;
@@ -583,18 +617,23 @@ void trace_rc_suffix(
583617
// that requires a cpu-built emissive triangle structured buffer which is not part of
584618
// this rewrite, the bounce-emission path is unbiased so this is a variance-only win
585619
out_L_post += throughput * next.emission;
620+
// suffix vertices past rc, full brdf is correct here because L_post is reused as a
621+
// whole only via the rc bsdf factor at shift time, individual suffix vertices are not
622+
// re-evaluated against a different primary direction
586623
out_L_post += throughput * direct_lighting_at_vertex(
587624
next.hit_position, next.hit_normal, next.geometric_normal,
588-
-nd, next.albedo, next.roughness, next.metallic, seed);
625+
-nd, next.albedo, next.roughness, next.metallic, false, seed);
589626

590627
cur = next;
591628
view_dir = -nd;
592629
}
593630
}
594631

595-
// gathers the rc vertex's nee + emission contribution (with rc bsdf baked in at src view dir,
596-
// view-dep bias bounded by the get_restir_rc_min_roughness floor on rc reuse) and the suffix
597-
// radiance past rc (with rc bsdf factored out into rc_outgoing_dir for paper-faithful shifts)
632+
// gathers the rc vertex's nee + emission contribution (with the rc brdf reduced to lambert
633+
// only so the stored radiance is view-independent and reuses correctly at any dst primary,
634+
// missing specular-lobe energy at rc is bounded by the get_restir_rc_min_roughness floor)
635+
// and the suffix radiance past rc (with the rc brdf factored out into rc_outgoing_dir for
636+
// paper-faithful reconnection shifts)
598637
void accumulate_subpath_at_rc(
599638
PathPayload rc,
600639
float3 rc_view_dir,
@@ -605,9 +644,13 @@ void accumulate_subpath_at_rc(
605644
out float3 out_first_dir)
606645
{
607646
out_L_nee = rc.emission;
647+
// diffuse-only brdf at rc keeps the stored nee radiance view-independent so the
648+
// reconnection shift can reuse it at any dst primary without re-evaluating the rc brdf
649+
// against the dst-correct incoming direction, the rc roughness gate at 0.3 bounds the
650+
// missing specular lobe energy at this same vertex
608651
out_L_nee += direct_lighting_at_vertex(
609652
rc.hit_position, rc.hit_normal, rc.geometric_normal,
610-
rc_view_dir, rc.albedo, rc.roughness, rc.metallic, seed);
653+
rc_view_dir, rc.albedo, rc.roughness, rc.metallic, true, seed);
611654

612655
out_L_post = float3(0, 0, 0);
613656
out_first_dir = float3(0, 0, 0);
@@ -766,6 +809,56 @@ PathSample sample_light_candidate(
766809
return s;
767810
}
768811

812+
bool is_point = (light.flags & (1u << 1)) != 0;
813+
bool is_spot = (light.flags & (1u << 2)) != 0;
814+
if (is_point || is_spot)
815+
{
816+
// dirac local light, rc is the light position treated as a point emitter, distance
817+
// attenuation and spot cone are folded into rc_L_nee so the shift-time evaluation
818+
// collapses to brdf_dst * rc_L_nee like the area-light branch, the BRDF strategy
819+
// cannot produce paths that land on a point/spot light (they have zero volume in
820+
// the bvh) so the MIS denominator is single-strategy and we use a unit solid-angle
821+
// pdf to keep ris weights in the same scale as area light candidates (the pdf
822+
// really is a dirac, but a single-strategy weight target/pdf does not care about
823+
// the absolute scale of pdf as long as it is consistent across same-strategy samples)
824+
float3 to = light.position - primary_pos;
825+
float light_dist = length(to);
826+
if (light_dist < 1e-3f)
827+
return s;
828+
829+
float3 dir = to / light_dist;
830+
if (dot(dir, primary_normal) <= MIN_COS_AT_PRIMARY)
831+
return s;
832+
833+
float range_factor = saturate(1.0f - light_dist / max(light.range, 0.01f));
834+
float attenuation = range_factor * range_factor / max(light_dist * light_dist, 0.01f);
835+
836+
if (is_spot)
837+
{
838+
float cos_angle = dot(-dir, light.direction);
839+
float cos_outer = cos(light.angle);
840+
float cos_inner = cos(light.angle * 0.8f);
841+
float spot = saturate((cos_angle - cos_outer) / max(cos_inner - cos_outer, 1e-4f));
842+
attenuation *= spot;
843+
if (attenuation <= 0.0f)
844+
return s;
845+
}
846+
847+
s.rc_pos = light.position;
848+
s.rc_normal = -dir;
849+
s.rc_L_nee = light.color.rgb * light.intensity * attenuation;
850+
s.rc_L_post = float3(0, 0, 0);
851+
s.flags |= PATH_FLAG_HAS_RC | PATH_FLAG_NEE;
852+
853+
// unit solid-angle pdf keeps dirac candidates on the same numerical scale as the
854+
// area branch so the ris ordering is stable across mixed light types, the actual
855+
// dirac density is infinite which would zero out weights through 1/pdf in mis,
856+
// single-strategy w = target/(n_light * 1) yields the correct estimator for the
857+
// collapsed dirac case (no brdf strategy contribution to balance against)
858+
source_pdf = pick_pdf;
859+
return s;
860+
}
861+
769862
return s;
770863
}
771864

@@ -845,28 +938,20 @@ PathSample trace_path_from_primary(
845938
L_nee = max(L_nee, 0.0f);
846939
L_post = max(L_post, 0.0f);
847940

848-
// soft luminance clamp, the W clamp + m cap bound the unbiased estimator energy but not
849-
// single sample variance, when a glossy rc lands on a bright emitter the rc bsdf baked
850-
// into rc_L_nee can spike to thousands of nits and a stable spatial neighbor will then
851-
// stamp that pulse across many pixels (boiling), the soft scale preserves chromaticity so
852-
// the residual downward bias is uniform across channels and the denoiser absorbs the rest
853-
{
854-
float lum_nee = luminance(L_nee);
855-
float lum_post = luminance(L_post);
856-
const float firefly_ceiling = 250.0f;
857-
if (lum_nee > firefly_ceiling) L_nee *= firefly_ceiling / lum_nee;
858-
if (lum_post > firefly_ceiling) L_post *= firefly_ceiling / lum_post;
859-
}
860-
941+
// stored radiance is left unmodified at sample construction so the resampler scores the
942+
// true integrand, per-sample firefly safety is provided downstream via the w clamp at
943+
// shade time (get_w_clamp_for_sample) and the denoiser's spatial firefly suppression,
944+
// squashing radiance pre-ris would bias the integrand the resampler is trying to estimate
861945
s.rc_L_nee = L_nee;
862946
s.rc_L_post = L_post;
863947
s.rc_outgoing_dir = first_outgoing_dir;
864948
s.path_length = 2;
865949

866-
// reconnection validity: the rc vertex must be rough enough that the rc_L_nee view-dep
867-
// bias (rc bsdf baked at src direction for the nee component) stays small, the suffix
868-
// view-dep is now factored out via rc_L_post + rc_outgoing_dir + rc material, distance
869-
// must be large enough to keep the solid-angle jacobian well-conditioned
950+
// reconnection validity: the rc vertex must be rough enough that the lambert-only nee at
951+
// rc captures most of the energy (the dropped ggx specular lobe at rc is the remaining
952+
// bias source, bounded by the roughness floor), the suffix view-dep is factored out via
953+
// rc_L_post + rc_outgoing_dir + rc material, distance must be large enough to keep the
954+
// solid-angle jacobian well-conditioned
870955
float rc_min_roughness = get_restir_rc_min_roughness();
871956
float dist_sq = dot(hit.hit_position - primary_pos, hit.hit_position - primary_pos);
872957
bool rc_valid = (hit.roughness >= rc_min_roughness)

0 commit comments

Comments
 (0)