@@ -216,57 +216,52 @@ float trace_inline_shadow_ray(Light light, Surface surface, float2 pixel_xy)
216216// Subsurface scattering with wrapped diffuse and thickness estimation
217217float3 subsurface_scattering (Surface surface, Light light, AngularInfo angular_info)
218218{
219- // material-dependent scattering parameters
220- const float wrap_factor = 0.5f ; // wrapped lighting factor (0 = no wrap, 1 = full wrap)
221- const float sss_exponent = 3.0f ; // translucency falloff sharpness
222- const float thickness_exponent = 1.5f ; // edge thickness falloff
223- const float sss_scale = 0.8f ; // overall scattering strength multiplier
224- const float min_scatter = 0.05f ; // minimum ambient scattering
225-
219+ const float wrap_factor = 0.5f ;
220+ const float sss_scale = 1.5f ; // overall scattering strength
221+ const float min_scatter = 0.05f ;
222+
226223 // light.to_pixel and surface.camera_to_pixel are pre-normalized by their builders
227224 float3 L = -light.to_pixel;
228225 float3 V = -surface.camera_to_pixel;
229226 float3 N = surface.normal;
230-
231- // wrapped diffuse, allows light to wrap around the surface
227+
228+ // wrapped diffuse for the front lit half, lets sun energy bleed past the n_dot_l terminator
232229 float n_dot_l_wrapped = saturate ((dot (N, L) + wrap_factor) / (1.0f + wrap_factor));
233230 float wrapped_diffuse = n_dot_l_wrapped * n_dot_l_wrapped;
234-
235- // back scattering translucency, light passing through from behind using a distorted normal
231+
232+ // back scatter translucency for the back lit half, this is the gdc 2011 penner formulation
233+ // where the LIGHT direction is distorted by the surface normal (not the other way around),
234+ // the previous version had the operands swapped so it produced a vector parallel to the normal
235+ // and dot(V, -that) was always negative on the back hemisphere, leaving back lit translucent
236+ // surfaces like grass blades and leaves at the 5 percent min_scatter floor and looking black
237+ //
238+ // H = normalize(L + N * distortion) // light vector pulled toward the normal
239+ // back_scatter = saturate(dot(V, -H)) // peaks when the camera looks toward the light
240+ //
241+ // for the canonical case (camera looks at the sun through a leaf), L is roughly -V, the
242+ // distortion bends H toward the back face normal but L still dominates, -H points roughly
243+ // toward the camera, and dot(V, -H) approaches 1 which is what we want
236244 const float distortion = 0.4f ;
237- float3 N_distorted = normalize (N + L * distortion);
238- float back_scatter = saturate (dot (V, -N_distorted));
239- // pow(x, 3) replaced with mults, sss_exponent is the constant 3
240- back_scatter = back_scatter * back_scatter * back_scatter;
241-
242- // combine forward and backward scattering
245+ float3 L_distorted = normalize (L + N * distortion);
246+ float back_scatter = saturate (dot (V, -L_distorted));
247+ back_scatter = back_scatter * back_scatter * back_scatter; // sharpens the back lit lobe
248+
249+ // combine forward and backward scattering, t crosses 0.5 at the terminator
243250 float sss_term = lerp (back_scatter, wrapped_diffuse, saturate (dot (N, L) * 0.5f + 0.5f ));
244- sss_term = max (sss_term, min_scatter);
245-
246- // thickness modulation, stronger scattering at thin edges
247- float n_dot_v = saturate (dot (N, V));
248- // pow(x, 1.5) replaced with x * sqrt(x), thickness_exponent is the constant 1.5
249- float one_minus_nv = 1.0f - n_dot_v;
250- float view_thickness = one_minus_nv * sqrt (one_minus_nv);
251-
252- // light dependent, backlit areas show more scattering
253- float n_dot_l = saturate (dot (N, L));
254- float light_thickness = 1.0f - n_dot_l;
255-
256- // combine thickness terms
251+ sss_term = max (sss_term, min_scatter);
252+
253+ // thickness modulation, thin grazing edges scatter more, back lit surfaces scatter more
254+ float n_dot_v = saturate (dot (N, V));
255+ float one_minus_nv = 1.0f - n_dot_v;
256+ float view_thickness = one_minus_nv * sqrt (one_minus_nv);
257+ float n_dot_l_clamped = saturate (dot (N, L));
258+ float light_thickness = 1.0f - n_dot_l_clamped;
257259 float thickness_modulation = saturate (view_thickness + light_thickness * 0.5f );
258-
259- // compute light contribution with proper radiance
260- float3 light_radiance = light.radiance;
261-
262- // apply material strength and scale
263- float sss_strength = surface.subsurface_scattering * sss_scale;
264-
265- // Color tinting: preserve material color for subsurface scattering
266- float3 sss_color = surface.albedo;
267-
268- // combine all terms
269- return light_radiance * sss_term * thickness_modulation * sss_strength * sss_color;
260+
261+ float sss_strength = surface.subsurface_scattering * sss_scale;
262+ float3 sss_color = surface.albedo;
263+
264+ return light.radiance * sss_term * thickness_modulation * sss_strength * sss_color;
270265}
271266
272267// evaluates a single light against the surface, accumulates into out parameters
@@ -288,6 +283,14 @@ void evaluate_light(
288283 Light light;
289284 light.Build (light_index, surface);
290285
286+ // raw light energy uncoupled from n_dot_l, used by subsurface scattering which models
287+ // light transmitting through translucent surfaces from the back hemisphere, the canonical
288+ // case (grass blade, leaf, ear) is where the lit side is opposite the viewed side and
289+ // n_dot_l is zero or negative, light.radiance bakes n_dot_l into it so it cannot drive
290+ // sss on its own, the raw form preserves the energy that sss internally redirects through
291+ // its wrap_factor and back_scatter terms
292+ float3 light_radiance_raw = light.color * light.intensity * light.attenuation;
293+
291294 // when restir pt is enabled the initial ris pool now samples every light type
292295 // (directional, point, spot, area) so all direct lighting at the primary is owned by
293296 // restir, skip the analytical surface eval entirely to avoid double counting, volumetric
@@ -301,40 +304,70 @@ void evaluate_light(
301304 float3 L_subsurface = 0.0f ;
302305 float3 L_volumetric = 0.0f ;
303306
304- // light.radiance already bakes attenuation and n_dot_l, zero radiance means no surface contribution
305- bool light_contributes_to_surface = any (light.radiance > 0.0f );
307+ // light can contribute via the standard brdf only when n_dot_l clears (light.radiance > 0)
308+ // light can contribute via subsurface scattering whenever the raw energy is non zero (sss
309+ // fires regardless of n_dot_l, that is its whole purpose), so the gate admits either path
310+ bool light_can_contribute = any (light_radiance_raw > 0.0f );
311+ bool has_brdf = any (light.radiance > 0.0f );
312+ bool has_sss = surface.subsurface_scattering > 0.0f ;
306313
307- if (eval_surface && !surface.is_sky () && !skip_surface_lighting && light_contributes_to_surface )
314+ if (eval_surface && !surface.is_sky () && !skip_surface_lighting && light_can_contribute && (has_brdf || has_sss) )
308315 {
309- // shadow term, ray traced shadows are mutually exclusive with rasterized/screen space shadows
316+ // shadow term is split into a primary and a contact shadow
317+ //
318+ // primary -> ray traced shadow when the cvar and the light enable it, otherwise the
319+ // rasterized cascade / cubemap shadow map, this tells us whether the surface
320+ // is occluded by some OTHER object in the scene (a wall, the terrain, etc)
321+ //
322+ // contact -> screen space contact shadows refine the primary, they capture the small
323+ // features (cascade aliasing, contact under foliage, etc) that neither the
324+ // shadow map nor the ray traced primary resolves well, they used to be
325+ // mutually exclusive with ray traced shadows which lost all contact detail
326+ // whenever ray traced shadows were enabled
327+ //
328+ // primary applies to both brdf and sss, sss is "light reaches this surface at all"
329+ //
330+ // contact applies ONLY to brdf, never to sss, contact shadows march a screen space ray
331+ // from the pixel toward the light and treat every surface (including the leaf itself)
332+ // as opaque, on a translucent material like grass or leaves the ray immediately hits the
333+ // very blade we are shading and reports occluded, that crushed sss to zero on every back
334+ // face and produced the pitch black grass look, the primary shadow already correctly
335+ // handles the "is the blade itself in real shadow" case
310336 bool can_use_rt_shadows = light.has_shadows () && is_ray_traced_shadows_enabled ();
311337
338+ float L_shadow_primary = 1.0f ;
339+ float L_shadow_contact = 1.0f ;
340+
312341 if (can_use_rt_shadows && light.is_directional ())
313342 {
314343 // dedicated screen space pass produces high quality multi sample sun shadows
315- L_shadow = sample_ray_traced_shadow (surface.uv);
316- light.radiance *= L_shadow;
344+ L_shadow_primary = sample_ray_traced_shadow (surface.uv);
317345 }
318346 #ifdef RAY_TRACING_ENABLED
319347 else if (can_use_rt_shadows)
320348 {
321349 // inline ray traced shadow for point spot and area lights
322- L_shadow = trace_inline_shadow_ray (light, surface, float2 (pixel_xy));
323- light.radiance *= L_shadow;
350+ L_shadow_primary = trace_inline_shadow_ray (light, surface, float2 (pixel_xy));
324351 }
325352 #endif
326353 else if (light.has_shadows ())
327354 {
328- L_shadow = compute_shadow (surface, light);
329-
330- if (light.has_shadows_screen_space () && surface.is_opaque ())
331- {
332- L_shadow = min (L_shadow, tex_uav_sss[int3 (pixel_xy, light.screen_space_shadows_slice_index)].x);
333- }
355+ L_shadow_primary = compute_shadow (surface, light);
356+ }
334357
335- light.radiance *= L_shadow;
358+ if (light.has_shadows () && light.has_shadows_screen_space () && surface.is_opaque ())
359+ {
360+ L_shadow_contact = tex_uav_sss[int3 (pixel_xy, light.screen_space_shadows_slice_index)].x;
336361 }
337362
363+ // exposed for output composition (the engine still tracks a single L_shadow for now)
364+ L_shadow = min (L_shadow_primary, L_shadow_contact);
365+
366+ // brdf gets the combined shadow (primary plus contact)
367+ // sss gets only the primary, contact shadows must not block translucent transmission
368+ light.radiance *= L_shadow;
369+ light_radiance_raw *= L_shadow_primary;
370+
338371 AngularInfo angular_info;
339372 angular_info.Build (light, surface);
340373
@@ -348,6 +381,7 @@ void evaluate_light(
348381 }
349382
350383 float3 L_specular_lobes = 0.0f ;
384+ if (has_brdf)
351385 {
352386 if (surface.anisotropic > 0.0f )
353387 {
@@ -367,11 +401,17 @@ void evaluate_light(
367401 {
368402 L_specular_lobes += BRDF_Specular_Sheen (surface, angular_info);
369403 }
404+ }
370405
371- if (surface.subsurface_scattering > 0.0f )
372- {
373- L_subsurface += subsurface_scattering (surface, light, angular_info);
374- }
406+ if (has_sss)
407+ {
408+ // sss uses the raw radiance so it can fire on back faced surfaces, the previous
409+ // code passed light.radiance (n_dot_l baked in) which was zero for the entire
410+ // back hemisphere and left translucent geometry pitch black on its shadowed side
411+ // even when the sun was clearly visible through the material from the camera
412+ Light light_sss = light;
413+ light_sss.radiance = light_radiance_raw;
414+ L_subsurface += subsurface_scattering (surface, light_sss, angular_info);
375415 }
376416
377417 L_specular_sum += L_specular_lobes;
@@ -380,7 +420,7 @@ void evaluate_light(
380420 surface.roughness_alpha = original_roughness_alpha;
381421
382422 // diffuse_precomputed is zero for transparents, skip the eval entirely
383- if (!is_transparent)
423+ if (has_brdf && !is_transparent)
384424 {
385425 L_diffuse_term += BRDF_Diffuse (surface, angular_info);
386426 }
0 commit comments