Sign in to follow this  
matt77hias

Material parameters in PBR pipeline

Recommended Posts

Which material parameters does one normally support in a forward and deferred PBR pipeline?

I currently use the following material parameters:

  • diffuse color (3x float)
  • specular color (3x float)
  • roughness/smootness (1x float) (or the specular exponent/shininess for non-PBR BRDFs such as the Phong/Blinn family)
  • specular reflection coefficient (used in Schlick's approximation) /index of refraction (1x float) (the index of refraction of the camera medium can be set to 1 or some arbitrary value in a separate constant buffer)

The dissolve/opacity of the material is always handled in a forward pass.

So this results in 8 floats = 2 material textures in the GBuffer so far (I do not use AO at the moment).

But how do you incorporate "metalness"? Another float?

Furthermore, Kd = cdiff/pi and Ks = cspec/pi, but what is actually stored in the diffuse and specular texture having a range of [0,1]: Kd/Ks or cdiff/cspec? I think the latter, although the division by pi ruins the images?

 

Edited by matt77hias

Share this post


Link to post
Share on other sites

You either store metalness OR you store specular colour. If you're using a metalness workflow, then you just have a single material colour and compute diffuse/specular colours like:

diffuse colour = lerp( material colour, 0, metalness )
specular colour = lerp( ~0.03, material colour, metalness )

Also, specular colour and specular reflection coefficient are usually the same thing.

"Kd = cdiff/pi" is the Lambert diffuse model. Dividing by pi is a part of the lambert math. Dividing by pi is not a part of every specular function -- check your specific specular model to see if it contains that scaling factor or not.

You should store values in a way that maximises precision. If it's an 8-bit texture, you only get to store values from [0.0, 1.0]. If you store values in the range of [0.0, 1/pi] you'll only be using six and a half bits out of 8...

Share this post


Link to post
Share on other sites
1 hour ago, Hodgman said:

"Kd = cdiff/pi" is the Lambert diffuse model. Dividing by pi is a part of the lambert math. Dividing by pi is not a part of every specular function -- check your specific specular model to see if it contains that scaling factor or not.

Most of the BRDFs I have seen with a cspec have the division by pi. Sometimes the pi is hidden in some component. This paper:

NGAN A., DURAND F., MATUSIK W.: Experimental Analysis of BRDF Models, Proceedings of the Sixteenth Eurographics Conference on Rendering Techniques, 2005.

defines all its BRDFs with cdiff/pi and cspec/pi in its supplemental material.

1 hour ago, Hodgman said:

You either store metalness OR you store specular colour. If you're using a metalness workflow, then you just have a single material colour and compute diffuse/specular colours like:

diffuse colour = lerp( material colour, 0, metalness )
specular colour = lerp( ~0.03, material colour, metalness )

But metals fully absorb the non-reflected light. So the diffuse color needs to be zero?

What is a metalness workflow? Only for metals or taking metals into account beside non-metals? I prefer having a single shader for metals and non-metals.

Edited by matt77hias

Share this post


Link to post
Share on other sites
1 hour ago, Hodgman said:

You should store values in a way that maximises precision. If it's an 8-bit texture, you only get to store values from [0.0, 1.0]. If you store values in the range of [0.0, 1/pi] you'll only be using six and a half bits out of 8...

So the cdiff/cspec is what is stored in the texture. But than the division by pi seems strange? If both cdiff/spec have a division by pi, I could move the division by pi out of the expression and divide my accumulated result in the end by pi. But how can I obtain a red diffuse color for instance, since the division will always reduce the redness? [0,1] range divided by [0,pi] range, results in a [0, less than 1] range?

Share this post


Link to post
Share on other sites

The "metallic" workflow comes from Disney's 2012 SIGGRAPH presentation about their physically based shading model (slides, course notes). Basically when metallic is 0 then you treat your base color as the diffuse reflectance, with a fixed F0 ("head-on") specular reflectance of 0.03 or 0.04 (with fresnel applied so that it goes to 1.0 at grazing angles). This gives you a typical dielectric with colored diffuse, and white specular. However when metallic is 1.0 you then use your base color as your F0 specular reflectance, with diffuse reflectance set to 0. This now gives you a typical metal, with colored specular and no diffuse. So that lets you represent both dielectrics and metals with a single set of 5 parameters (base color, metallic, and roughness), which is nice for deferred renderers and/or for packing those parameters into textures. 

The 1/Pi factor in a Lambertian diffuse BRDF is essentially a normalization term that ensures that the surface doesn't reflect more energy than the amount that is incident to the surface (the irradiance). Imagine a white surface with diffuse reflectance of 1 that's in a completely white room (like the construct from the matrix), where the incoming radiance is 1.0 in every direction. If you compute the irradiance in this situation by integrating the cosine (N dot L) term of the entire hemisphere surrounding the surface normal you get a value of Pi. Now let's say our diffuse BRDF is just Cdiff instead of Cdiff/Pi. To the get the outgoing radiance in any viewing direction, you would compute Cdiff * irradiance. This would give you a value of Pi for Cdiff = 1.0, which means that the surface is reflecting a value of Pi in every viewing direction! In other words we have 1.0 coming in from every direction, but Pi going out! However if we use the proper Lambertian BRDF with the 1/Pi factor, when end up with 1.0 going out an all is well.

So yes, this means that if you have a red surface with Cdiff = [1, 0, 0] that's being lit by a directional light with irradiance of 1.0, then the surface will have an intensity of [1 / Pi, 0, 0]. However this should make sense if you consider that in this case the lighting is coming from a single direction, and is getting scattered in all directions on the hemisphere. So naturally there is less light in any particular viewing direction. To get a fully lit surface, you need to have constant incoming lighting from all directions on the hemisphere.

Share this post


Link to post
Share on other sites
7 minutes ago, MJP said:

single set of 5 parameters (base color, metallic, and roughness), which is nice for deferred renderers and/or for packing those parameters into textures. 

Ok, this kind of breaks all non-PBR BRDFs which use a 3 channel cspec :o (though it is not that bad, I had no (single or 3 channel) specular textures any way on my test models :D ).

But why is the specular reflectance not included (or even better the index of refraction which can be used to obtain the specular reflectance)?

14 minutes ago, MJP said:

To get a fully lit surface, you need to have constant incoming lighting from all directions on the hemisphere.

Thanks. This was the statement I was looking for :) 

Share this post


Link to post
Share on other sites

With a metallic workflow the specular reflectance is fixed for dialectrics (constant IOR), or for metals the specular reflectance is equal to the base color. So there's no need to store an IOR, since it's redundant. It also fits well with how most tools/engines have artists author material parameters, and is easily usable with Schlick's approximation. Naty Hoffman talks about this a bit in the section entitled "Fresnel Reflection" from these course notes.

Share this post


Link to post
Share on other sites
10 hours ago, matt77hias said:

But how can I obtain a red diffuse color for instance, since the division will always reduce the redness? [0,1] range divided by [0,pi] range, results in a [0, less than 1] range?

Lighting results are in the [0, infinity] range, not [0, 1] :)

There's nothing special about "1 unit" of energy. In traditional rendering, we defined RGB(1,1,1) as white, but now such a definition is arbitrary. You could say that RGB(42, 42, 42) is white and it wouldn't really affect anything (except you'd also have to increase the intensity of your light sources to match.

That's what the tonemapper does these days - arbitrarily maps from the [0, infinity] lighting output range, to the [0, 1] displayable range.

Share this post


Link to post
Share on other sites
1 minute ago, Hodgman said:

That's what the tonemapper does these days - arbitrarily maps from the [0, infinity] lighting output range, to the [0, 1] displayable range.

What is the displayable range for HDR displays'?

Share this post


Link to post
Share on other sites
13 minutes ago, Infinisearch said:

What is the displayable range for HDR displays'?

Ugh, a headache. Still 0-1, but either 8, 10 or 12 bits per channel (and using different RGB wavelengths than normal displays... Requiring a colour rotation if you've generated normal content... And there's also a curve that needs to be applied, similar to sRGB but different).

However, each individual HDR display will map 1.0 to somewhere from 1k nits to 10k nits, which is painfully bright.

You want "traditional white" (aka "paper white") -- e.g. the intensity of a white unlit UI element/background -- to be displayed at about 300 nits, which might be ~0.3 on one HDR display, or 0.03 on another HDR display...

So, you need to query the TV to ask what its max nits level is, and then configure your tonemapper accordingly, so that it will result in "white" coming out at 300 nits and everything "brighter than white" going into the (300, 1000] to (300, 10000] range...

But... HDR displays are allowed to respond to the max-nits query with a value of 0, meaning "don't know", in which case you need to display a calibration UI to the user to empirically discover a suitable gamma adjustment (usually around 1 to 1.1), a suitable value for "paper white", and also what the saturation point / max nits is, so you can put an appropriate soft shoulder in your tonemapper...

I honestly expect a lot of HDR-TV compatible games that ship this year to do a pretty bad job of supporting all this :|

Share this post


Link to post
Share on other sites
29 minutes ago, Hodgman said:

Ugh, a headache. Still 0-1, but either 8, 10 or 12 bits per channel (and using different RGB wavelengths than normal displays... Requiring a colour rotation if you've generated normal content... And there's also a curve that needs to be applied, similar to sRGB but different).

However, each individual HDR display will map 1.0 to somewhere from 1k nits to 10k nits, which is painfully bright.

You want "traditional white" (aka "paper white") -- e.g. the intensity of a white unlit UI element/background -- to be displayed at about 300 nits, which might be ~0.3 on one HDR display, or 0.03 on another HDR display...

So, you need to query the TV to ask what its max nits level is, and then configure your tonemapper accordingly, so that it will result in "white" coming out at 300 nits and everything "brighter than white" going into the (300, 1000] to (300, 10000] range...

But... HDR displays are allowed to respond to the max-nits query with a value of 0, meaning "don't know", in which case you need to display a calibration UI to the user to empirically discover a suitable gamma adjustment (usually around 1 to 1.1), a suitable value for "paper white", and also what the saturation point / max nits is, so you can put an appropriate soft shoulder in your tonemapper...

I honestly expect a lot of HDR-TV compatible games that ship this year to do a pretty bad job of supporting all this

This is not exactly true, and perfectly wrong for the 0.3 vs 0.03, there is some guarantee here !

HDR capable monitors and TV are doing so according to the HDR10 standard. Under HDR10, the display buffer is at least 10bits with PQ ST2084. This encoding is mapping a range of 0-10000nits. If you want your paper write, just write the equivalent of 100nits, and all TVs should be pretty close to it. The same image in the 0-300nits range would be pretty close on all hardware, it is the point of HDR10. then, as you reach more saturated colors and brightness, you enter the blackbox enigma of tone-mapping each vendor implement. 

If it is true that the max brightness is unknown ( at least on console, we are denied the value, dxgi can report it on pc, but it is to take with a pinch of salt ), the low brightness should just be close to what you are asking. unless you have some larger bright area that is pulling down the rest of the monitor ( to not blow a fuse ). What we do in our game is to behave linear up to 800-900nits and add a soft toe in order to retain colors/intention over the TV tonemap/clamp for the range it would not support well..

The problem with pre-hdr era monitors and tvs is that they already shoot more than paper white, around 300-400nits and people are use to it, having windows in HDR with the 100nits white windows feel very dim ( it is a stupid mistake of Microsoft to not have a brightness slider for people working in bright environment ). But in a game, you do not want to have paper white at 300-400nits, you would lose a 2 stop of dynamic range just to start with, it would be quite stupid and your picture would not match anymore what art has design.

 

 

Share this post


Link to post
Share on other sites
2 hours ago, galop1n said:

This is not exactly true, and perfectly wrong for the 0.3 vs 0.03, there is some guarantee here !

HDR capable monitors and TV are doing so according to the HDR10 standard. Under HDR10, the display buffer is at least 10bits with PQ ST2084. This encoding is mapping a range of 0-10000nits. If you want your paper write, just write the equivalent of 100nits, and all TVs should be pretty close to it. The same image in the 0-300nits range would be pretty close on all hardware, it is the point of HDR10. then, as you reach more saturated colors and brightness, you enter the blackbox enigma of tone-mapping each vendor implement. 

If it is true that the max brightness is unknown ( at least on console, we are denied the value, dxgi can report it on pc, but it is to take with a pinch of salt ), the low brightness should just be close to what you are asking. unless you have some larger bright area that is pulling down the rest of the monitor ( to not blow a fuse ). What we do in our game is to behave linear up to 800-900nits and add a soft toe in order to retain colors/intention over the TV tonemap/clamp for the range it would not support well..

The problem with pre-hdr era monitors and tvs is that they already shoot more than paper white, around 300-400nits and people are use to it, having windows in HDR with the 100nits white windows feel very dim ( it is a stupid mistake of Microsoft to not have a brightness slider for people working in bright environment ). But in a game, you do not want to have paper white at 300-400nits, you would lose a 2 stop of dynamic range just to start with, it would be quite stupid and your picture would not match anymore what art has design.

 

 

This is the ideal, unfortunately IHVs, bastards that they are, don't necessarily adhere to any spec while advertising "HDR!" and allowing for input as such anyway. The primary one I can think of is Samsung's CHG70 monitors, which don't formally follow HDR10 spec AFAIK. Fortunately for there Freesync 2 is available, so it'll tonemap directly to the monitors space. But it's an example that IHV's don't necessarily give a shit about technical specs or following them at all, especially when marketing gets their hands on a product (just look at Dells absolute bullshit "HDR" monitor from earlier this year).

Share this post


Link to post
Share on other sites
On 10/3/2017 at 1:05 AM, MJP said:

With a metallic workflow the specular reflectance is fixed for dialectrics (constant IOR), or for metals the specular reflectance is equal to the base color.

If I understand Disney's presentation and code correctly, the used BRDF (diffuse and specular component) is something like this (after stripping it down a bit):

BRDF := F_diffuse  * BRDF_diffuse + F_specular * BRDF_specular

F_diffuse := (1-metallic) * lerp(1, FD90, Fresnel(n_dot_l)) * lerp(1, FD90, Fresnel(n_dot_v))

BRDF_diffuse := base_color/pi

F_specular := lerp(lerp(???, base_color, metallic), (1,1,1), Fresnel(n_dot_h))

BRDF_specular := (G * D) / (4 * n_dot_v * n_dot_l) // Fresnel(n_dot_h) moved to F_specular.

 

@Hodgman @MJP is the ??? factor the 0.3/0.4 you are talking about?

 

I am also still confused about their Fresnel calculation with Schlick's approximation: where is the reflectance gone? The Schlick approximation is defined as F(theta) := F0 + (1-F0)(1-cos(theta))^5. Disney uses (1-F(theta_l))*(1-F_theta_d) although they rather seem to use (1-F(theta_l))*(1-F_theta_v). If we expand the latter (while skipping the first F0 in Schlick's approximation???), we get: (1+(F0-1)(1-cos(theta_l))^5)(1+(F0-1)(1-cos(theta_v))^5). After replacing F0 with FD90, we get our F_diffuse above. My understanding is that they set 1 - lerp(a,b,m) to lerp(b,a,m) which is not correct? Or I am missing some approximations?

 

Edited by matt77hias

Share this post


Link to post
Share on other sites

So instead of using the BRDF as available here.

I would rather use the following modified format:

float3 CookTorranceBRDFxCos(float3 n, float3 l, float3 v, 
    float3 base_color, float roughness, float metalness) {
    
    const float  alpha   = sqr(roughness);
    const float  n_dot_l = sat_dot(n, l);
    const float  n_dot_v = sat_dot(n, v);
    const float3 h       = HalfDirection(l, v);
    const float  n_dot_h = sat_dot(n, h);
    const float  v_dot_h = sat_dot(v, h);

    const float  Fd90    = F_D90(v_dot_h, roughness);
    const float  FL      = BRDF_F_COMPONENT(n_dot_l, Fd90);
    const float  FV      = BRDF_F_COMPONENT(n_dot_v, Fd90);
    const float  F_diff  = (1.0f - metalness) * (1.0f - FL) * (1.0f - FV);

    const float3 c_spec  = lerp(g_dielectric_F0, base_color, metalness);
    const float3 F_spec  = BRDF_F_COMPONENT(v_dot_h, c_spec);
    const float  D       = BRDF_D_COMPONENT(n_dot_h, alpha);
    const float  V       = BRDF_V_COMPONENT(n_dot_v, n_dot_l, n_dot_h, v_dot_h, alpha);

    const float3 Fd      = F_diff * base_color * g_inv_pi;
    const float3 Fs      = F_spec * 0.25f * D * V;

    return (Fd + Fs) * n_dot_l;
}

Note that instead of calculating the BRDF, I calculate the BRDF multiplied by the cosine factor. Furthermore, I do not use an explicit Geometry (G) component in the Microfacet model, but instead a Visibility (V) component. The V component is equal to the G component divided by the foreshortening terms (n_dot_l * n_dot_v). So the Microfacet model reduces to F * D * V / 4.

F_D90 is equal to (as mentioned in the course):

float F_D90(float v_dot_h, float roughness) {
    return 0.5f + 2.0f * roughness * sqr(v_dot_h);
}

Any thoughts?

Share this post


Link to post
Share on other sites
On 10/2/2017 at 8:42 AM, Hodgman said:

Also, specular colour and specular reflection coefficient are usually the same thing.

Just wanted to say, they're not.

While they make similar results, coloured fresnel / IOR tends to lack colour at the borders, unlike specular colour. It's a subtle difference.

Share this post


Link to post
Share on other sites
5 hours ago, Matias Goldberg said:

Just wanted to say, they're not.

While they make similar results, coloured fresnel / IOR tends to lack colour at the borders, unlike specular colour. It's a subtle difference.

Oh, I plug "specular color" into fresnel as F0 to get white borders, but I've seen other PBR shaders that multiply the result with a "reflection coefficient" to basically dull the entire specular results. I guess this is like a specular occlusion mask?

Share this post


Link to post
Share on other sites
31 minutes ago, Hodgman said:

Oh, I plug "specular color" into fresnel as F0 to get white borders, but I've seen other PBR shaders that multiply the result with a "reflection coefficient" to basically dull the entire specular results. I guess this is like a specular occlusion mask?

Some BRDFs have both a diffuse and specular color. Some BRDFs seem to remove the specular color and just use a Fresnel component. Apparently, the specular color can be used to calculate this Fresnel component.

Actually I have seen Cook-Torrance with and without an explicit specular color.

Share this post


Link to post
Share on other sites

Yeah now that you mention it, I remember reading a paper on trying to recreate real world measured data with cook-torrence, and their error numbers were smallest when they allowed coloured F0 and a coloured multiplier over the entire specular result (instead of just one or the other).

I've never actually seen this model used in games though :o

Share this post


Link to post
Share on other sites
4 hours ago, Hodgman said:

Oh, I plug "specular color" into fresnel as F0 to get white borders, but I've seen other PBR shaders that multiply the result with a "reflection coefficient" to basically dull the entire specular results. I guess this is like a specular occlusion mask?

Yes.

This was discussed (but for some reason the blogpost was removed, likely in server migration) in Filmic Worlds' website. Fortunately Web Archive remembers.

Also twitter discussion: https://twitter.com/olanom/status/444116562430029825

 

Edited by Matias Goldberg

Share this post


Link to post
Share on other sites
19 hours ago, matt77hias said:

So instead of using the BRDF as available here.

I would rather use the following modified format:


float3 CookTorranceBRDFxCos(float3 n, float3 l, float3 v, 
    float3 base_color, float roughness, float metalness) {
    
    const float  alpha   = sqr(roughness);
    const float  n_dot_l = sat_dot(n, l);
    const float  n_dot_v = sat_dot(n, v);
    const float3 h       = HalfDirection(l, v);
    const float  n_dot_h = sat_dot(n, h);
    const float  v_dot_h = sat_dot(v, h);

    const float  Fd90    = F_D90(v_dot_h, roughness);
    const float  FL      = BRDF_F_COMPONENT(n_dot_l, Fd90);
    const float  FV      = BRDF_F_COMPONENT(n_dot_v, Fd90);
    const float  F_diff  = (1.0f - metalness) * (1.0f - FL) * (1.0f - FV);

    const float3 c_spec  = lerp(g_dielectric_F0, base_color, metalness);
    const float3 F_spec  = BRDF_F_COMPONENT(v_dot_h, c_spec);
    const float  D       = BRDF_D_COMPONENT(n_dot_h, alpha);
    const float  V       = BRDF_V_COMPONENT(n_dot_v, n_dot_l, n_dot_h, v_dot_h, alpha);

    const float3 Fd      = F_diff * base_color * g_inv_pi;
    const float3 Fs      = F_spec * 0.25f * D * V;

    return (Fd + Fs) * n_dot_l;
}

Note that instead of calculating the BRDF, I calculate the BRDF multiplied by the cosine factor. Furthermore, I do not use an explicit Geometry (G) component in the Microfacet model, but instead a Visibility (V) component. The V component is equal to the G component divided by the foreshortening terms (n_dot_l * n_dot_v). So the Microfacet model reduces to F * D * V / 4.

F_D90 is equal to (as mentioned in the course):


float F_D90(float v_dot_h, float roughness) {
    return 0.5f + 2.0f * roughness * sqr(v_dot_h);
}

Any thoughts?

I never check any of these dot products for equality with zero. I checked for NaNs or Infs by marking such pixels, but didn't found any in a couple of scenes.

The Fresnel effect at glancing angles feels sometimes a bit strange in for instance the Sponza scene. It seems like the walls are wet due to rain or so. (I use Disney's default roughness of 0.5f as my own default roughness if not specified).

Share this post


Link to post
Share on other sites

I readded "+ Fd90" to mimic Disney's BRDF as written. Without it looks pretty dark.

float3 CookTorranceBRDFxCos(float3 n, float3 l, float3 v, 
    float3 base_color, float roughness, float metalness) {
    
    const float  alpha   = sqr(roughness);
    const float  n_dot_l = sat_dot(n, l);
    const float  n_dot_v = sat_dot(n, v);
    const float3 h       = HalfDirection(l, v);
    const float  n_dot_h = sat_dot(n, h);
    const float  v_dot_h = sat_dot(v, h);

    const float  Fd90    = F_D90(v_dot_h, roughness);
    const float  FL      = 1.0f + Fd90 - BRDF_F_COMPONENT(n_dot_l, Fd90);
    const float  FV      = 1.0f + Fd90 - BRDF_F_COMPONENT(n_dot_v, Fd90);
    const float  F_diff  = (1.0f - metalness) * FL * FV;

    const float3 c_spec  = lerp(g_dielectric_F0, base_color, metalness);
    const float3 F_spec  = BRDF_F_COMPONENT(v_dot_h, c_spec);
    const float  D       = BRDF_D_COMPONENT(n_dot_h, alpha);
    const float  V       = BRDF_V_COMPONENT(n_dot_v, n_dot_l, n_dot_h, v_dot_h, alpha);

    const float3 Fd      = F_diff * base_color * g_inv_pi;
    const float3 Fs      = F_spec * 0.25f * D * V;

    return (Fd + Fs) * n_dot_l;
}

Though, not that visually different from an older Cook-Torrance version:

float3 CookTorranceBRDFxCos(float3 n, float3 l, float3 v, 
    float3 base_color, float roughness, float metalness) {
    
    const float  alpha   = sqr(roughness);
    const float  n_dot_l = sat_dot(n, l);
    const float  n_dot_v = sat_dot(n, v);
    const float3 h       = HalfDirection(l, v);
    const float  n_dot_h = sat_dot(n, h);
    const float  v_dot_h = sat_dot(v, h);

    const float3 F0      = lerp(g_dielectric_F0, base_color, metalness);
    const float3 F_spec  = BRDF_F_COMPONENT(v_dot_h, F0);
    const float3 F_diff  = (1.0f - F_spec) * (1.0f - metalness);
    const float  D       = BRDF_D_COMPONENT(n_dot_h, alpha);
    const float  V       = BRDF_V_COMPONENT(n_dot_v, n_dot_l, n_dot_h, v_dot_h, alpha);

    const float3 Fd      = F_diff * base_color * g_inv_pi;
    const float3 Fs      = F_spec * 0.25f * D * V;

    return (Fd + Fs) * n_dot_l;
}

 

Edited by matt77hias

Share this post


Link to post
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now

Sign in to follow this