-
Notifications
You must be signed in to change notification settings - Fork 29
Fix the coat darkening color shift #253
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev_1.2
Are you sure you want to change the base?
Fix the coat darkening color shift #253
Conversation
to avoid color shifts
Did some tests comparing the "full" and "simplified" formulas for the darkening (in equation 72 in the notes above). An interesting observation to point out is that the new formulation changes the look even in the case of a clear coat. Since before (in our implementation anyway), turning the ![]() |
In the last meeting, it was noted that the proposed behavior is not ideal in the "undarkened" clear coat case, since it leaves the extra saturation due to internal bounces intact. While what is more likely to be expected is that the undarkened clear-coat has the same apparent color as the base (apart from the Fresnel mixed on top). So I attempted to alter the description of the how the darkening is defined, to fix this, as follows: ![]() This explicitly makes the undarkened case have the "naive" albedo, that is nothing more than the Let's call the previous formula "chromaticity preserving". The new formula is "naive-albedo preserving". First with a clear-coat, darkening off:
Now with a clear-coat, and darkening on:
So this does fix the issue with the color saturation in the undarkened clear-coat. However, now let's add some blue coat absorption color, darkening off:
And here is with blue coat absorption color, darkening on:
We see that the naive-albedo preservation has re-introduced the color shift between darkened and undarkened. For another example (of the naive-albedo preserving behavior), here is a case with a rough yellow metallic base, and various coats:
This behavior is expected with the naive-albedo preservation. Since:
Arguably this behavior is acceptable, since the darkening slider is essentially dialing between the naive result and the physically correct result. On the other hand, I expect that there might be the objection that the darkening parameter should not cause any significant color shift even in the absorbing case. In that case we have to agree more clearly on what we want the slider to do. The difficulty is that the physical darkening does cause a color (i.e. chromaticity) shift compared to
We have to choose one of these behaviors I think. In my view the new naive-albedo preservation is acceptable, and can be thought of a physical correctness slider (dialing from "naive expectation" to closer to reality). It cannot simultaneously be a color-preserving slider. |
So it seems like the formula I was using to calculate the "physically correct" result taking into account the coat absorption color, was a bit wrong (apologies for adding to the confusion..). I had the darkening factor as: But it seemed unintuitive that the absorption color (the Looking again at the derivation, it seems it should actually be (to a first-order approximation at least): Which makes the absorption have more impact. This seems to resolve most of the apparent color discrepancy between the undarkened (i.e. "naive albedo") case and the physically darkened case:
The previous example with rough copper metal base. (No coat): ![]() Add clear coat:
Make coat color blue:
With this corrected formula, there is some color shifting, but the effect is mostly just extra saturation and darkening. So I feel it should be fine to use the newly proposed ("naive-albedo preserving") formulation, together with suggesting the corrected physical darkening math approximation. This does demonstrate that it is quite important to try to give good, principled suggested approximations for the physical effects, as otherwise the visual results can be inconsistent. (We're working on trying to develop a simulation testbed for the coat physical effects, and an official write-up with improved approximations based on that). |
Hi @portsmouth , Thanks for working on this improvement. Since you've iterated on this multiple times, I want to make sure I understand the problem that this is trying to solve, the goal, and the approach. Could you let me know if the following summary of the final proposal is correct? Problem: The material's hue changes when coat darkening is disabled or enabled. This happens because multiple interactions with the base surface causes more absorption when coat darkening is enabled. This happens regardless of whether a coat color is used, but setting the coat color to a complementary color of the base color can make the hue shift more visible by setting a neutral gray baseline. At a high level it seems like the proposal might have come full circle except for the inclusion of the coat color. Could you quickly summarize how the final proposal changes the behavior compared to the current specification? Thanks! |
Yes.
Yes, though for the colored coat, we probably don't want the base color itself to be visible. Avoiding the hue shift should not mean ignoring completely the coat color. The current spec does not really properly consider the effect of coat color on the darkening effect, so we need to improve that.
Not quite. When the darkening is disabled, the coat does affect the base color for sure. Just we need to be careful to define what we want this "undarkened" appearance to be, to be intuitive and not cause any big disconcerting color shift. So to elaborate, what I did in the new proposal is state that the "undarkened" appearance should have a particular albedo (at normal incidence), which is: where
This is just the regular "albedo-scaling" formula, which produces a look the same as e.g. Standard Surface. It is "naive", and will differ from the true physical appearance since (as you know) it ignores various inter-reflection and view dependence & Fresnel effects. But the true appearance is not very different from this naive albedo, there is essentially just some extra saturation (this is what I showed in the images immediately before this). In the clear-coat case, since Fresnel is grey the "naive" albedo is just a mix of the original base color with grey Fresnel, so the same hue, which is what is expected for the undarkened case. So having the darkening slider dial between this naive albedo and the physically correct albedo is (I think) a reasonable definition of what the slider should do. The new text simply implements that by saying that ![]() The green boxed equation 61 is the assumed form of the true physical albedo (it will always have this "top Fresnel plus remainder" form). We modify that in 63 by boosting the second term, producing in the undarkened case the "naive" albedo in the red boxed equation. It's written as a "boost" factor I think so far it's all quite general, as we are just saying the goal appearance (i.e. albedo at normal incidence) has to be some well defined thing (assuming the physically correct case is well-defined). A difficulty is that we don't say in detail exactly how the altered albedo is supposed to be achieved. It would depend on the specific approximations used. But in principle one could imagine in the full physical model, increasing the base albedo (automatically) so that the physical effects are effectively canceled, producing the required observed albedo as For a clear-coat, there is a known exact result. ![]() Where the For the absorbing case, we don't have such a simple result, but a rough approximation is doable (the orange equation below): ![]() So then we can write down an OK approximation for the terms in equation 61. So then we can figure out the "boost" in 62 required (i.e. the pink equations). (The boost factor obtained in the second pink equation obviously ends up just canceling the darkening factor as In the simplest albedo-scaling scheme, it all reduces to equation 73, i.e. apply a darkening factor which is interpolated between 1 and the physical approximate I'm aware this is a possibly a bit over technical for the spec. Perhaps we should restrict the text to giving the required appearance, i.e. equations 61-63, which is sufficient in principle. Then we could leave the derivation of how to implement this requirement in a particular approximate physical model, for our more detailed notes eventually. |
Just to be more explicit, in the case where the coat has an absorption color (e.g. a brown varnish), I don't think it would make any sense to just completely ignore this color in the undarkened case. Then for colored coats the darkening slider would in general completely change the apparent color (to the base color as if the coat absorption went away), which isn't very helpful. Instead I'm interpreting the darkening slider like a "physical correctness" slider, where it means to do a more principled approximation taking into account the inter-coat bounces. Then even when undarkened, the coat absorption color appears (as well as the base color), just it is less saturated since we don't account for the physical effect of the inter-reflections. The result seems to be a reasonably subtle change in overall appearance when changing the darkening slider, according to my tests so far (using the physical approximation presented in the spec, which I had to fix up a bit to account for absorption). |
I added an attempt at the MaterialX implementation in 4e44cb0. Actually, it seems the old darkening code was not really done correctly (by me...), as the coat weight was not being dealt with correctly (accounted for both in the coat BSDF weight, and the darkening calculation, which is wrong). I've refactored to do it more correctly, expressing the coated base as a statistical mix of coated and uncoated base (the coat weight then only appears as this mix weight, not in the The coated base (layer of coat on base) then accounts for the absorption color tint, and the (optional) extra darkening due to the inter-reflection calculation, as described in the updated proposal. It's now very explicit that when <!-- coat inter-reflection darkening, modulated via coat_darkening -->
<mix name="modulated_base_darkening" type="color3">
<input name="fg" type="color3" nodename="base_darkening" />
<input name="bg" type="color3" value="1.0, 1.0, 1.0" />
<input name="mix" type="float" interfacename="coat_darkening" />
</mix>
<multiply name="darkened_base_substrate" type="BSDF">
<input name="in1" type="BSDF" nodename="base_substrate" />
<input name="in2" type="color3" nodename="modulated_base_darkening" />
</multiply>
<!-- base_substrate, attenuated due to coat absorption color, and (optional) coat inter-reflection darkening -->
<multiply name="base_substrate_attenuated" type="BSDF">
<input name="in1" type="BSDF" nodename="darkened_base_substrate" />
<input name="in2" type="color3" nodename="coat_color" />
</multiply>
<!-- coated base -->
<layer name="coated_base_substrate" type="BSDF">
<input name="top" type="BSDF" nodename="coat_bsdf" />
<input name="base" type="BSDF" nodename="base_substrate_attenuated" />
</layer>
<!-- statistical mix of coated and uncoated base -->
<mix name="partially_coated_base" type="color3">
<input name="fg" type="BSDF" nodename="coated_base_substrate" />
<input name="bg" type="BSDF" nodename="base_substrate" />
<input name="mix" type="float" interfacename="coat_weight" />
</mix> Here This needs to be checked for compilation, and correctness. (Side note -- I wish I had some command line "MaterialX compiler" that can do this quickly without mucking around with graph editors, and give a good error report). |
Thanks for all of the clarifications @portsmouth . To clarify, I didn't mean to imply that the coat absorption color wouldn't apply in the undarkened case. I just neglected to consider it while writing certain parts of my previous message. I need to review the formulas more closely. I did have a few new questions, but they will likely be answered with a more careful reading of your proposal. For example:
Overall the approach and the changes make sense. |
I skimmed the text again. I think I'll have to work through the math or implement the new version myself to understand it better (or at least review it at a time when I can focus on it more deeply). It would be nice if it could somehow be formulated in a more intuitive way, but that might not be possible. Here are some things that aren't fully clear to me (or at least aren't intuitive) (using the equation numbering from your previous comment):
Sorry if many of these are naive questions that could be answered myself with a careful analysis. That said, if some of these things can somehow be clarified in the specification, it might save other readers some effort and potential confusion. |
Thanks for the commentary Peter. I'll attempt to answer your queries:
So this is supposed to be a very minimal expression for the form of the directional albedo of the coat+base setup (for normal incidence). In my former notes, I wrote this down for the case of a clear-coat (as below), and it does have the basic form of "Fresnel reflection of coat, plus Fresnel transmittance times stuff", which is all i'm saying in Equation 61. ![]() The BRDF that was derived from is reciprocal (see Equation 7 below). But of course the directional albedo is a function of only one direction, so reciprocity isn't explicit in it. Though, maybe we need to be more careful about whether the albedo of the coated base always has the form of Equation 61, i.e. (at normal incidence) So hopefully the formula is reasonably general, i.e. allowing the
Then the darkening can described as an effective alteration of that albedo (lerping into As noted earlier:
Yeah maybe it could be said in a clearer way. It does make sense though, since ![]() How can we rephrase this to be more intuitive?
As noted above, maybe it's unintuitive, but
Yep fair point. I used
Yes definitely 👍. I think the plan should be to move the formulas for the darkening approximation out out of the spec, and into our technical report. There, we can do a more thorough job and include such a table. (The spec only needs to contain what is strictly necessary to define the model. Ideally all the suggested approximations, that aren't mandatory, can be moved into the tech report).
It can be derived that way, though in the Elias paper I reference, it isn't -- they use radiative transfer equation to solve for it. We could give the summation derivation though in the tech report, perhaps, to make it a bit clearer where this comes from.
It's from the Elias paper. I reproduced it in my notes. The exact BRDF for a (smooth) absorbing coat on Lambertian base is: ![]() ![]() I roughly approximated the We can probably do a better job of approximating it, but it's a first-order attempt, that probably isn't too bad. This should certainly be relegated to the tech report, as it's a very specific attempt to come up with an approximation, that could be done in other (more accurate) ways, and different implementations may want to use different schemes to approximate it. (And if the base isn't Lambertian, and/or the coat interface is rough, the formula is no longer correct, so some more general approximation is required).
It was derived for a Lambertian base. In the case of a dielectric base, then the theory will differ a bit. If the base is a smooth dielectric, then it's also possible to solve it exactly I think, for the BRDF/BTDF and their albedos. (The derivation is easier in this case since it's all purely specular). The darkening effect in this case is somewhat lessened, since under specular reflection no TIR occurs (this is described in the Weta "Path of Water" paper a bit). Based on that, I made an attempt to account for this as in the discussion in the spec (again, a very specific approximation that is better to move out of the spec): ![]() Yes of course the transmission through such a dielectric base will be affected by the light transport inside the coat. It all gets too complicated to fully describe in the spec, but is all standard light transport stuff, so we don't need to really. This dielectric base case can also be expanded on in the tech report.
Yes.. they do, when the darkening goes to zero. We are boosting the base albedo to cancel the physical darkening, by design.
That might a be a bit clearer, agreed. 👍
No problem, thanks for working through it in detail. 🙏 We should certainly try to simplify it in the spec to the clearest, most minimal statement of how the darkening parameter works. Then the gory details of how an implementation can approximate this, should be relegated to the tech report. As a priority, we just need to agree that the statement of how the darkening parameter is defined is consistent and functions as required for the purposes of the spec, at least. That consists minimally of equations 61 and 62 only. |
My description of this here was maybe a bit misleading. All i'm doing in the suggested approximation, is writing down the physical darkening (from Elias's formulas) in the form of Equation 61, then applying the stated boost factor from Equation 62. This isn't really the same as "making the base albedo artificially bright", since I'm not explicitly solving for the new Lambert BRDF albedo in Equation 71 that produces the required boost. I just apply the required boost, and assume that is the result of some suitably selected base albedo.. |
Here is an example pseudo-code for the coat darkening that gives the full approximate calculation suggested in the text, in a (hopefully) suitably generic form to be understandable. We could put this (or similar) in the tech report. // Update the set of substrate BSDFs with a coat layer
// Given:
// - ShaderGlobals with incoming light direction wi, surface normal N, etc.
// - Params struct containing:
// - .coat_darkening: float, the darkening factor for the coat layer.
// - .coat_weight: float, the weight of the coat layer.
// - .coat_color: vec3, the color of the coat layer.
// - .coat_ior: float, the index of refraction for the coat layer.
// - .specular_ior: float, the index of refraction for the specular layer.
// - .specular_weight: float, the weight of the specular layer.
// - .specular_roughness: vec3, the roughness of the specular layer.
// - .base_color: vec3, the color of the base layer.
// - .base_weight: vec3, the weight of the base layer.
// - .base_metalness: float, the metalness of the base layer.
// - .transmission_weight: float, the weight of the transmission layer.
// - .subsurface_weight: float, the weight of the subsurface layer.
// - .subsurface_color: vec3, the color of the subsurface layer.
void AddCoat(const ShaderGlobals& sg, const Params& P, BSDFs& substrate_bsdfs)
{
float C = P.coat_weight;
float M = P.base_metalness;
float xi = P.specular_weight;
float T = P.transmission_weight;
float S = P.subsurface_weight;
vec3 WHITE = vec3(1.f);
// Compute the base_darkening factor based on the coat properties:
vec3 base_darkening = P.coat_color;
if (P.coat_darkening > 0.f)
{
float Kr = 1 - (1 - DielectricFresnelAvg(P.coat_ior))/sqr(P.coat_ior); // internal diffuse reflection coefficient (rough case, Eqn 66)
float Ks = DielectricFresnel(abs(dot(sg.wi, sg.N)), P.coat_ior); // internal diffuse reflection coefficient (smooth case, Eqn 67)
float eta_s = mix(P.specular_ior, P.specular_ior/P.coat_ior, C); // specular/coat IOR ratio
float F0 = DielectricFresnel(1.f, eta_s); // thus coat Fresnel at normal incidence
float Fs = clamp(xi * F0, 0.f, 1.f); // thus modulated dielectric Fresnel
float rd = mix(1.f, P.specular_roughness, Fs); // thus estimate of roughness of dielectric base
float rm = P.specular_roughness.r; // estimate of roughness of metallic base
float rb = mix(rd, rm, M); // thus estimate roughness of entire base (Eqn 69)
float K = mix(Ks, Kr, rb); // thus estimate internal diffuse reflection coeff (Eqn 68)
vec3 Cb = P.base_weight * P.base_color; // opaque base albedo
vec3 E_dielec = mix(mix(S, Cb, P.subsurface_color), vec3(1.f-F0), T); // estimate of dielectric base albedo
vec3 E_metal = clamp(Cb * xi, 0.f, 1.f); // estimate of metallic base albedo
vec3 E_base = mix(E_dielec, E_metal, M); // thus estimate entire base albedo (Eqn 70)
vec3 Delta = (WHITE - K) / (WHITE - E_base*K*P.coat_color); // thus compute base darkening factor (Eqn 71)
base_darkening *= mix(WHITE, Delta, P.coat_darkening); // modulate according to coat_darkening (Eqn 73)
}
// Apply albedo scaling to model the layering of the coat on the base, taking into account the base darkening.
// First, the coat BSDF has the coat_weight presence weight functioning as a multiplier:
BSDF* coat_bsdf = coat_weight * DielectricBSDF(sg, P.coat_ior, P.coat_roughness);
// The base BSDFs are then multiplied by the coat_throughput factor:
vec3 coat_albedo = Albedo(coat_bsdf);
vec3 coat_throughput = mix(WHITE, base_darkening * (WHITE - coat_albedo), coat_weight)
substrate_bsdfs *= coat_throughput;
substrate_bsdfs.add(coat_bsdf);
} |
All sounds good, thanks for your detailed replies @portsmouth . Everything makes sense at a high level. I'll let you know if I think of any ways to refine the description. |
For reference, the issue with the coat darkening was this color shift that can happen in the presence of a strong coat color:
I came up with this restatement of how the coat darkening operates, which should ensure there is no color (i.e. chromaticity) shift between the darkened and "un-darkened" results.
The resulting approximation corresponds simply to dividing the darkening factor by its luminance (in the undarkened case) which was arranged by design, but at least it follows from some reasonably logical definitions. (Actually I think it makes a bit more sense now, since we don't ignore the absorption color of the coat, and are more explicit about how this all works in RGB).
In code this amounts to:
where
Delta
is the darkening factor formula given in the spec. Then thisbase_darkening
is multiplied into the base lobe.