Context
Follow-up from PR #1292 (material shading fixes) — see review comment: #1292 (comment)
Issue
In computeIBLContribution (Sources/UntoldEngine/Shaders/LightShader.metal), the diffuse term is attenuated by the full multi-bounce-compensated reflected value returned by environmentReflectance (which includes the Kulla/Conty laterBounces energy-compensation factor), rather than the raw single-bounce reflectance (reflectedInOneBounce).
Physically, the energy "taken from" diffuse should be the single-bounce specular share at that viewing angle. The multi-bounce compensation exists to recover energy lost by the specular term's own approximation (accounting for light bouncing between a rough surface's microfacets before escaping) — it isn't a measure of how much the surface diverts away from diffuse scattering in the first place. Using the compensated value for both purposes conflates the two.
Why it hasn't caused a visible bug yet
- For non-metals,
F0 = 0.04 keeps laterBounces close to 1, so the error is small.
- For metals,
(1 - metallic) = 0 zeroes out the diffuse term entirely, so the discrepancy never shows up.
None of the new render tests in #1292 caught this because they don't target the specific case where it would matter: a non-metal with high roughness where laterBounces deviates furthest from 1.
Suggested fix
Use reflectedInOneBounce (already computed inside environmentReflectance) for the diffuse-energy-conservation term, and keep the fully compensated reflected value only for the specular contribution. This likely means exposing reflectedInOneBounce from environmentReflectance (e.g. as an additional return value or a second small helper) rather than just the final compensated color.
Acceptance criteria
- Diffuse attenuation in
computeIBLContribution uses single-bounce reflectance, not the multi-bounce-compensated one.
- Existing
EnvironmentReflectionShadingTests (especially testAWhiteSurfaceGivesBackAllTheLightWhateverItsFinish and testARoughColoredMetalStaysWithinItsColor) still pass.
- Consider adding a test for a rough, high-F0-adjacent non-metal (e.g. roughness 1.0, base color gray) to pin down the expected diffuse/specular split precisely enough to catch regressions here.
Context
Follow-up from PR #1292 (material shading fixes) — see review comment: #1292 (comment)
Issue
In
computeIBLContribution(Sources/UntoldEngine/Shaders/LightShader.metal), the diffuse term is attenuated by the full multi-bounce-compensatedreflectedvalue returned byenvironmentReflectance(which includes the Kulla/ContylaterBouncesenergy-compensation factor), rather than the raw single-bounce reflectance (reflectedInOneBounce).Physically, the energy "taken from" diffuse should be the single-bounce specular share at that viewing angle. The multi-bounce compensation exists to recover energy lost by the specular term's own approximation (accounting for light bouncing between a rough surface's microfacets before escaping) — it isn't a measure of how much the surface diverts away from diffuse scattering in the first place. Using the compensated value for both purposes conflates the two.
Why it hasn't caused a visible bug yet
F0 = 0.04keepslaterBouncesclose to 1, so the error is small.(1 - metallic) = 0zeroes out the diffuse term entirely, so the discrepancy never shows up.None of the new render tests in #1292 caught this because they don't target the specific case where it would matter: a non-metal with high roughness where
laterBouncesdeviates furthest from 1.Suggested fix
Use
reflectedInOneBounce(already computed insideenvironmentReflectance) for the diffuse-energy-conservation term, and keep the fully compensatedreflectedvalue only for the specular contribution. This likely means exposingreflectedInOneBouncefromenvironmentReflectance(e.g. as an additional return value or a second small helper) rather than just the final compensated color.Acceptance criteria
computeIBLContributionuses single-bounce reflectance, not the multi-bounce-compensated one.EnvironmentReflectionShadingTests(especiallytestAWhiteSurfaceGivesBackAllTheLightWhateverItsFinishandtestARoughColoredMetalStaysWithinItsColor) still pass.