Am I calculating my physically-based shader correctly?

I know this is my third forum regarding this but this is gonna be the last one. So I rewrote my code and this time, the result looked better. It doesn’t make the material completely black if the roughness is 1 but, I’m still not sure if this is the desired effect.

Here are some results with different properties. If you see the image with 1 roughness and 1 metallic, the material still does look dark but if you look closely, you can see some texture so it’s not completely black.

The code:

Shader "ShaderChallenge/CustomPBR"
{
    Properties
    {
        _Albedo("Albedo", 2D) = "white" {}
        _Roughness("Roughness", Range(0, 1)) = 1.0
        _Metallic("Metallic", Range(0, 1)) = 0.0
    }
    SubShader
    {
        Pass
        {
            Tags { "LightMode" = "ForwardBase" }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityStandardBRDF.cginc"

            #define pi 3.14159265359

            sampler2D _Albedo;
            float _Roughness;
            float _Metallic;

            struct v2f
            {
                float4 pos : SV_POSITION;
                float3 worldPos : TEXCOORD0;
                float3 normal : TEXCOORD1;
                float2 uv : TEXCOORD2;
            };

            v2f vert(appdata_base v)
            {
                v2f o;
                o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
                // Convert vertex position to world space
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                // Normal vector
                o.normal = UnityObjectToWorldNormal(v.normal);
                o.uv = v.texcoord;
                return o;
            }

            // Oren-Nayar
            float OrenNayar(float3 n, float3 v, float3 l)
            {
                float nl = dot(n, l);
                float nv = dot(n, v);

                float anglenl = acos(nl);
                float anglenv = acos(nv);

                float alpha = max(anglenv, anglenl);
                float beta = min(anglenv, anglenl);
                float gamma = dot(v - n * nv, l - n * nl);

                float a2 = pow(_Roughness, 2.0);

                float A = 1.0 - 0.5 * (a2 / (a2 + 0.57));
  
                float B = 0.45 * (a2 / (a2 + 0.09));
  
                float C = sin(alpha) * tan(beta);
  
                float result = max(0.0, nl) * (A + B * max(0.0, gamma) * C);
  
                return result;
            }

            float DistributionGGX(float3 n, float3 h)
            {
                float a2 = pow(_Roughness, 2.0);
                float nh = dot(n, h);
                float nh2 = pow(nh, 2.0);

                float num = a2;
                float den = (nh2 * (a2 - 1.0) + 1.0);
                den = pi * pow(den, 2.0);

                return num / den;
            }
          
            float GeometrySchlickGGX(float dot, float k)
            {
            float num = dot;
            float den = dot * (1.0 - k) + k;
  
            return num / den;
            }

            float GeometrySmith(float3 n, float3 v, float3 l)
            {
                float k = pow(_Roughness + 1.0, 2.0) / 8.0;

                float nv = DotClamped(n, v);
                float nl = DotClamped(n, l);
                float ggx1 = GeometrySchlickGGX(nv, k);
                float ggx2 = GeometrySchlickGGX(nl, k);

                return ggx1 * ggx2;
            }

            float FresnelSchlick(float3 n, float3 v, float3 albedo)
            {
                float cosTheta = dot(n, v);
                float3 F0 = 0.04;
                F0 = lerp(F0, albedo, _Metallic);

                return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
            }

            float Microfacet(float3 n, float3 h, float3 v, float3 l, float3 albedo)
            {
                float d = DistributionGGX(h, n);
                float g = GeometrySmith(n, v, l);
                float f = FresnelSchlick(n, v, albedo);
  
                return (d * g * f) / 4.0 * DotClamped(n, l) * DotClamped(n, v) + 0.001;
            }

            float4 frag(v2f i) : SV_TARGET
            {
                float3 lightCol = _LightColor0.rgb;

                // Normalize the normal
                float3 n = normalize(i.normal);
                // Light vector from mesh's surface
                float3 l = normalize(_WorldSpaceLightPos0.xyz);
                // Viewport(camera) vector from mesh's surface
                float3 v = normalize(_WorldSpaceCameraPos - i.worldPos.xyz);
                // Halfway vector
                float3 h = normalize(l + v);

                float3 albedo = tex2D(_Albedo, i.uv).rgb;
                float3 ambient = unity_AmbientSky;

                float3 specular = Microfacet(n, h, v, l, albedo);

                float3 diffuse = ambient + OrenNayar(n, v, l);
                diffuse *= 1.0 - _Metallic;

                float3 color = albedo * (diffuse * lightCol) + (specular * lightCol);

                return float4(color, 1.0);
            }
            ENDCG
        }
    }
}

Not really an answer to your question, but I wrote a little piece on how to optimize Oren-Nayar some time ago:

It replaces all trigonometric instructions (sin, acos, tan) with a single sqrt. It also removes two max instructions and the one min instruction. It’s mathematically the same as the original, but due to rounding there can be small differences. I’ve measured that to be in the range of 0.01%, so nothing noticeable. But it really is a lot GPU friendlier.

Applied to your code it would look like:

float OrenNayar(float3 n, float3 v, float3 l)
{
   float roughness2 = _Roughness * _Roughness;
   float2 oren_nayar_fraction = roughness2 / (roughness2 + float2(0.33, 0.09));
   float2 oren_nayar = float2(1, 0) + float2(-0.5, 0.45) * oren_nayar_fraction;
   // Theta and phi
   float2 cos_theta = saturate(float2(dot(n, l), dot(n, v)));
   float2 cos_theta2 = cos_theta * cos_theta;
   float sin_theta = sqrt((1-cos_theta2.x) * (1-cos_theta2.y));
   float3 light_plane = normalize(light - cos_theta.x * normal);
   float3 view_plane = normalize(view - cos_theta.y * normal);
   float cos_phi = saturate(dot(light_plane, view_plane));
   // Composition
   float diffuse_oren_nayar = cos_phi * sin_theta / max(cos_theta.x, cos_theta.y);
   float diffuse = cos_theta.x * (oren_nayar.x + oren_nayar.y * diffuse_oren_nayar);
   return diffuse;
}

Edit: The code is a bit old and assumes vector units. For current desktop GPU’s it could be optimized a little more.

Hey, sorry I am not actually able to test and, and this certainly is not physically correct, but you are missing the ambient specular light from a refection probe or the sky. I just through this together so you can see the difference, so optimize and correct the bdrf accordingly.

// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "ShaderChallenge/CustomPBR"
{
    Properties
    {
        _Albedo("Albedo", 2D) = "white" {}
        _Roughness("Roughness", Range(0, 1)) = 1.0
        _Metallic("Metallic", Range(0, 1)) = 0.0
    }
    SubShader
    {
        Pass
        {
            Tags { "LightMode" = "ForwardBase" }
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityStandardBRDF.cginc"
            #define pi 3.14159265359
            sampler2D _Albedo;
            float _Roughness;
            float _Metallic;
            struct v2f
            {
                float4 pos : SV_POSITION;
                float3 worldPos : TEXCOORD0;
                float3 normal : TEXCOORD1;
                float2 uv : TEXCOORD2;
                half3 worldRefl: TEXCOORD3;
            };
            v2f vert(appdata_base v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                // Convert vertex position to world space
                float3 worldPos = mul(unity_ObjectToWorld, v.vertex);
               
                o.worldPos = worldPos;
                float3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
                // Normal vector
                float3 worldNormal = UnityObjectToWorldNormal(v.normal);
                o.normal = worldNormal;
                o.worldRefl = reflect(-worldViewDir, worldNormal);
                o.uv = v.texcoord;
                return o;
            }
            // Oren-Nayar
            float OrenNayar(float3 n, float3 v, float3 l)
            {
                float nl = dot(n, l);
                float nv = dot(n, v);
                float anglenl = acos(nl);
                float anglenv = acos(nv);
                float alpha = max(anglenv, anglenl);
                float beta = min(anglenv, anglenl);
                float gamma = dot(v - n * nv, l - n * nl);
                float a2 = pow(_Roughness, 2.0);
                float A = 1.0 - 0.5 * (a2 / (a2 + 0.57));
                float B = 0.45 * (a2 / (a2 + 0.09));
                float C = sin(alpha) * tan(beta);
                float result = max(0.0, nl) * (A + B * max(0.0, gamma) * C);
                return result;
            }
            float DistributionGGX(float3 n, float3 h)
            {
                float a2 = pow(_Roughness, 2.0);
                float nh = dot(n, h);
                float nh2 = pow(nh, 2.0);
                float num = a2;
                float den = (nh2 * (a2 - 1.0) + 1.0);
                den = pi * pow(den, 2.0);
                return num / den;
            }
        
            float GeometrySchlickGGX(float dot, float k)
            {
                float num = dot;
                float den = dot * (1.0 - k) + k;
   
                return num / den;
            }
            float GeometrySmith(float3 n, float3 v, float3 l)
            {
                float k = pow(_Roughness + 1.0, 2.0) / 8.0;
                float nv = DotClamped(n, v);
                float nl = DotClamped(n, l);
                float ggx1 = GeometrySchlickGGX(nv, k);
                float ggx2 = GeometrySchlickGGX(nl, k);
                return ggx1 * ggx2;
            }
            float FresnelSchlick(float3 n, float3 v, float3 albedo)
            {
                float cosTheta = dot(n, v);
                float3 F0 = 0.04;
                F0 = lerp(F0, albedo, _Metallic);
                return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
            }
            float Microfacet(float3 n, float3 h, float3 v, float3 l, float3 albedo)
            {
                float d = DistributionGGX(h, n);
                float g = GeometrySmith(n, v, l);
                float f = FresnelSchlick(n, v, albedo);
                return (d * g * f) / 4.0 * DotClamped(n, l) * DotClamped(n, v) + 0.001;
            }
            float4 frag(v2f i) : SV_TARGET
            {
                float3 lightCol = _LightColor0.rgb;
                // Normalize the normal
                float3 n = normalize(i.normal);
                // Light vector from mesh's surface
                float3 l = normalize(_WorldSpaceLightPos0.xyz);
                // Viewport(camera) vector from mesh's surface
                float3 v = normalize(_WorldSpaceCameraPos - i.worldPos.xyz);
                // Halfway vector
                float3 h = normalize(l + v);
                float3 albedo = tex2D(_Albedo, i.uv).rgb;
                float3 ambient = unity_AmbientSky;
                float3 specular = Microfacet(n, h, v, l, albedo);
               
                float4 probe = UNITY_SAMPLE_TEXCUBE_LOD (unity_SpecCube0, i.worldRefl, _Roughness * 6);
                specular.xyz += DecodeHDR(probe, unity_SpecCube0_HDR);
               
                float3 diffuse = ambient + OrenNayar(n, v, l);
                diffuse *= 1.0 - _Metallic;
                float3 color = albedo * (diffuse * lightCol) + (specular * lightCol);
                return float4(color, 1.0);
            }
            ENDCG
        }
    }
}

I added in the code quickly just to see the result and you’re right that there’s not much of a difference in visual appearances. I assume you were the one who came up with this right? If so, I’ll be citing your work if I use it after I studied the original BRDF and your rewrite. Thanks for this :slight_smile:

Well, I haven’t added in reflection into my code because I wanted to make sure all of the calculations were correct first. But anyways, I added it in and noticed there were some differences between my custom shader and Unity’s standard shader. Here are all four results that I compiled together showing the differences and similarities. Mine is the sphere in the left while the standard shader is the one in the right. The one showing a major difference is in the third image which is the one I’ve been trying to fix for days…

Yes, that page is the result of my take on trying to optimize the Oren-Nayar BRDF. I had a feeling it could be more GPU friendly and after some reformulating it turned out it can be.

Alright, thanks again for sharing it!