I'm creating DXR PathTracer highly influence by Matt Pettineo's one - https://github.com/TheRealMJP/DXRPathTracer ; Relevant HLSL code below:

void RayGen()
    // Accumulate for limited amount of frames
    if (g_giCB.maxFrames > 0 && g_giCB.accFrames >= g_giCB.maxFrames)
    uint2 LaunchIndex = DispatchRaysIndex().xy;
    uint2 LaunchDimensions = DispatchRaysDimensions().xy;
    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.roughness = 0;
    indirectPayload.rndSeed = seed;
    indirectPayload.rndSeed2 = seed2;
    indirectPayload.pathLength = 0;
    indirectPayload.diffusePath = false;
    // Calculate pixel color in current pass and merge with previous frames
    float4 finalColor = float4(shootIndirectRay(primaryRayOrigin, primaryRayDirection, 1e-3f, indirectPayload), 1.0f);
    bool colorsNan = any(isnan(finalColor));
    if (colorsNan)
        finalColor = float4(0, 0, 0, 1);
    const float FP16Max = 65000.0f;
    finalColor = clamp(finalColor, 0.0f, FP16Max);
    if (g_giCB.accFrames > 0)
        float4 prevScene = RTOutput[LaunchIndex];
        finalColor = ((float) g_giCB.accFrames * prevScene + finalColor) / ((float) g_giCB.accFrames + 1.0f);
    RTOutput[LaunchIndex] = finalColor;

void Miss(inout RayPayload payload : SV_RayPayload)
    payload.vis = 1.0f;

void ClosestHit(inout PayloadIndirect payload, in BuiltInTriangleIntersectionAttributes attribs)


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;
float3 CalcLighting(in float3 normal, in float3 lightDir, in float3 peakIrradiance,
                    in float3 diffuseAlbedo, in float3 specularAlbedo, in float roughness,
                    in float3 positionWS, in float3 cameraPosWS, in float3 msEnergyCompensation)
    float3 lighting = diffuseAlbedo * INV_PI;

    float3 view = normalize(cameraPosWS - positionWS);
    const float NoL = saturate(dot(normal, lightDir));
    if (NoL > 0.0f)
        const float NoV = saturate(dot(normal, view));
        float3 h = normalize(view + lightDir);

        float3 fresnel = Fresnel(specularAlbedo, h, lightDir);

        float specular = GGXSpecular(roughness, normal, h, view, lightDir);
        lighting += specular * fresnel * msEnergyCompensation;

    return lighting * NoL * peakIrradiance;
float3 CalculateRadiance(inout PayloadIndirect payload, in BuiltInTriangleIntersectionAttributes attribs)
    /* Prepare input data */
    float3 hitPos = WorldRayOrigin() + WorldRayDirection() * RayTCurrent();
    float3 triangleNormal, triangleTangent, triangleBitangent;
    loadHitData(triangleNormal, triangleTangent, triangleBitangent, attribs);

    float4 albedo = albedoTexture.Load(int3(DispatchRaysIndex().xy, 0));

    float roughness = specularTexture.Load(int3(DispatchRaysIndex().xy, 0)).g;
    roughness = max(roughness, payload.roughness);
    float metallic = specularTexture.Load(int3(DispatchRaysIndex().xy, 0)).b;
    float3 normalTextureData = NormalTextureInput.Load(int3(DispatchRaysIndex().xy, 0)).rgb;
    float3x3 tangentToWorld = float3x3(triangleTangent, triangleBitangent, triangleNormal);
    float3 normalWS = triangleNormal;

    bool enableDiffuse = metallic < 1.0f;
    bool enableSpecular = !payload.diffusePath;
    if (enableDiffuse == false && enableSpecular == false)
        return float3(0, 0, 0);
    const float3 diffuseAlbedo = lerp(albedo.rgb, 0.0f, metallic) * (enableDiffuse ? 1.0f : 0.0f);
    const float3 specularAlbedo = lerp(0.03f, albedo.rgb, metallic) * (enableSpecular ? 1.0f : 0.0f);

    float3 msEnergyCompensation = float3(1, 1, 1);
    float2 DFG = dfgTexture.SampleLevel(g_sampler, float2(saturate(dot(triangleNormal, -WorldRayDirection())), roughness), 0.0f).xy;
    float Ess = DFG.x;
    msEnergyCompensation = float3(1, 1, 1) + specularAlbedo * (1.0f / Ess - 1.0f);
    float3 worldOrigin = WorldRayOrigin();  
    // Calculate directional light
        float3 V = g_sceneCB.cameraPosition.xyz;
        float3 N = triangleNormal.xyz;
        float3 L = normalize(g_giCB.sunDirection);
        float distToLight = 1e+38f;
        // Check visibility
        float NoL = saturate(dot(N, L));
        float visibility = shadowRayVisibility(hitPos, L, 1e-3f, distToLight);
        // Calculate light contribution to point in world (diffuse lambertian term)
        payload.color += CalcLighting(triangleNormal, L, 1.0f, diffuseAlbedo, specularAlbedo,
                    roughness, hitPos, worldOrigin, msEnergyCompensation) * visibility;

    float2 brdfSample = SamplePoint(payload.rndSeed, payload.rndSeed2);
    float selector = brdfSample.x;
    if (enableSpecular == false)
        selector = 0.0f;
    else if (enableDiffuse == false)
        selector = 1.0f;
    if (g_giCB.useIndirect == 1)
        float3 throughput = float3(0, 0, 0);
        // Find next direction
        float3 rayDirWS = float3(0, 0, 0);
        if (g_giCB.samplingType == SAMPLE_MJ)
            if (selector < 0.5f)
                if (enableSpecular)
                    brdfSample.x *= 2.0f;
                float3 rayDirTS = SampleDirectionCosineHemisphere(brdfSample.x, brdfSample.y);
                rayDirWS = normalize(mul(rayDirTS, tangentToWorld));
                throughput = diffuseAlbedo;
                if (enableDiffuse)
                    brdfSample.x = (brdfSample.x - 0.5f) * 2.0f;

                float3 incomingRayDirWS = WorldRayDirection();
                float3 incomingRayDirTS = normalize(mul(incomingRayDirWS, transpose(tangentToWorld)));

                float3 wo = incomingRayDirTS;
                float3 wm = SampleGGXVisibleNormal(-wo, roughness, roughness, brdfSample.x, brdfSample.y);
                float3 wi = reflect(wo, wm);

                float3 normalTS = float3(0.0f, 0.0f, 1.0f);

                float3 F = Fresnel(specularAlbedo.rgb, wm, wi);
                float G1 = SmithGGXMasking(normalTS, wi, -wo, roughness * roughness);
                float G2 = SmithGGXMaskingShadowing(normalTS, wi, -wo, roughness * roughness);

                throughput = (F * (G2 / G1));
                float3 rayDirTS = wi;
                rayDirWS = normalize(mul(rayDirTS, tangentToWorld));
#if 1
                DFG = dfgTexture.SampleLevel(g_sampler, float2(saturate(dot(triangleNormal, -WorldRayDirection())), roughness), 0.0f).xy;
                Ess = DFG.x;
                throughput *= float3(1, 1, 1) + specularAlbedo * (1.0f / Ess - 1.0f);
        if (enableDiffuse && enableSpecular)
            throughput *= 2.0f;
        if (payload.pathLength < g_giCB.bounceCount)
            // Prepare payload
            PayloadIndirect newPayload;
            newPayload.pathLength = payload.pathLength + 1;
            newPayload.rndSeed = payload.rndSeed;
            newPayload.rndSeed2 = payload.rndSeed2;
            newPayload.color = float3(0, 0, 0);
            newPayload.diffusePath = (selector < 0.5f);
            newPayload.roughness = roughness;
            // Calculate next ray bounce color contribution
            float3 bounceColor = shootIndirectRay(hitPos, rayDirWS, 1e-3f, newPayload);
            // Check to make sure our randomly selected, normal mapped diffuse ray didn't go below the surface.
            if (dot(normalWS, rayDirWS) <= 0.0f)
                bounceColor = float3(0, 0, 0);
            payload.color += bounceColor * throughput; //* albedo.rgb;
    return payload.color;

void ClosestHitIndirect(inout PayloadIndirect payload, in BuiltInTriangleIntersectionAttributes attribs)
    payload.color = CalculateRadiance(payload, attribs);

Problem with my code occurs when using path of length more than 1 (indirect light bounces more than once). Shadows are starting to disappear and objects doesn't look grounded anymore. Series of images will occur now. Open full-size for better comparison. Points of interest marked in red in first image. Information about path length can be seen in GUI on the left. All images are generated with 1024 spp.

Here is my render (1-4-8 indirect bounces in that order): enter image description here

enter image description here

enter image description here

As you can see, information about shadows are lost in the process of increasing path length. Now, I tried to skip skybox, so any miss shader will just return (0, 0, 0) instead of skybox values. Results are slightly better (4, 8 indirect bounces):

enter image description here

enter image description here

Here is reference from MJP's Path Tracer mentioned above (3-6 path length, but it's calculated differently. In my terms in 1-4 path length actually). No specular paths. Take a look at points of interest. Shadows are well grounded and you can see that creases are not getting many light:

enter image description here

enter image description here

And here is ground truth reference created in MJP's Path Tracer with 5000 spp, 8 (in my terms - 6) path length:

enter image description here

  • $\begingroup$ Yes indirect illumination can bring extra light. By cutting off the bounces at some length you are cutting off part of the energy. $\endgroup$
    – lightxbulb
    Oct 12, 2020 at 19:55
  • $\begingroup$ @lightxbulb You're right and I'm aware of that. However, shouldn't some corners, creases, holes etc. still be dark as presented in ground truth reference (last image)? I feel like my image is failing to present any contrast between objects because everything becomes too bright. In advance - I am using exposure slider, aces filmic tone mapping and convertion from linear to sRGB space. $\endgroup$ Oct 12, 2020 at 20:28
  • 1
    $\begingroup$ Considering you're using exposure slider, tone mapping, etc, and that similarly the "reference" may use a 100 different things, it's impossible to tell anything. It could be that your code or the code used to generate the reference has a GI bug, or both. Or it could be that the two don't agree on some parameters/tonemapping. $\endgroup$
    – lightxbulb
    Oct 13, 2020 at 8:35
  • 1
    $\begingroup$ Actually with more bounces, corners/holes may become brighter due to indirect illumination. The other way to keep it darker is to: decrease the light intensity or decrease the materials reflectivity. In either case the image will look darker overall however. $\endgroup$
    – lightxbulb
    Oct 13, 2020 at 9:51
  • 1
    $\begingroup$ Disclaimer: I do not plan to wade through MJP or your code to search for a bug that may not be there. After all very minor differences (that are not necessarily bugs) can result in the differences you mention. What you could do however, is to pick a very simple scene: cornell, some spheres, or w/e. Then remove all postprocess and check that the results are not too different between the two renderers. The usual suspects for bugs would be: random number generation, ray scattering and its associated pdf, the estimator that you are using. I could not find where you acc attenuation either. $\endgroup$
    – lightxbulb
    Oct 13, 2020 at 15:49

1 Answer 1


To follow up my question and provide partial answer: First of all, I'd like to thank lightxbulb for providing comments which helped me to find solution.

So here is a basic image, 4 light bounces with 1024 spp:

enter image description here

Problem here is that everything is rather light. Why? Because I've based this part of my HLSL code on MJP's path tracer ( https://github.com/TheRealMJP/DXRPathTracer ) which assumes that if path allows both diffuse and specular then throughput *= 2.0. However, specular term is allowed only for first bounce with 50% chance probability. I decided that this multiplying was based on testing in his engine and it doesn't have physical basis so I got rid of it and got better results. Specular/metal parts are basically the same, but you can see darkenings in the background which grounds objects in their place.

enter image description here

However I noticed that throughput is not mutiplied. MJP's algorithm is that throughput = 0, then throughput += diffuse/specular BRDF. He doesn't multiply BRDF results in the fashion presented in PBRT book ( http://www.pbr-book.org/3ed-2018/Light_Transport_I_Surface_Reflection/Path_Tracing.html ). So I've added throughput to the payload. It starts with value (1.0, 1.0, 1.0) and is multiplied by BRDF at each point in the path resulting in below image:

enter image description here

Based on my knowledge, last image should be closest to ground truth in physics terms. Also, I think that it's closest to my reference that I've created in MJP's engine and I find it most pleasing. I am aware of the flaws. One is that for many samples (3000-5000 spp), diffuse textures starts having weird contrast and I don't know the reason yet.

Also russian roulette turned out to be workin poorly in my progressive path tracing. http://www.pbr-book.org/3ed-2018/Monte_Carlo_Integration/Russian_Roulette_and_Splitting.html - maybe after trying to optimize it to reduce variance it would be worth but I'm afraid that it'll still introduce too much noise with very little speedup. It might be worth giving it a try after adding denoiser I think, but for now, I don't think it's a good idea.


Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.