<p>One point: the Lambert BRDF is not <span class="math-container">$N\cdot L$</span>, it's just the albedo divided by pi. The <span class="math-container">$N \cdot L$</span> factor comes from the <span class="math-container">$\cos \theta$</span> in the rendering equation. So, when sampling with a cosine-weighted distribution the <span class="math-container">$N \cdot L$</span>s and pis will cancel out and you should just be accumulating <span class="math-container">$\frac{1}{N} \sum L_i * \text{albedo}$</span>.</p>
<p>It looks like in your code you're doing this correctly for the cosine-weighted distribution. But then in the case of the uniform hemispherical distribution you're not putting in the needed factor of <span class="math-container">$2(N \cdot L)$</span>. (The factor of 2 comes from dividing by the uniform hemispherical pdf of 1/2π, and canceling the 1/π in the BRDF.) So I think that your uniform sampling method does not give quite correct results here.</p>
<p>I'm not quite sure what you meant by &quot;image doesn't converge to ground truth&quot;. Do you mean that it's still noisy even after quite a lot of samples? The problem could be in your accumulation strategy. Depending on the bit depth of your framebuffer, after some number of frames the contribution of one more frame will be so small as to be smaller than the least-significant bit of the accumulated value. If you're using 16-bit float format for instance this will happen after about 1000 frames; if using R11G11B10_FLOAT then it will take only 30-60 frames before new accumulated values will no longer have any effect. The accumulation framebuffer should be 32-bit float at a minimum.</p>
<p>With multiple bounces, it doesn't look like you're accounting for throughput along a path correctly. You have <code>payload.color += bounceColor * albedo.rgb</code>, but note that this only takes account of the albedo at the current surface. If this is the Nth bounce, the color should be multiplied by the albedos of all previous surfaces in the path—since that's how this light is getting to the camera, by bouncing through all of them. The path payload structure needs to include not just an accumulated color, but a value typically called &quot;throughput&quot;, that contains the product of all the (BRDF * cos(theta) / pdf) factors along the path so far. On each bounce you update the throughput by multiplying in the factor for the latest bounce, then update the color by multiplying the sampled radiance with the accumulated throughput.</p>
<p>Also, do you have any exposure control / tonemapping on the final image? An image with multiple bounces is expected to be brighter overall than the same scene with 1 bounce, as more light is being accumulated. If you don't adjust the exposure and apply a tone curve of some sort (and gamma correction), you can end up with things looking bad/wrong on screen, even if the internal HDR framebuffer is correct.</p>

CC BY-SA 4.0
2020-09-19T19:34:21.337Z
(1/2) Thank you for information about BRDF, I'll fix uniform sampling tomorrow and check results. Regarding image convergance - it is noisy after 5000 samples (please take a look at first post) and it doesn't look different from 100 samples. I am using R8B8G8A8_UNORM, now that I've realized it, it definately pointless to use alpha channel here, it's just default format that I'm using in my function and haven't realized it. I think that payload.color code is correct. BounceColor is color returned after next ray, so it's just recurvise call.
DirectX_Programmer
CC BY-SA 4.0
2020-09-19T19:38:13.037Z
(2/2)Therefore, first it (primary ray) should contain all the information of the path, unless I don't understand it correctly. I was comparing my code to MJP's and it seemed that this part of code works the same - https://github.com/TheRealMJP/DXRPathTracer/blob/master/DXRPathTracer/RayTrace.hlsl ; Here is my exposure - https://github.com/komilll/RTCP/blob/master/RTCP/Shaders/PS_Postprocess.hlsl ; Can you provide more details about applying correct tone curve and gamma correction to an image, with some examples? Great post, thank you Nathan. I'll get back to you tomorrow with tested results.
DirectX_Programmer
CC BY-SA 4.0
2020-09-19T23:20:50.770Z
OK, maybe I misunderstood how the payload stuff works. For tone mapping, it's a pretty huge topic but a good place to start is here: [ACES filmic tone curve](https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/). You would do exposure first, then apply the tone curve, and finally convert the values from linear to sRGB space, see the function $\gamma(u)$ defined here: [sRGB](https://en.wikipedia.org/wiki/SRGB#Specification_of_the_transformation). 8-bit colors are definitely too small to do sample accumulation in—bump that up to 16 bits at a minimum, 32 bits is better.
Nathan Reed
CC BY-SA 4.0
2020-09-21T10:43:13.213Z
I've tested your solution and changing texture format fixed converging image. Also, tone mapping and sRGB fixed oversaturation. I've added appropriate edit to main post to show how images generated now, looks like. Thank you!
DirectX_Programmer
CC BY-SA 4.0
2020-09-19T17:56:32.500Z
2020-09-19T18:14:09.510Z
2020-09-19T18:14:09.510Z
Nathan Reed
<p>I'm in process of creating progressive path tracer using DX12 + DXR. I managed to create working raytracing code + pathtracing code with multiple bounces but I'm experiencing problems stated below (after full RT code):</p>
<pre><code>[shader(&quot;raygeneration&quot;)]
void RayGen()
{
    // Accumulate for limited amount of frames
    if (g_giCB.maxFrames &gt; 0 &amp;&amp; g_giCB.accFrames &gt;= g_giCB.maxFrames)
    {
        return;
    }
    uint2 LaunchIndex = DispatchRaysIndex().xy;
    uint2 LaunchDimensions = DispatchRaysDimensions().xy;
    float4 normalAndDepth = NormalTextureInput.Load(int3(LaunchIndex, 0));

    // No geometry hit - skip pixel and use skybox data
    if (normalAndDepth.w == 0)
    {
        RTOutput[LaunchIndex] = albedoTexture.Load(int3(LaunchIndex, 0));
        return;
    }
    
    // Calculate primary ray direction
    uint seed = initRand(LaunchIndex.x + LaunchIndex.y * LaunchDimensions.x, g_sceneCB.frameCount, 16);
    uint seed2 = 0;
    float2 offset = float2(0, 0);
    if (g_giCB.samplingType == SAMPLE_UNIFORM)
    {
        seed2 = initRand(LaunchIndex.x + LaunchIndex.y * LaunchDimensions.x, g_sceneCB.frameCount, 17);
        offset = HammersleyDistribution(g_giCB.accFrames, g_giCB.maxFrames, uint2(seed, seed2));
    }
    else if (g_giCB.samplingType == SAMPLE_MJ)
    {
        const uint pixelIdx = LaunchIndex.y * LaunchDimensions.x + LaunchIndex.x;
        uint sampleSetIdx = 0;
        offset = SamplePoint(pixelIdx, sampleSetIdx);
        seed = pixelIdx;
        seed2 = sampleSetIdx;
    }
    
    float3 primaryRayOrigin = g_sceneCB.cameraPosition.xyz;
    float3 primaryRayDirection;
    GenerateCameraRay(LaunchIndex, LaunchDimensions, g_sceneCB.projectionToWorld, primaryRayOrigin, primaryRayDirection, offset);
    
    // Prepare payload
    PayloadIndirect indirectPayload;
    indirectPayload.color = float3(0, 0, 0);
    indirectPayload.rndSeed = seed;
    indirectPayload.rndSeed2 = seed2;
    indirectPayload.pathLength = 0;
        
    // Calculate pixel color in current pass and merge with previous frames
    float4 finalColor = float4(shootIndirectRay(primaryRayOrigin, primaryRayDirection, 1e-3f, indirectPayload), 1.0f);
    float4 prevScene = RTOutput[LaunchIndex];
    finalColor = ((float) g_giCB.accFrames * prevScene + finalColor) / ((float) g_giCB.accFrames + 1.0f);
    RTOutput[LaunchIndex] = finalColor;
}

[shader(&quot;miss&quot;)]
void Miss(inout RayPayload payload : SV_RayPayload)
{
    payload.vis = 1.0f;
}

[shader(&quot;closesthit&quot;)]
void ClosestHit(inout PayloadIndirect payload, in BuiltInTriangleIntersectionAttributes attribs)
{

}

[shader(&quot;miss&quot;)]
void MissIndirect(inout PayloadIndirect payload : SV_RayPayload)
{
    // Use skybox as contribution if ray failed to hit geometry (right now, disabled for debug purposes)
    float3 rayDir = WorldRayDirection();
    rayDir.z = -rayDir.z;
    if (g_giCB.useSkybox)
    {
        payload.color += skyboxTexture.SampleLevel(g_sampler, rayDir, 0).rgb;
    }
}

[shader(&quot;closesthit&quot;)]
void ClosestHitIndirect(inout PayloadIndirect payload, in BuiltInTriangleIntersectionAttributes attribs)
{
    // Load hit data
    float3 hitPos = WorldRayOrigin() + WorldRayDirection() * RayTCurrent();
    float3 triangleNormal, triangleTangent, triangleBitangent;
    loadHitData(triangleNormal, triangleTangent, triangleBitangent, attribs);

    // Use white albedo for all textures (DEBUG version)
    float4 albedo = albedoTexture.Load(int3(DispatchRaysIndex().xy, 0));
    albedo = float4(1, 1, 1, 1);
    
    // Iterate over all lights
    float lightsCount = g_lightCB.lightPositionAndType[15].w;
    for (int i = 0; i &lt; lightsCount; i++)
    {
        // Calculate each light data
        float3 lightColor = g_lightCB.lightDiffuseColor[i].rgb;
        float3 toLight = g_lightCB.lightPositionAndType[i].xyz - hitPos;
        float distToLight = length(toLight);
        toLight = normalize(toLight);
        
        // Check visibility
        float NoL = saturate(dot(triangleNormal.xyz, toLight));
        float visibility = shadowRayVisibility(hitPos, toLight, 1e-3f, distToLight);

        // Calculate light contribution to point in world (diffuse lambertian term)
        payload.color += visibility * NoL * albedo.rgb * INV_PI;
    }
    
    if (g_giCB.useIndirect == 1)
    {
        // Continue spawning rays if path left has not reached maximum
        if (payload.pathLength &lt; g_giCB.bounceCount)
        {
            // Find next direction
            float3 rayDirWS = float3(0, 0, 0);
            if (g_giCB.samplingType == SAMPLE_UNIFORM)
            {
                float3x3 tangentToWorld = float3x3(triangleTangent, triangleBitangent, triangleNormal);
                float2 hammersley = HammersleyDistribution(payload.pathLength, g_giCB.bounceCount, uint2(payload.rndSeed, payload.rndSeed2));
                float3 rayDirTS = UniformSampleHemisphere(hammersley.x, hammersley.y);
                rayDirWS = normalize(mul(rayDirTS, tangentToWorld));
            }
            else if (g_giCB.samplingType == SAMPLE_MJ)
            {
                float3x3 tangentToWorld = float3x3(triangleTangent, triangleBitangent, triangleNormal);
                float2 brdfSample = SamplePoint(payload.rndSeed, payload.rndSeed2);
                float3 rayDirTS = SampleDirectionCosineHemisphere(brdfSample.x, brdfSample.y);
                rayDirWS = normalize(mul(rayDirTS, tangentToWorld));
            }
            else if (g_giCB.samplingType == SAMPLE_RANDOM)
            {
                rayDirWS = getCosHemisphereSample(payload.rndSeed, triangleNormal, triangleTangent, triangleBitangent);
                nextRand(payload.rndSeed);
            }
            
            // Prepare payload
            PayloadIndirect newPayload;
            newPayload.pathLength = payload.pathLength + 1;
            newPayload.rndSeed = payload.rndSeed;
            newPayload.rndSeed2 = payload.rndSeed2;
            newPayload.color = float3(0, 0, 0);
            
            // Calculate next ray bounce color contribution
            float3 bounceColor = shootIndirectRay(hitPos, rayDirWS, 1e-3f, newPayload);
            payload.color += bounceColor * albedo.rgb;
        }
    }
}
</code></pre>
<p><strong>1. Image doesn't converge to ground truth over time</strong></p>
<p>I am using only Lambertian BRDF term in my code, while implementing multi sampling schemes (Multi-Jittered sampling, Uniform sampling and random direction sampling). All of them provides similar results, hardly possible to distinguish. Here is an image generated with almost 5000 frames. It doesn't differ from image generated with about 100 frames. I am using albedo = (1,1,1) for all textures, for debug purposes in this image:</p>
<p><a href="https://i.sstatic.net/pBCoI.jpg" rel="nofollow noreferrer"><img src="https://i.sstatic.net/pBCoI.jpg" alt="enter image description here" /></a></p>
<p>It looks even much more noisy when source of light is outside the window and the you can can never access light directly except primary ray (situation similar to described here - <a href="http://www.pbr-book.org/3ed-2018/Light_Transport_III_Bidirectional_Methods/Bidirectional_Path_Tracing.html" rel="nofollow noreferrer">http://www.pbr-book.org/3ed-2018/Light_Transport_III_Bidirectional_Methods/Bidirectional_Path_Tracing.html</a> ). Therefore I placed point light in the middle of room for simpler example.</p>
<p>I'm not suspecting my equation to be incorrect, because:
<span class="math-container">$$
L_o(\textbf{p}, \mathbf{w_o}) = L_e + \int_{\Omega} L_i(\textbf{p}, \mathbf{w_i}) fr(\mathbf{w_o}, \mathbf{w_i}) \cos \theta d\omega
$$</span></p>
<p><span class="math-container">$$
\frac{1}{N} \sum_{k=1}^{N} \frac{ L_i(\textbf{p}, \mathbf{w_k}) fr(\mathbf{w_k}, w_o) \cos \theta }{p(\mathbf{w_k})}
$$</span></p>
<p>Monte Carlo equation above will be simplified, because I have single light with intensity = 1.0, so <span class="math-container">$L_i$</span> term will be terminated. BRDF for Lambertian diffuse is equal to NdotL (I'll be using notation <span class="math-container">$NoL$</span>). PDF for sampling cosine hemisphere is <span class="math-container">$NoL / \pi$</span>. Let's simplify equation for now:</p>
<p><span class="math-container">$$
\frac{1}{N} \sum_{k=1}^{N} \frac{ \cos \theta }{\pi}
$$</span></p>
<p>Which is basically:
<span class="math-container">$$
\frac{1}{N} \sum_{k=1}^{N} \frac{ NoL }{\pi}
$$</span></p>
<p>And translates to code as:</p>
<pre><code>color = NoL * INV_PI;
</code></pre>
<p>However, we need to check if light <span class="math-container">$L$</span> that we're referring to is visible, therefore full equation, which could be seen in code is:</p>
<pre><code>// Calculate light contribution to point in world (diffuse lambertian term)
payload.color += visibility * NoL * albedo.rgb * INV_PI;
</code></pre>
<p>Regarding sampling, I've tried multiple method, especially I was hopeful about Multi-Jittered sampling <a href="https://graphics.pixar.com/library/MultiJitteredSampling/paper.pdf" rel="nofollow noreferrer">[Kensler2013]</a>. I am using native implementation, copied from <a href="https://github.com/TheRealMJP/DXRPathTracer" rel="nofollow noreferrer">MJP's Path Tracer</a>.</p>
<p><strong>2. Oversaturation of albedo textures</strong></p>
<p><a href="https://i.sstatic.net/ywhkI.jpg" rel="nofollow noreferrer"><img src="https://i.sstatic.net/ywhkI.jpg" alt="Primary ray + single bounce image with albedo textures" /></a>
<em>Primary ray + single bounce image with albedo textures</em></p>
<p><a href="https://i.sstatic.net/Lm27V.jpg" rel="nofollow noreferrer"><img src="https://i.sstatic.net/Lm27V.jpg" alt="Primary ray + 4 ray bounces image; Notice oversaturation of albedo textures" /></a>
<em>Primary ray + 4 ray bounces image; Notice oversaturation of albedo textures</em></p>
<p>If I simplify code to the maximum, then ray bounces boils down to very few lines of code used in practice. The problem might be adding albedo too many times but I checked it for sure, and both MJP and <a href="http://cwyman.org/code/dxrTutors/tutors/Tutor14/tutorial14.md.html" rel="nofollow noreferrer">cwyman</a> are using same code for calculating path tracing (multiple bounces of rays).</p>
<pre><code>[shader(&quot;closesthit&quot;)]
void ClosestHitIndirect(inout PayloadIndirect payload, in BuiltInTriangleIntersectionAttributes attribs)
{
    float4 albedo = // ...
    
    // Use single light
    float3 toLight = g_lightCB.lightPositionAndType[0].xyz - hitPos;
    float distToLight = length(toLight);
    toLight = normalize(toLight);
        
    // Check visibility
    float NoL = saturate(dot(triangleNormal.xyz, toLight));
    float visibility = shadowRayVisibility(hitPos, toLight, 1e-3f, distToLight);

    // Calculate light contribution to point in world (diffuse lambertian term)
    payload.color += visibility * NoL * albedo.rgb * INV_PI;
    
    if (g_giCB.useIndirect == 1)
    {
        // Continue spawning rays if path left has not reached maximum
        if (payload.pathLength &lt; g_giCB.bounceCount)
        {
            // Find next direction
            float3 rayDirWS = // ... multiple lighting schemes, check full code above
            
            // Prepare payload
            PayloadIndirect newPayload;
            newPayload.pathLength = payload.pathLength + 1;
            newPayload.rndSeed = payload.rndSeed;
            newPayload.rndSeed2 = payload.rndSeed2;
            newPayload.color = float3(0, 0, 0);
            
            // Calculate next ray bounce color contribution
            float3 bounceColor = shootIndirectRay(hitPos, rayDirWS, 1e-3f, newPayload);
            payload.color += bounceColor * albedo.rgb;
        }
    }
}
</code></pre>
<p>For now, I'd like to creating converging diffuse path tracer. I'll continue with Specular GGX in order to have a ground truth reference for Bidirectional Path Tracing and other methods, which can speed up converging rate of an image. Thanks in advance for all your advices.</p>
<p>Also, for future readers - I'd recommend checking questions below, which are related to the topic, but didn't manage to solve my problems:</p>
<ul>
<li><a href="https://computergraphics.stackexchange.com/questions/3972/is-it-expected-that-a-naive-path-tracer-takes-many-many-samples-to-converge">Is it expected that a naive path tracer takes many, many samples to converge?</a></li>
<li><a href="https://computergraphics.stackexchange.com/questions/245/non-real-time-raytracing">Non Real Time Raytracing</a></li>
<li><a href="https://computergraphics.stackexchange.com/questions/4394/path-tracing-the-cook-torrance-brdf?rq=1">Path tracing the Cook-Torrance BRDF</a></li>
</ul>
<hr />
<p><strong>Edit after <a href="https://computergraphics.stackexchange.com/users/48/nathan-reed">Nathan's Reed</a> answer:</strong></p>
<p>The oversaturated image looks better now. It looks a bit dull after moving from linear to sRGB space (following Nathan's order of exposure -&gt; tone mapping -&gt; linear_To_sRGB):</p>
<p><a href="https://i.sstatic.net/LdCy3.jpg" rel="nofollow noreferrer"><img src="https://i.sstatic.net/LdCy3.jpg" alt="enter image description here" /></a>
<em>Primary ray + 4 ray bounces image; No skybox miss shader applied to fair comparison to oversaturated image before</em></p>
<p>However, I checked case with light source from the outside and I really like the result. There is a room for improvement, for sure, like adding some AA, moving to PBR (GGX). I doubt I'll have a time for that, but using BDPT and implementing soft shadows would definatelly improve case shown below:</p>
<p><a href="https://i.sstatic.net/MoQEu.jpg" rel="nofollow noreferrer"><img src="https://i.sstatic.net/MoQEu.jpg" alt="enter image description here" /></a></p>
<p><strong>To sum up for future reference:</strong> adding tone mapper (on top of existing exposure settings) and moving from linear to sRGB space helped with oversaturation. Using R16G16B16A16_FLOAT instead of R8B8G8A8_UNORM format for backbuffer and target output textures, solved problem with not converging images. In the future, R32G32B32A32_FLOAT might be used, but with my current DX12 setup, this format causes errors when used in backbuffer and I cannot change that for now. 4x16 format proved to be enough for now, so I'll stay with that.</p>

<raytracing><pathtracing><sampling><hlsl><directx12>
Path tracing - cannot converge diffuse sampling + oversaturation
DirectX_Programmer
# Path tracing - cannot converge diffuse sampling + oversaturation

(Asked by: username_0 on 2020-09-19)

I'm in process of creating progressive path tracer using DX12 + DXR. I managed to create working raytracing code + pathtracing code with multiple bounces but I'm experiencing problems stated below (after full RT code):

```
[shader("raygeneration")]
void RayGen()
{
    // Accumulate for limited amount of frames
    if (g_giCB.maxFrames > 0 && g_giCB.accFrames >= g_giCB.maxFrames)
    {
        return;
    }
    uint2 LaunchIndex = DispatchRaysIndex().xy;
    uint2 LaunchDimensions = DispatchRaysDimensions().xy;
    float4 normalAndDepth = NormalTextureInput.Load(int3(LaunchIndex, 0));

    // No geometry hit - skip pixel and use skybox data
    if (normalAndDepth.w == 0)
    {
        RTOutput[LaunchIndex] = albedoTexture.Load(int3(LaunchIndex, 0));
        return;
    }
    
    // Calculate primary ray direction
    uint seed = initRand(LaunchIndex.x + LaunchIndex.y * LaunchDimensions.x, g_sceneCB.frameCount, 16);
    uint seed2 = 0;
    float2 offset = float2(0, 0);
    if (g_giCB.samplingType == SAMPLE_UNIFORM)
    {
        seed2 = initRand(LaunchIndex.x + LaunchIndex.y * LaunchDimensions.x, g_sceneCB.frameCount, 17);
        offset = HammersleyDistribution(g_giCB.accFrames, g_giCB.maxFrames, uint2(seed, seed2));
    }
    else if (g_giCB.samplingType == SAMPLE_MJ)
    {
        const uint pixelIdx = LaunchIndex.y * LaunchDimensions.x + LaunchIndex.x;
        uint sampleSetIdx = 0;
        offset = SamplePoint(pixelIdx, sampleSetIdx);
        seed = pixelIdx;
        seed2 = sampleSetIdx;
    }
    
    float3 primaryRayOrigin = g_sceneCB.cameraPosition.xyz;
    float3 primaryRayDirection;
    GenerateCameraRay(LaunchIndex, LaunchDimensions, g_sceneCB.projectionToWorld, primaryRayOrigin, primaryRayDirection, offset);
    
    // Prepare payload
    PayloadIndirect indirectPayload;
    indirectPayload.color = float3(0, 0, 0);
    indirectPayload.rndSeed = seed;
    indirectPayload.rndSeed2 = seed2;
    indirectPayload.pathLength = 0;
        
    // Calculate pixel color in current pass and merge with previous frames
    float4 finalColor = float4(shootIndirectRay(primaryRayOrigin, primaryRayDirection, 1e-3f, indirectPayload), 1.0f);
    float4 prevScene = RTOutput[LaunchIndex];
    finalColor = ((float) g_giCB.accFrames * prevScene + finalColor) / ((float) g_giCB.accFrames + 1.0f);
    RTOutput[LaunchIndex] = finalColor;
}

[shader("miss")]
void Miss(inout RayPayload payload : SV_RayPayload)
{
    payload.vis = 1.0f;
}

[shader("closesthit")]
void ClosestHit(inout PayloadIndirect payload, in BuiltInTriangleIntersectionAttributes attribs)
{

}

[shader("miss")]
void MissIndirect(inout PayloadIndirect payload : SV_RayPayload)
{
    // Use skybox as contribution if ray failed to hit geometry (right now, disabled for debug purposes)
    float3 rayDir = WorldRayDirection();
    rayDir.z = -rayDir.z;
    if (g_giCB.useSkybox)
    {
        payload.color += skyboxTexture.SampleLevel(g_sampler, rayDir, 0).rgb;
    }
}

[shader("closesthit")]
void ClosestHitIndirect(inout PayloadIndirect payload, in BuiltInTriangleIntersectionAttributes attribs)
{
    // Load hit data
    float3 hitPos = WorldRayOrigin() + WorldRayDirection() * RayTCurrent();
    float3 triangleNormal, triangleTangent, triangleBitangent;
    loadHitData(triangleNormal, triangleTangent, triangleBitangent, attribs);

    // Use white albedo for all textures (DEBUG version)
    float4 albedo = albedoTexture.Load(int3(DispatchRaysIndex().xy, 0));
    albedo = float4(1, 1, 1, 1);
    
    // Iterate over all lights
    float lightsCount = g_lightCB.lightPositionAndType[15].w;
    for (int i = 0; i < lightsCount; i++)
    {
        // Calculate each light data
        float3 lightColor = g_lightCB.lightDiffuseColor[i].rgb;
        float3 toLight = g_lightCB.lightPositionAndType[i].xyz - hitPos;
        float distToLight = length(toLight);
        toLight = normalize(toLight);
        
        // Check visibility
        float NoL = saturate(dot(triangleNormal.xyz, toLight));
        float visibility = shadowRayVisibility(hitPos, toLight, 1e-3f, distToLight);

        // Calculate light contribution to point in world (diffuse lambertian term)
        payload.color += visibility * NoL * albedo.rgb * INV_PI;
    }
    
    if (g_giCB.useIndirect == 1)
    {
        // Continue spawning rays if path left has not reached maximum
        if (payload.pathLength < g_giCB.bounceCount)
        {
            // Find next direction
            float3 rayDirWS = float3(0, 0, 0);
            if (g_giCB.samplingType == SAMPLE_UNIFORM)
            {
                float3x3 tangentToWorld = float3x3(triangleTangent, triangleBitangent, triangleNormal);
                float2 hammersley = HammersleyDistribution(payload.pathLength, g_giCB.bounceCount, uint2(payload.rndSeed, payload.rndSeed2));
                float3 rayDirTS = UniformSampleHemisphere(hammersley.x, hammersley.y);
                rayDirWS = normalize(mul(rayDirTS, tangentToWorld));
            }
            else if (g_giCB.samplingType == SAMPLE_MJ)
            {
                float3x3 tangentToWorld = float3x3(triangleTangent, triangleBitangent, triangleNormal);
                float2 brdfSample = SamplePoint(payload.rndSeed, payload.rndSeed2);
                float3 rayDirTS = SampleDirectionCosineHemisphere(brdfSample.x, brdfSample.y);
                rayDirWS = normalize(mul(rayDirTS, tangentToWorld));
            }
            else if (g_giCB.samplingType == SAMPLE_RANDOM)
            {
                rayDirWS = getCosHemisphereSample(payload.rndSeed, triangleNormal, triangleTangent, triangleBitangent);
                nextRand(payload.rndSeed);
            }
            
            // Prepare payload
            PayloadIndirect newPayload;
            newPayload.pathLength = payload.pathLength + 1;
            newPayload.rndSeed = payload.rndSeed;
            newPayload.rndSeed2 = payload.rndSeed2;
            newPayload.color = float3(0, 0, 0);
            
            // Calculate next ray bounce color contribution
            float3 bounceColor = shootIndirectRay(hitPos, rayDirWS, 1e-3f, newPayload);
            payload.color += bounceColor * albedo.rgb;
        }
    }
}

```

**1. Image doesn't converge to ground truth over time**

I am using only Lambertian BRDF term in my code, while implementing multi sampling schemes (Multi-Jittered sampling, Uniform sampling and random direction sampling). All of them provides similar results, hardly possible to distinguish. Here is an image generated with almost 5000 frames. It doesn't differ from image generated with about 100 frames. I am using albedo = (1,1,1) for all textures, for debug purposes in this image:

[![enter image description here](https://i.sstatic.net/pBCoI.jpg)](https://i.sstatic.net/pBCoI.jpg)

It looks even much more noisy when source of light is outside the window and the you can can never access light directly except primary ray (situation similar to described here - <http://www.pbr-book.org/3ed-2018/Light_Transport_III_Bidirectional_Methods/Bidirectional_Path_Tracing.html> ). Therefore I placed point light in the middle of room for simpler example.

I'm not suspecting my equation to be incorrect, because:
$$
L\_o(\textbf{p}, \mathbf{w\_o}) = L\_e + \int\_{\Omega} L\_i(\textbf{p}, \mathbf{w\_i}) fr(\mathbf{w\_o}, \mathbf{w\_i}) \cos \theta d\omega
$$

$$
\frac{1}{N} \sum\_{k=1}^{N} \frac{ L\_i(\textbf{p}, \mathbf{w\_k}) fr(\mathbf{w\_k}, w\_o) \cos \theta }{p(\mathbf{w\_k})}
$$

Monte Carlo equation above will be simplified, because I have single light with intensity = 1.0, so $L\_i$ term will be terminated. BRDF for Lambertian diffuse is equal to NdotL (I'll be using notation $NoL$). PDF for sampling cosine hemisphere is $NoL / \pi$. Let's simplify equation for now:

$$
\frac{1}{N} \sum\_{k=1}^{N} \frac{ \cos \theta }{\pi}
$$

Which is basically:
$$
\frac{1}{N} \sum\_{k=1}^{N} \frac{ NoL }{\pi}
$$

And translates to code as:

```
color = NoL * INV_PI;

```

However, we need to check if light $L$ that we're referring to is visible, therefore full equation, which could be seen in code is:

```
// Calculate light contribution to point in world (diffuse lambertian term)
payload.color += visibility * NoL * albedo.rgb * INV_PI;

```

Regarding sampling, I've tried multiple method, especially I was hopeful about Multi-Jittered sampling [[Kensler2013]](https://graphics.pixar.com/library/MultiJitteredSampling/paper.pdf). I am using native implementation, copied from [MJP's Path Tracer](https://github.com/TheRealMJP/DXRPathTracer).

**2. Oversaturation of albedo textures**

[![Primary ray + single bounce image with albedo textures](https://i.sstatic.net/ywhkI.jpg)](https://i.sstatic.net/ywhkI.jpg)
*Primary ray + single bounce image with albedo textures*

[![Primary ray + 4 ray bounces image; Notice oversaturation of albedo textures](https://i.sstatic.net/Lm27V.jpg)](https://i.sstatic.net/Lm27V.jpg)
*Primary ray + 4 ray bounces image; Notice oversaturation of albedo textures*

If I simplify code to the maximum, then ray bounces boils down to very few lines of code used in practice. The problem might be adding albedo too many times but I checked it for sure, and both MJP and [cwyman](http://cwyman.org/code/dxrTutors/tutors/Tutor14/tutorial14.md.html) are using same code for calculating path tracing (multiple bounces of rays).

```
[shader("closesthit")]
void ClosestHitIndirect(inout PayloadIndirect payload, in BuiltInTriangleIntersectionAttributes attribs)
{
    float4 albedo = // ...
    
    // Use single light
    float3 toLight = g_lightCB.lightPositionAndType[0].xyz - hitPos;
    float distToLight = length(toLight);
    toLight = normalize(toLight);
        
    // Check visibility
    float NoL = saturate(dot(triangleNormal.xyz, toLight));
    float visibility = shadowRayVisibility(hitPos, toLight, 1e-3f, distToLight);

    // Calculate light contribution to point in world (diffuse lambertian term)
    payload.color += visibility * NoL * albedo.rgb * INV_PI;
    
    if (g_giCB.useIndirect == 1)
    {
        // Continue spawning rays if path left has not reached maximum
        if (payload.pathLength < g_giCB.bounceCount)
        {
            // Find next direction
            float3 rayDirWS = // ... multiple lighting schemes, check full code above
            
            // Prepare payload
            PayloadIndirect newPayload;
            newPayload.pathLength = payload.pathLength + 1;
            newPayload.rndSeed = payload.rndSeed;
            newPayload.rndSeed2 = payload.rndSeed2;
            newPayload.color = float3(0, 0, 0);
            
            // Calculate next ray bounce color contribution
            float3 bounceColor = shootIndirectRay(hitPos, rayDirWS, 1e-3f, newPayload);
            payload.color += bounceColor * albedo.rgb;
        }
    }
}

```

For now, I'd like to creating converging diffuse path tracer. I'll continue with Specular GGX in order to have a ground truth reference for Bidirectional Path Tracing and other methods, which can speed up converging rate of an image. Thanks in advance for all your advices.

Also, for future readers - I'd recommend checking questions below, which are related to the topic, but didn't manage to solve my problems:

* [Is it expected that a naive path tracer takes many, many samples to converge?](https://computergraphics.stackexchange.com/questions/3972/is-it-expected-that-a-naive-path-tracer-takes-many-many-samples-to-converge)
* [Non Real Time Raytracing](https://computergraphics.stackexchange.com/questions/245/non-real-time-raytracing)
* [Path tracing the Cook-Torrance BRDF](https://computergraphics.stackexchange.com/questions/4394/path-tracing-the-cook-torrance-brdf?rq=1)

---

**Edit after [username_1's username_1](https://computergraphics.stackexchange.com/users/48/username_1-username_1) answer:**

The oversaturated image looks better now. It looks a bit dull after moving from linear to sRGB space (following username_1's order of exposure -> tone mapping -> linear\_To\_sRGB):

[![enter image description here](https://i.sstatic.net/LdCy3.jpg)](https://i.sstatic.net/LdCy3.jpg)
*Primary ray + 4 ray bounces image; No skybox miss shader applied to fair comparison to oversaturated image before*

However, I checked case with light source from the outside and I really like the result. There is a room for improvement, for sure, like adding some AA, moving to PBR (GGX). I doubt I'll have a time for that, but using BDPT and implementing soft shadows would definatelly improve case shown below:

[![enter image description here](https://i.sstatic.net/MoQEu.jpg)](https://i.sstatic.net/MoQEu.jpg)

**To sum up for future reference:** adding tone mapper (on top of existing exposure settings) and moving from linear to sRGB space helped with oversaturation. Using R16G16B16A16\_FLOAT instead of R8B8G8A8\_UNORM format for backbuffer and target output textures, solved problem with not converging images. In the future, R32G32B32A32\_FLOAT might be used, but with my current DX12 setup, this format causes errors when used in backbuffer and I cannot change that for now. 4x16 format proved to be enough for now, so I'll stay with that.

**Tags:** raytracing, pathtracing, sampling, hlsl, directx12


---

## 1 answers

### Answer by username_1 on 2020-09-19. Score: 4 | Accepted answer

One point: the Lambert BRDF is not $N\cdot L$, it's just the albedo divided by pi. The $N \cdot L$ factor comes from the $\cos \theta$ in the rendering equation. So, when sampling with a cosine-weighted distribution the $N \cdot L$s and pis will cancel out and you should just be accumulating $\frac{1}{N} \sum L\_i \* \text{albedo}$.

It looks like in your code you're doing this correctly for the cosine-weighted distribution. But then in the case of the uniform hemispherical distribution you're not putting in the needed factor of $2(N \cdot L)$. (The factor of 2 comes from dividing by the uniform hemispherical pdf of 1/2π, and canceling the 1/π in the BRDF.) So I think that your uniform sampling method does not give quite correct results here.

I'm not quite sure what you meant by "image doesn't converge to ground truth". Do you mean that it's still noisy even after quite a lot of samples? The problem could be in your accumulation strategy. Depending on the bit depth of your framebuffer, after some number of frames the contribution of one more frame will be so small as to be smaller than the least-significant bit of the accumulated value. If you're using 16-bit float format for instance this will happen after about 1000 frames; if using R11G11B10\_FLOAT then it will take only 30-60 frames before new accumulated values will no longer have any effect. The accumulation framebuffer should be 32-bit float at a minimum.

With multiple bounces, it doesn't look like you're accounting for throughput along a path correctly. You have `payload.color += bounceColor * albedo.rgb`, but note that this only takes account of the albedo at the current surface. If this is the Nth bounce, the color should be multiplied by the albedos of all previous surfaces in the path—since that's how this light is getting to the camera, by bouncing through all of them. The path payload structure needs to include not just an accumulated color, but a value typically called "throughput", that contains the product of all the (BRDF \* cos(theta) / pdf) factors along the path so far. On each bounce you update the throughput by multiplying in the factor for the latest bounce, then update the color by multiplying the sampled radiance with the accumulated throughput.

Also, do you have any exposure control / tonemapping on the final image? An image with multiple bounces is expected to be brighter overall than the same scene with 1 bounce, as more light is being accumulated. If you don't adjust the exposure and apply a tone curve of some sort (and gamma correction), you can end up with things looking bad/wrong on screen, even if the internal HDR framebuffer is correct.

> **Comments:**
> **username_0**: (1/2) Thank you for information about BRDF, I'll fix uniform sampling tomorrow and check results. Regarding image convergance - it is noisy after 5000 samples (please take a look at first post) and it doesn't look different from 100 samples. I am using R8B8G8A8_UNORM, now that I've realized it, it definately pointless to use alpha channel here, it's just default format that I'm using in my function and haven't realized it. I think that payload.color code is correct. BounceColor is color returned after next ray, so it's just recurvise call.  
> **username_0**: (2/2)Therefore, first it (primary ray) should contain all the information of the path, unless I don't understand it correctly. I was comparing my code to MJP's and it seemed that this part of code works the same - https://github.com/TheRealMJP/DXRPathTracer/blob/master/DXRPathTracer/RayTrace.hlsl ; Here is my exposure - https://github.com/komilll/RTCP/blob/master/RTCP/Shaders/PS_Postprocess.hlsl ; Can you provide more details about applying correct tone curve and gamma correction to an image, with some examples? Great post, thank you username_1. I'll get back to you tomorrow with tested results.  
> **username_1**: OK, maybe I misunderstood how the payload stuff works. For tone mapping, it's a pretty huge topic but a good place to start is here: [ACES filmic tone curve](https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/). You would do exposure first, then apply the tone curve, and finally convert the values from linear to sRGB space, see the function $\gamma(u)$ defined here: [sRGB](https://en.wikipedia.org/wiki/SRGB#Specification_of_the_transformation). 8-bit colors are definitely too small to do sample accumulation in—bump that up to 16 bits at a minimum, 32 bits is better.  
> **username_0**: I've tested your solution and changing texture format fixed converging image. Also, tone mapping and sRGB fixed oversaturation. I've added appropriate edit to main post to show how images generated now, looks like. Thank you!  

---

