Skip to content

KHR_materials_coat - "Advanced Coat" - #2534

Open
MiiBond wants to merge 2 commits into
KhronosGroup:mainfrom
MiiBond:mbond/KHR_materials_coat
Open

KHR_materials_coat - "Advanced Coat"#2534
MiiBond wants to merge 2 commits into
KhronosGroup:mainfrom
MiiBond:mbond/KHR_materials_coat

Conversation

@MiiBond

@MiiBond MiiBond commented Oct 7, 2025

Copy link
Copy Markdown
Contributor

This is a rough draft of an extension that would replace the existing KHR_materials_clearcoat as well as the proposals for various sub-extensions for clearcoat, including for IOR, color, anisotropy and darkening.

This is an attempt to bring compatibility with OpenPBR's coat layer.

Comment thread extensions/2.0/Khronos/KHR_materials_coat/README.md Outdated
@emackey emackey added the PBR Physically Based Rendering label Dec 12, 2025
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@dfattal

dfattal commented Aug 17, 2026

Copy link
Copy Markdown

Darkening: the hemisphere-averaged reflectance looks ~6.3x too high

We've implemented this extension in the DisplayXR model viewer — every property in the parameter tables, including coatNormalTexture — as one Vulkan shader shared across Windows, macOS, Linux and Android. @MiiBond asked us to bring our implementation notes here rather than keep them on email, so: four comments on this PR. This is the one we'd actually act on.

The Real-time Implementation section defines:

For environment lighting (IBL), however, light is coming from all angles so we calculate the hemisphere-averaged reflectance as $F_0 + 0.5 * F_{90}$ which gives a value halfway between $F_0$ and $F_{90}$.

"Halfway between $F_0$ and $F_{90}$" describes the arithmetic accurately, but that isn't a hemisphere average — it weights grazing incidence as heavily as normal incidence, whereas a hemisphere average weights by projected solid angle, which falls to zero at grazing.

The cosine-weighted average of the Schlick Fresnel has a closed form:

$$\bar{F} = \int_0^1 \left[ F_0 + (1-F_0)(1-\mu)^5 \right] 2\mu , d\mu = F_0 + \frac{1-F_0}{21}$$

For a 1.5-IOR coat ($F_0 = 0.04$):

value
as written, $F_0 + 0.5 F_{90}$ 0.54
cosine-weighted hemisphere average 0.086

a factor of 6.3.

Carried through the section's own $T = (1-R)^2$, at normal incidence and zero coat roughness:

$R$ $T$ light lost
$R_{IBL}$ as written 0.29 0.504 ~50%
$R_{IBL}$ with the cosine-weighted average 0.063 0.878 ~12%
$R_{directlight}$ 0.04 0.922 ~8%

So as written, ambient light loses ~50% where direct light loses ~8%. That gap is visible rather than subtle: it puts a dark band on the unlit side of every coated surface, because that is exactly where ambient dominates. With the cosine-weighted average it becomes 12% against 8%, and the band goes away. We ship the latter.

Suggested replacement for that paragraph:

For environment lighting (IBL), light arrives from all angles, so we use the cosine-weighted hemisphere average of the Schlick Fresnel, $\bar{F} = F_0 + (1 - F_0)/21$. We then add to this the Schlick Fresnel approximation for $dot(N, V)$ and divide by 2.
$$R_{IBL} = (fresnel(N, V) + \bar{F}) * 0.5$$

Happy to supply the before/after capture pair if it's useful for the discussion.


David Fattal — implementing for the DisplayXR project (model viewer).

@dfattal

dfattal commented Aug 17, 2026

Copy link
Copy Markdown

Darkening: which $R$ applies when a renderer has already summed direct and ambient?

The Real-time Implementation section gives two forms:

$$R_{directlight} = (fresnel(N, V) + fresnel(N, L)) * 0.5$$

$$R_{IBL} = (fresnel(N, V) + F_0 + 0.5 * F_{90}) * 0.5$$

but no guidance on which to apply where, and the choice isn't free. A forward renderer commonly has the direct and ambient contributions summed into a single colour by the time the coat layer is applied — one radiance value, two candidate $R$, and nothing in the text says what to do.

The failure mode isn't symmetric, so it's worth stating the rule explicitly. Applying $R_{directlight}$ to the ambient portion is badly wrong: $fresnel(N,L) \rightarrow 1$ as $N \cdot L \rightarrow 0$, so ambient light gets darkened hardest exactly where there is no direct light to justify it. On our parameter sweep that read as -37% at the terminator against -3% on the lit side, before we split the two paths apart.

Suggestion: say that $R$ is per-light-path — $R_{directlight}$ applied to each analytic light's contribution, $R_{IBL}$ applied to the IBL/ambient contribution — and add a sentence for renderers that cannot separate the two, recommending $R_{IBL}$. It is the bounded one, and it is the better approximation in the region where the difference is actually visible.

@dfattal

dfattal commented Aug 17, 2026

Copy link
Copy Markdown

coatColor and coatDarkening still affect the base when coatFactor is 0

Two parts of the spec disagree. Extending Materials says:

If coatFactor is zero, the entire coat layer is disabled.

But the layering pseudocode places both factors on the base, outside the mix:

coated_material =
  fresnel_coat(
    normal = coatNormal,
    ior = coatIor,
    weight = coatWeight,
    base = material * coatColor * coatDarkening,
    layer = coat_brdf)

together with:

function fresnel_coat(normal, ior, weight, base, layer) {
  f0 = ((1-ior)/(1+ior))^2
  fr = f0 + (1 - f0)*(1 - abs(dot(V, normal)))^5
  return mix(base, layer, weight * fr)
}

At coatWeight = 0, mix(base, layer, 0) returns base — which is still material * coatColor * coatDarkening. So a material with coatFactor: 0 and a non-default coatColorFactor remains tinted, and with the default coatDarkeningFactor: 1.0 it remains darkened. The layer is off, but its absorption and its darkening are still applied.

This bites hardest with a textured coat: a coatTexture that masks the coat off across part of a surface leaves those texels tinted and darkened, which will read to an artist as a broken asset rather than a spec ambiguity.

We gate both on the coat weight:

base = material * mix(vec3(1.0), coatColor * coatDarkening, coatWeight)

Either fold the two factors into the weighted term as above, or state in the text that both are conditioned on coatWeight so the "entire coat layer is disabled" sentence holds literally.

@dfattal

dfattal commented Aug 17, 2026

Copy link
Copy Markdown

coatDarkeningFactor default of 1.0 changes existing clearcoat assets before any conversion tool is involved

The parameter table gives:

|coatDarkeningFactor | number | The amount of physically-correct darkening. | No, default: 1.0 |

and Conversion from KHR_materials_clearcoat already acknowledges the consequence:

The one exception is coatDarkeningFactor where the default value is 1.0 which enables the physically-correct darkening. This may or may not match a renderer's handling of the KHR_materials_clearcoat extension [...] Therefore it is left up to the implementor of conversion tools whether to set this property to 0.0 during conversion.

The gap we hit: the same section opens by saying the parameters are "fully transferable to this extension, with no changes", which invites a loader to route KHR_materials_clearcoat straight through its coat code path at load time rather than through an offline conversion step. That is what we did, and it is the cheap and obvious implementation. When a loader does that, the default applies and every existing clearcoat asset silently gains darkening — with no conversion tool anywhere in the pipeline for the current advice to reach.

Two ways to close it:

  1. Make the default 0.0. Backwards-compatible by construction; assets wanting physical darkening opt in.
  2. Keep 1.0, but state normatively that a material carrying KHR_materials_clearcoat and no KHR_materials_coat MUST be rendered with darkening disabled, regardless of how the loader maps it internally.

We took (1) — darkening defaults to 0 unless a KHR_materials_coat block is actually present on the material. Either works; the current wording leaves the runtime-mapping case uncovered.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

PBR Physically Based Rendering

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants