Skip to content

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

Open
wants to merge 8 commits into
base: dev_1.2
Choose a base branch
from

Conversation

portsmouth
Copy link
Contributor

@portsmouth portsmouth commented Apr 15, 2025

For reference, the issue with the coat darkening was this color shift that can happen in the presence of a strong coat color:

image

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).

image

In code this amounts to:

RGB base_darkening = Lerp(coat_weight, RGB_WHITE, Delta * Lerp(coat_darkening, 1.f/Luminance(Delta), 1.f));

where Delta is the darkening factor formula given in the spec. Then this base_darkening is multiplied into the base lobe.

@portsmouth portsmouth changed the title Prelimiary proposal for fixing the coat darkening statement in the spec Proposal for fixing the coat darkening statement in the spec Apr 15, 2025
@portsmouth
Copy link
Contributor Author

portsmouth commented Apr 15, 2025

Here's a check that the new darkening logic does the right thing.

The two rows are:

  • yellow (1, 1, 0.5) base and clear coat
  • yellow (1, 1, 0.5) base and blue (0.5, 0.5, 1) coat

The main point is that turning off the darkening in the blue coated case, keeps the color intact.

no coat coated, coat_darkening=1 coated, coat_darkening=0
Clear coat test1_uncoated test1_coat_d1 test1_coat_d0
Blue coat test2_uncoated test2_coat_d1 test2_coat_d0
Blue coat (old behavior) test2_uncoated coat_darkened coat_undarkened

@portsmouth portsmouth changed the title Proposal for fixing the coat darkening statement in the spec Proposal for fixing the coat darkening color shift Apr 15, 2025
@portsmouth portsmouth changed the title Proposal for fixing the coat darkening color shift Fix the coat darkening color shift Apr 15, 2025
@portsmouth
Copy link
Contributor Author

portsmouth commented Apr 23, 2025

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 coat_darkening to zero preserved the chromaticity of the base lobe. But it is more correct for the base lobe to be slightly more saturated even when "undarkened" (to be equal brightness), since physically the coat generates internal bounces which enhance the base color. Our previous implementation failed to model this.

image

@portsmouth
Copy link
Contributor Author

portsmouth commented May 2, 2025

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:

image

This explicitly makes the undarkened case have the "naive" albedo, that is nothing more than the base_color tinted with the coat_color, mixed with Fresnel. Then the formulas are simpler actually (there is no more need for an approximate version).

Let's call the previous formula "chromaticity preserving". The new formula is "naive-albedo preserving".

First with a clear-coat, darkening off:

current spec chromaticity preserving naive-albedo preserving
case_2 case_2 case_2

Now with a clear-coat, and darkening on:

current spec chromaticity preserving naive-albedo preserving
case_3 case_3 case_3

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:

current spec chromaticity preserving naive-albedo preserving
case_4 case_4 case_4

And here is with blue coat absorption color, darkening on:

current spec chromaticity preserving naive-albedo preserving
case_5 case_5 case_5

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:

no coat clear coat, darkening off clear coat, darkening on blue coat, darkening off blue coat, darkening on
no_coat clearcoat_undarkened clearcoat_darkened bluecoat_undarkened bluecoat_darkened

This behavior is expected with the naive-albedo preservation. Since:

  • in the undarkened case, the color is ("naively") given simply by the base_color multiplied by the coat_color (about grey, since yellow * blue = grey).
  • in the darkened case, the color is (approximately) physically correct, so there is a shift compared the naive color, due to the inter-reflections which enhance the base color. In other words, the darkened case has less of the base color than it physically should do (so appears more blue in both cases).

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 base_color * coat_color. So either (given that the physical result is obtained with darkening on):

  • the darkening parameter avoids this chromaticity shift (and instead only "undarkens" the luminance), thus making the undarkened result not be the same chromaticity as base_color * coat_color. But this was essentially the objection about the chromaticity -preserving formula.
  • the darkening parameter does not fix the color (i.e. chromaticity) shift and produces base_color * coat_color (modulo Fresnel), which is what the new naive-albedo preservation does.

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.

@portsmouth
Copy link
Contributor Author

portsmouth commented May 3, 2025

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:
image

But it seemed unintuitive that the absorption color (the $\mathbf{T}^2_\mathrm{coat}$ factor) doesn't have a bigger effect (or at least comparable effect to the base albedo) since it tints every path in the internal ladder of bounces.

Looking again at the derivation, it seems it should actually be (to a first-order approximation at least):
image

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:

no coat clear coat, darkening off clear coat, darkening on blue coat, darkening off blue coat, darkening on
no_coat clearcoat_undarkened clearcoat_darkened bluecoat_undarkened bluecoat_darkened

The previous example with rough copper metal base.

(No coat):

case_1

Add clear coat:

darkening current spec naive-albedo preserving (fixed)
off case_2 case_2
on case_3 case_3

Make coat color blue:

darkening current spec naive-albedo preserving (fixed)
off case_4 case_4
on case_5 case_5

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).

@peterkutz
Copy link
Contributor

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.
Goal: Make sure the user-specified base color is visible when coat darkening is disabled and avoid the hue shift when coat darkening is enabled, even if this means ignoring the extra saturation that is caused by the extra absorption events at the base surface. Also describe how the coat color is supposed to be incorporated into the darkening.
Solution: Update the text and formulas to clarify that the coat should not affect the base color when darkening is disabled (apart from basic Fresnel effects), and incorporate the coat absorption from the coat color parameter into the coat darkening so it's applied along every internal path segment.

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!

@portsmouth
Copy link
Contributor Author

portsmouth commented May 16, 2025

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.

Yes.

Goal: Make sure the user-specified base color is visible when coat darkening is disabled and avoid the hue shift when coat darkening is enabled, even if this means ignoring the extra saturation that is caused by the extra absorption events at the base surface. Also describe how the coat color is supposed to be incorporated into the darkening.

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.

Solution: Update the text and formulas to clarify that the coat should not affect the base color when darkening is disabled (apart from basic Fresnel effects), and incorporate the coat absorption from the coat color parameter into the coat darkening so it's applied along every internal path segment.

At a high level it seems like the proposal might have come full circle except for the inclusion of the coat color.

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:

$$F_0 + \mathbf{T}^2_\mathrm{coat} \, \mathbf{E}_b (1 - F_0)$$

where

  • $F_0$ is the coat Fresnel
  • $\mathbf{T}^2_\mathrm{coat}$ is coat_color
  • $\mathbf{E}_b$ is the albedo of the base

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 coat_darkening $\delta$ defines the required effective albedo of the coat + base be given by interpolating from the naive result at coat_darkening=0 to the physical result at coat_darkening=1:

image

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 $B(\delta)$ applied to the physical albedo expression.

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 coat_darkening is varied. The text gives an outline of how that is done using a simple physical model of the darkening.

For a clear-coat, there is a known exact result.

image

Where the $\Delta$ factor is the "darkening" which occurs additionally to the usual albedo-scaling result.

For the absorbing case, we don't have such a simple result, but a rough approximation is doable (the orange equation below):

image

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 coat_darkening goes to zero..).

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 $\Delta$.


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.

@portsmouth
Copy link
Contributor Author

portsmouth commented May 20, 2025

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.

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).

@AdrienHerubel AdrienHerubel requested a review from peterkutz May 27, 2025 17:03
@portsmouth
Copy link
Contributor Author

portsmouth commented May 30, 2025

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 coat_bsdf whose weight is set to 1).

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_darkening=0, the result is simply the coat layered "naively" (i.e. via albedo-scaling) on top of the "base tinted by the absorption color", with no other effects, which is essentially what I proposed in this PR.

    <!-- 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 base_darkening is the $\Delta$ term in the formulas. So the base_substrate_attenuated and modulated_base_darkening nodes are implementing equation (73) above directly.

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).

@peterkutz
Copy link
Contributor

peterkutz commented Jun 14, 2025

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:

  • Does the F₀ + (1 − F₀) x form consider reciprocity?
  • Which things are defined only at normal incidence and why?
    • Why does only ever appear at normal incidence?

Overall the approach and the changes make sense.

@peterkutz
Copy link
Contributor

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):

  • In Equation 61, does this formula consider reciprocity in any way?
  • Right before Equation 62, the text says that "the effect of coat_darkening is to multiply by a boost factor", which sounds a bit counterintuitive.
  • In Equation 62, the fact that there's a T²_coat term in the numerator is counterintuitive – it seems like multiplying by T²_coat would darken the result whereas a boost would brighten the result.
  • I wonder if it would be helpful to replace capital delta with a Latin character. Even though I know Δ is just a variable, I read it as a "change" symbol half the time.
  • Maybe it would be helpful to have a key of terms in a box or something, like K is the albedo of the inside of the coat, E_b is the albedo of the base surface, etc.
  • Equation 65 is the simplified form of a summation, right? Maybe it would be useful to show the expanded summation itself if not doing so already.
  • How was Equation 71 derived? Is the T²_coat conceptually considered part of the base albedo, or the albedo of the inside of the coat, or both, or neither?
  • Does Equation 71 (and related equations) only apply to darkening reflections from the base? What about transmissions through the base?
  • In Equation 73, it's not very intuitive that the boost factor B and the darkening factor Δ are multiplied together. It seems like they would cancel each other out.
  • It seems like the "View-dependent absorption" section should be moved before the "Darkening" section, since the darkening incorporates the transmittance.

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.

@portsmouth
Copy link
Contributor Author

portsmouth commented Jun 21, 2025

Thanks for the commentary Peter. I'll attempt to answer your queries:


In Equation 61, does this formula consider reciprocity in any way?

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.

image

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)
image
For a smooth coat, I think it's clear that it does (whatever the base is). For a rough coat though, the $F_0$ in the formula would no longer be pure Fresnel but depend in some way on the roughness. I think though the $(1-F_0)$ would still appear, since if the albedo of the interaction with the base is 1, the total albedo must be 1.

So hopefully the formula is reasonably general, i.e. allowing the $\mathbf{E}^\prime_c$ to be separated out from the coat Fresnel.
The albedo $\mathbf{E}^\prime_c$ being:

"the albedo due to transmission into the coat medium (and scattering off the base substrate, potentially multiple internal reflections off the coat interface, and re-transmission back out".

Then the darkening can described as an effective alteration of that albedo (lerping into $\mathbf{T}_\mathrm{coat}^2 \mathbf{E}_b$).

As noted earlier:

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 coat_darkening is varied. The text gives an outline of how that is done using a simple physical model of the darkening.



Right before Equation 62, the text says that "the effect of coat_darkening is to multiply by a boost factor", which sounds a bit counterintuitive.

Yeah maybe it could be said in a clearer way. It does make sense though, since $\mathbf{E}^\prime_c$ is supposed to be the physically correct result, so it will generally be darker than $\mathbf{T}_\mathrm{coat}^2 \mathbf{E}_b$, due to the internal reflections. So when the "darkening" is low/off, the boost factor $B &gt; 1$, to compensate and make the appearance less dark. when the darkening is high/on, the "boost" goes down to 1, so there is no brightening:

image

How can we rephrase this to be more intuitive?



In Equation 62, the fact that there's a T²_coat term in the numerator is counterintuitive – it seems like multiplying by T²_coat would darken the result whereas a boost would brighten the result.

As noted above, maybe it's unintuitive, but $B \ge 1$ so it really is a "boost". Intuitively, the base albedo is boosted (relative to its physically correct value) to turn off the physical darkening.



I wonder if it would be helpful to replace capital delta with a Latin character. Even though I know Δ is just a variable, I read it as a "change" symbol half the time.

Yep fair point. I used $\Delta$ (the Greek "D") to indicate darkening, but a Latin $D$ is maybe more straightforward, so long as people don't get it confused with e.g. the NDF $D$.. I do kind of prefer $\Delta$, so maybe let's discuss options. It's just terminology though, so not a huge deal.



Maybe it would be helpful to have a key of terms in a box or something, like K is the albedo of the inside of the coat, E_b is the albedo of the base surface, etc.

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).



Equation 65 is the simplified form of a summation, right? Maybe it would be useful to show the expanded summation itself if not doing so already.

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.



How was Equation 71 derived? Is the T²_coat conceptually considered part of the base albedo, or the albedo of the inside of the coat, or both, or neither?

It's from the Elias paper. I reproduced it in my notes. The exact BRDF for a (smooth) absorbing coat on Lambertian base is:

image image

I roughly approximated the $K$ integral by replacing the angle-dependent term with $T$ inside the integral with $T^2$, thus pulling it out. Then it reduces to Equation 71.

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).



Does Equation 71 (and related equations) only apply to darkening reflections from the base? What about transmissions through the base?

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):

image

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.



In Equation 73, it's not very intuitive that the boost factor B and the darkening factor Δ are multiplied together. It seems like they would cancel each other out.

Yes.. they do, when the darkening goes to zero. We are boosting the base albedo to cancel the physical darkening, by design.
Maybe it's confusing, but in my suggestion here, the physical darkening is always happening, and we are just making the base albedo artificially bright so that the combination of physical darkening and brighter base produces the un-darkened result. Is that too confusing? That seems the least invasive thing to do (i.e. boosting the base albedo), since we don't have to break any light transport or physics -- apart from making the base uniformly brighter by an unspecified mechanism.



It seems like the "View-dependent absorption" section should be moved before the "Darkening" section, since the darkening incorporates the transmittance.

That might a be a bit clearer, agreed. 👍



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.

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.
(Obviously if you can think of a restatement you prefer, let's discuss).

@portsmouth
Copy link
Contributor Author

portsmouth commented Jun 22, 2025

Maybe it's confusing, but in my suggestion here, the physical darkening is always happening, and we are just making the base albedo artificially bright so that the combination of physical darkening and brighter base produces the un-darkened result

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..

@portsmouth
Copy link
Contributor Author

portsmouth commented Jul 12, 2025

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);
}

@peterkutz
Copy link
Contributor

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants