Regarding the Dual Scattering Implementation in Unreal Engine vs. the Original Paper

“I have been confused for quite a long time about Unreal Engine’s hair dual scattering implementation, and I would greatly appreciate any insight on this matter. Specifically, both the paper Dual Scattering Approximation for Fast Multiple Scattering in Hair (figure 5) and Efficient Implementation of the Dual Scattering Model in RenderMan (appendix A) treat multi-scattering and single-scattering as separate terms. However, Unreal Engine’s EvaluateHairMultipleScattering appears to compute globalScattering * (singleScattering + localScattering) and then adds KajiyaKayDiffuseAttenuation on top. I was wondering if there is a specific reason behind this design decision — perhaps as a deliberate trade-off to achieve better performance while still maintaining a visually plausible appearance? Any clarification or guidance would be deeply appreciated.”

https://media.disneyanimation.com/uploads/production/publication\_asset/24/asset/2\_DualScatteringImplementation.pdf

float3 EvaluateHairMultipleScattering(
  const FHairTransmittanceData TransmittanceData,
  const float Roughness,
  const float3 Fs)
{
  return TransmittanceData.GlobalScattering * (Fs + TransmittanceData.LocalScattering) * TransmittanceData.OpaqueVisibility;
}



question.png(228 KB)

Hello,

If I understand you correctly, you are assuming that the Hair BSDF evaluates the multiple-scattering term and then adds the Kajiya term to that result, yes? That should not be the case, since we use the multiple-scattering term only when shading a groom asset. In all other cases, we fall back to the Kaijiya term for any other geometry that uses the hair BSDF. We do this by setting the scatter parameter in the GBuffer to 0, which collapses the Kajiya evaluation to 0.

float3 KajiyaKayDiffuseAttenuation(FGBufferData GBuffer, float3 L, float3 V, half3 N, float Shadow)
{
	// Use soft Kajiya Kay diffuse attenuation
	float KajiyaDiffuse = 1 - abs(dot(N, L));
 
	float3 FakeNormal = normalize(V - N * dot(V, N));
	//N = normalize( DiffuseN + FakeNormal * 2 );
	N = FakeNormal;
 
	// Hack approximation for multiple scattering.
	float MinValue = 0.0001f;
	float Wrap = 1;
	float NoL = saturate((dot(N, L) + Wrap) / Square(1 + Wrap));
	float DiffuseScatter = (1 / PI) * lerp(NoL, KajiyaDiffuse, 0.33) * GBuffer.Metallic; // setting GBuffer.Metallic = 0 for hair strands reduces this function 0
	float Luma = Luminance(GBuffer.BaseColor);
        float3 BaseOverLuma = abs(GBuffer.BaseColor / max(Luma, MinValue));
	float3 ScatterTint = Shadow < 1 ? pow(BaseOverLuma, 1 - Shadow) : 1;
	return sqrt(abs(GBuffer.BaseColor)) * DiffuseScatter * ScatterTint;
}

I hope that clears up your concern. If you need any further clarification, let me know, and I will do my best to help.

Hello again,

Yes, you are correct; I meant to answer the second question about the Kajiya term with the code example. However, I failed to answer your first question, but I found that there is a comment in the HairBSDF.ush file that I think helps to answer it:

// Approximation to HairShadingRef using concepts from the following papers:
// [Marschner et al. 2003, "Light Scattering from Human Hair Fibers"]
// [Pekelis et al. 2015, "A Data-Driven Light Scattering Model for Hair"]

We use the HairShading function as an approximation for the more expensive HairShadingRef implementation. We also borrow some concepts from the Marschner and Pekelis papers to propose an alternative, cheaper variant of the multi-scattering BSDF. So I would not expect our HairShading approximation to match the Renderman implementation exactly. Have you tried comparing the two implementations using renders from our engine and Renderman?

Cheers,

Tim

Hi again,

We do not have exact code documentation available, but there are two main resources you can look through that explain the basis of the hair shading BSDF as it exists today:

  1. A SIGGRAPH 2016 course from Brian Karis: https://blog.selfshadow.com/publications/s2016\-shading\-course/karis/s2016\_pbs\_epic\_hair.pdf. This course goes into great detail about how we put together the hair shading model and is the best place for you to get started.
  2. Our white paper on Hair and Fur rendering in the engine, published in 2021, which also touches on how we calculate the hair transmission term through our voxel structure (see “Light Approximation” section): Box

Both of these resources should have the info you need to work through the code and figure out which discrepancies you are looking for. If that does not help, I suggest you post some comparison shots with a detailed explanation of what is lacking in the shading models for you, so that we can try and get you some further info.

Hi Eason,

You are right that our documentation on the hair shading model is a bit lacking, and we appreciate the feedback. Unfortunately, the code is a few years old now, and much of the knowledge of why we made deviations from the multi-scattering path has been lost, so it is unlikely we can provide more updated information beyond what I have already offered you. I know that is not a very satisfying answer, but that is the best I can offer you at this point. I hope you can understand. If you have any further questions, please let me know, though.

Best,

Tim

Thank you again for your patience in answering my questions. I truly appreciate your time and assistance.

I have additional questions regarding hair rendering for the differences in path tracing based on feedback from our artists. I will raise these in a separate thread.

Best regards,

Eason

You are welcome! Yes, please feel free to post those questions in another thread so we can address them there. I will close out this ticket for now. Have a good rest of your week

Hello,

Thank you for the reply.

What is discussed here is the strand hair (groom).

Two questions were asked:

  1. The dual scattering implementation for strands in UE5 is not identical to the original paper or the RenderMan implementation. I was wondering if there is a specific reason behind this design decision.
  2. In the case where HAIR_COMPONENT_MULTISCATTER is enabled, after obtaining the result from dual scattering, Kajiya attenuation is added, which does not yield a precise result.

I think comment here explain the second question, which Kajiya logic does not worked when strands on.

setting GBuffer.Metallic = 0 for hair strands reduces this function 0So my first question is still remains.

Unreal dual scattering composite logic.

TransmittanceData.GlobalScattering * (Fs + TransmittanceData.LocalScattering) * TransmittanceData.OpaqueVisibility;Indicated implementation from both dual scattering paper (figure 5) and Renderman implementation reference.

// Computing f_s^direct and f_s^scatter
float f_s_direct =
    M_R(theta_h) * N_R(theta_d, phi) +
    M_TT(theta_h) * N_TT(theta_d, phi) +
    M_TRT(theta_h) * N_TRT(theta_d, phi);
 
float f_s_scatter =
    M_R_G(theta_h) * N_R_G(theta_d, phi) +
    M_TT_G(theta_h) * N_TT_G(theta_d, phi) +
    M_TRT_G(theta_h) * N_TRT_G(theta_d, phi);
 
// Computing f_back^direct and f_back^scatter
float f_back_direct =
    2.0 * A_b[theta_d] * g(theta_h - Delta_b[theta_d], sigma_b_sq[theta_d])
    / (PI * cos(theta_d) * cos(theta_d));
 
float f_back_scatter =
    2.0 * A_b[theta_d] * g(theta_h - Delta_b[theta_d], sigma_b_sq[theta_d] + sigma_f_sq[theta_d])
    / (PI * cos(theta_d) * cos(theta_d));
 
// Computing F^direct
float3 F_direct = illuminated * (f_s_direct + d_b * f_back_direct);
 
// Computing F^scatter
float3 F_scatter = (T_f - illuminated) * d_f * (f_s_scatter + PI * d_b * f_back_scatter);
 
// Computing the final result
Ci += (F_direct + F_scatter) * cos(theta_i);

Have you tried comparing the two implementations using renders from our engine and Renderman?Yes, this question has confused me for at least 5 years. I compared them for a long time before, but was not satisfied with the Unreal Engine result. I think if there are papers or theory to support or explain what the code is doing could be better.

Thank you for your time, and I look forward to hearing from you.

Best regards,

Eason

Hello again,

Thank you for providing the document.

However, I must note that the documentation is not particularly helpful, as I have seen similar material before.

To clarify, this is not a question of comparing the Unreal Engine model with, for example, the RenderMan model. I have great respect for Unreal Engine’s open-source philosophy and appreciate that the code is accessible to all developers.

My point is more fundamental: when implementing an algorithm, the choices made should be grounded in theory or sound reasoning. In this case, the formula is not self-explanatory and does not appear to align with the intent of the original paper. There should be a clear rationale for selecting this particular formula; without it, anyone other than the original author is likely to find the implementation difficult to understand.

I hope this feedback is useful for improving the clarity of future documentation.

Eason