Implement KHR_materials_dispersion (#9471)

This change adds support for the KHR_materials_dispersion glTF extension, which introduces a dispersion property for refractive materials.

The dispersion property controls the angular separation of colors transmitting through a refractive object, allowing for more realistic rendering of materials like glass and diamonds.

  Changes include:
   - Added a dispersion property to the material definition.
   - Updated the shaders to incorporate the dispersion effect in the refraction calculations.
   - Added a new test case for dispersion.
   - Updated the material documentation to include the new dispersion property.
This commit is contained in:
Nemi
2025-12-12 22:55:25 +01:00
committed by GitHub
parent 98cec207ce
commit f701c1e1b5
13 changed files with 163 additions and 57 deletions

View File

@@ -6,3 +6,5 @@
appropriate header in [RELEASE_NOTES.md](./RELEASE_NOTES.md).
## Release notes for next branch cut
- materials: added support for the glTF `KHR_materials_dispersion` extension, which adds dispersion for refractive objects

View File

@@ -97,6 +97,7 @@ in table [standardProperties].
**transmission** | Defines how much of the diffuse light of a dielectric is transmitted through the object, in other words this defines how transparent an object is
**ior** | Index of refraction, either for refractive objects or as an alternative to reflectance
**microThickness** | Thickness of the thin layer of refractive objects
**dispersion** | Strength of the dispersion effect for refractive objects, specified as 20/Abbe number
**bentNormal** | A normal pointing in the average unoccluded direction. Can be used to improve indirect lighting quality
**shadowStrength** | Strength factor between 0 and 1 for all shadows received by this material
[Table [standardProperties]: Properties of the standard model]
@@ -126,6 +127,7 @@ The type and range of each property is described in table [standardPropertiesTyp
**absorption** | float3 | [0..n] |
**microThickness** | float | [0..n] |
**thickness** | float | [0..n] |
**dispersion** | float | [0..n] | Realistic values are between [0, 1], with the exception of Rutile, which has a value of 2.04
[Table [standardPropertiesTypes]: Range and type of the standard model's properties]
@@ -153,13 +155,14 @@ The type and range of each property is described in table [standardPropertiesTyp
as-is, which can lead to physically impossible materials, however, this might be desirable
for artistic reasons.
!!! Note: About `thickness` and `microThickness` for refraction
!!! Note: About `thickness`, `microThickness` and `dispersion` for refraction
`thickness` represents the thickness of solid objects in the direction of the normal, for
satisfactory results, this should be provided per fragment (e.g.: as a texture) or at least per
vertex. `microThickness` represent the thickness of the thin layer of an object, and can
generally be provided as a constant value. For example, a 1mm thin hollow sphere of radius 1m,
would have a `thickness` of 1 and a `microThickness` of 0.001. Currently `thickness` is not
used when `refractionType` is set to `thin`.
would have a `thickness` of 1 and a `microThickness` of 0.001. Dispersion controls the angular
separation of colors transmitting through a volume, and can be set by a contant value.
Currently `thickness` and `dispersion` are not used when `refractionType` is set to `thin`.
### Base color
@@ -651,6 +654,23 @@ the `refractionType` is set to `solid` and `absorption` coefficients are set.
![Figure [varyingThickness]: `thickness` varying from 0.0 at the top of the prism to 3.0 at the
bottom of the prism](images/material_thickness.png)
### Dispersion
The dispersion property controls the angular separation of colors transmitting through a relatively
clear volume. It can only be used when `refractionType` is set to `volume`.
Its value is specified as 20/Abbe number. When the value is zero, no dispersion is used.
Table [commonMatDispersion] describes acceptable dispersion values for various types of materials.
Material | Abbe Number (V) | Dispersion (20/V)
--------------------------:|:------------------:|:-----------------
Rutile | 9.8 | 2.04
Polycarbonate | 32 | 0.625
Diamond | 55 | 0.36
Water | 55 | 0.36
Crown Glass | 59 | 0.33
[Table [commonMatDispersion]: Dispersion of common materials]
## Subsurface model
### Thickness
@@ -2276,6 +2296,7 @@ struct MaterialInputs {
float3 absorption; // default float3(0.0, 0.0, 0.0)
float ior; // default: 1.5
float microThickness; // default: 0.0, not available with refractionType "solid"
float dispersion; // default: 0.0, not available with refractionType "thin"
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -203,7 +203,7 @@ enum class ReflectionMode : uint8_t {
// can't really use std::underlying_type<AttributeIndex>::type because the driver takes a uint32_t
using AttributeBitset = utils::bitset32;
static constexpr size_t MATERIAL_PROPERTIES_COUNT = 30;
static constexpr size_t MATERIAL_PROPERTIES_COUNT = 31;
enum class Property : uint8_t {
BASE_COLOR, //!< float4, all shading models
ROUGHNESS, //!< float, lit shading models only
@@ -230,6 +230,7 @@ enum class Property : uint8_t {
ABSORPTION, //!< float3, how much light is absorbed by the material
TRANSMISSION, //!< float, how much light is refracted through the material
IOR, //!< float, material's index of refraction
DISPERSION, //!< float, material's dispersion
MICRO_THICKNESS, //!< float, thickness of the thin layer
BENT_NORMAL, //!< float3, all shading models only, except unlit
SPECULAR_FACTOR, //!< float, lit shading models only, except subsurface and cloth

View File

@@ -46,6 +46,7 @@ std::unordered_map<std::string, Property> Enums::mStringToProperty = {
{ "absorption", Property::ABSORPTION },
{ "transmission", Property::TRANSMISSION },
{ "ior", Property::IOR },
{ "dispersion", Property::DISPERSION },
{ "microThickness", Property::MICRO_THICKNESS },
{ "bentNormal", Property::BENT_NORMAL },
{ "specularFactor", Property::SPECULAR_FACTOR },

View File

@@ -1248,6 +1248,7 @@ char const* CodeGenerator::getConstantName(MaterialBuilder::Property property) n
case Property::ABSORPTION: return "ABSORPTION";
case Property::TRANSMISSION: return "TRANSMISSION";
case Property::IOR: return "IOR";
case Property::DISPERSION: return "DISPERSION";
case Property::MICRO_THICKNESS: return "MICRO_THICKNESS";
case Property::BENT_NORMAL: return "BENT_NORMAL";
case Property::SPECULAR_FACTOR: return "SPECULAR_FACTOR";

View File

@@ -475,6 +475,32 @@ TEST_F(MaterialCompiler, StaticCodeAnalyzerTransmission) {
EXPECT_TRUE(PropertyListsMatch(expected, properties));
}
TEST_F(MaterialCompiler, StaticCodeAnalyzerDispersion) {
std::string fragmentCode(R"(
void material(inout MaterialInputs material) {
prepareMaterial(material);
material.absorption = vec3(0.0);
material.transmission = 0.96;
material.ior = 1.33;
material.dispersion = 0.33;
}
)");
std::string shaderCode = shaderWithAllProperties(*jobSystem, ShaderStage::FRAGMENT,
fragmentCode, "", filamat::MaterialBuilder::Shading::LIT,
filamat::MaterialBuilder::RefractionMode::CUBEMAP);
GLSLTools glslTools;
MaterialBuilder::PropertyList properties{ false };
glslTools.findProperties(ShaderStage::FRAGMENT, shaderCode, properties);
MaterialBuilder::PropertyList expected{ false };
expected[size_t(filamat::MaterialBuilder::Property::ABSORPTION)] = true;
expected[size_t(filamat::MaterialBuilder::Property::TRANSMISSION)] = true;
expected[size_t(filamat::MaterialBuilder::Property::IOR)] = true;
expected[size_t(filamat::MaterialBuilder::Property::DISPERSION)] = true;
EXPECT_TRUE(PropertyListsMatch(expected, properties));
}
TEST_F(MaterialCompiler, StaticCodeAnalyzerClearCoatRoughness) {
std::string fragmentCode(R"(
void material(inout MaterialInputs material) {

View File

@@ -94,10 +94,11 @@ struct alignas(4) MaterialKey {
bool hasSheen : 1;
bool hasIOR : 1;
bool hasVolume : 1;
bool hasDispersion : 1;
bool hasSpecular : 1;
bool hasSpecularTexture : 1;
bool hasSpecularColorTexture : 1;
bool padding : 2;
bool padding : 1;
// -- 32 bit boundary --
uint8_t specularTextureUV;
uint8_t specularColorTextureUV;

View File

@@ -1397,6 +1397,7 @@ MaterialKey FAssetLoader::getMaterialKey(const cgltf_data* srcAsset,
.hasSheen = !!inputMat->has_sheen,
.hasIOR = !!inputMat->has_ior,
.hasVolume = !!inputMat->has_volume,
.hasDispersion = !!inputMat->has_dispersion,
.hasSpecular = !!inputMat->has_specular,
.hasSpecularTexture = spConfig.specular_texture.texture != nullptr,
.hasSpecularColorTexture = spConfig.specular_color_texture.texture != nullptr,
@@ -1488,6 +1489,7 @@ MaterialInstance* FAssetLoader::createMaterialInstance(const cgltf_material* inp
auto sgConfig = inputMat->pbr_specular_glossiness;
auto ccConfig = inputMat->clearcoat;
auto trConfig = inputMat->transmission;
auto dpConfig = inputMat->dispersion;
auto shConfig = inputMat->sheen;
auto vlConfig = inputMat->volume;
auto spConfig = inputMat->specular;
@@ -1671,6 +1673,10 @@ MaterialInstance* FAssetLoader::createMaterialInstance(const cgltf_material* inp
}
}
if (matkey.hasDispersion) {
mi->setParameter("dispersion", dpConfig.dispersion);
}
// IOR can be implemented as either IOR or reflectance because of ubershaders
if (matkey.hasIOR) {
if (mi->getMaterial()->hasParameter("ior")) {

View File

@@ -334,6 +334,12 @@ std::string shaderFromKey(const MaterialKey& config) {
)SHADER";
}
if (config.hasDispersion) {
shader += R"SHADER(
material.dispersion = materialParams.dispersion;
)SHADER";
}
if (config.hasSpecular) {
shader += R"SHADER(
material.specularFactor = materialParams.specularStrength;
@@ -599,6 +605,10 @@ Material* createMaterial(Engine* engine, const MaterialKey& config, const UvMap&
builder.parameter("ior", MaterialBuilder::UniformType::FLOAT);
}
if (config.hasDispersion) {
builder.parameter("dispersion", MaterialBuilder::UniformType::FLOAT);
}
if (config.unlit) {
builder.shading(Shading::UNLIT);
} else if (config.useSpecularGlossiness) {

View File

@@ -447,22 +447,22 @@ struct Refraction {
float d;
};
void refractionSolidSphere(const PixelParams pixel,
void refractionSolidSphere(float etaIR, float etaRI, float thickness,
const vec3 n, vec3 r, out Refraction ray) {
r = refract(r, n, pixel.etaIR);
r = refract(r, n, etaIR);
float NoR = dot(n, r);
float d = pixel.thickness * -NoR;
float d = thickness * -NoR;
ray.position = vec3(shading_position + r * d);
ray.d = d;
vec3 n1 = normalize(NoR * r - n * 0.5);
ray.direction = refract(r, n1, pixel.etaRI);
ray.direction = refract(r, n1, etaRI);
}
void refractionSolidBox(const PixelParams pixel,
void refractionSolidBox(float etaIR, float thickness,
const vec3 n, vec3 r, out Refraction ray) {
vec3 rr = refract(r, n, pixel.etaIR);
vec3 rr = refract(r, n, etaIR);
float NoR = dot(n, rr);
float d = pixel.thickness / max(-NoR, 0.001);
float d = thickness / max(-NoR, 0.001);
ray.position = vec3(shading_position + rr * d);
ray.direction = r;
ray.d = d;
@@ -473,15 +473,15 @@ void refractionSolidBox(const PixelParams pixel,
#endif
}
void refractionThinSphere(const PixelParams pixel,
void refractionThinSphere(float etaIR, float uThickness,
const vec3 n, vec3 r, out Refraction ray) {
float d = 0.0;
#if defined(MATERIAL_HAS_MICRO_THICKNESS)
// note: we need the refracted ray to calculate the distance traveled
// we could use shading_NoV, but we would lose the dependency on ior.
vec3 rr = refract(r, n, pixel.etaIR);
vec3 rr = refract(r, n, etaIR);
float NoR = dot(n, rr);
d = pixel.uThickness / max(-NoR, 0.001);
d = uThickness / max(-NoR, 0.001);
ray.position = vec3(shading_position + rr * d);
#else
ray.position = vec3(shading_position);
@@ -494,63 +494,80 @@ vec3 evaluateRefraction(
const PixelParams pixel,
const vec3 n0, vec3 E) {
Refraction ray;
#if REFRACTION_TYPE == REFRACTION_TYPE_THIN
// For thin surfaces, the light will bounce off at the second interface in the direction of
// the reflection, effectively adding to the specular, but this process will repeat itself.
// Each time the ray exits the surface on the front side after the first bounce,
// it's multiplied by E^2, and we get: E + E(1-E)^2 + E^3(1-E)^2 + ...
// This infinite series converges and is easy to simplify.
// Note: we calculate these bounces only on a single component,
// since it's a fairly subtle effect.
E *= 1.0 + pixel.transmission * (1.0 - E.g) / (1.0 + E.g);
#endif
vec3 Ft;
#if defined(MATERIAL_HAS_DISPERSION) && (REFRACTION_TYPE == REFRACTION_TYPE_SOLID)
for (int i = 0; i < 3; i++) {
float etaIR = pixel.etaIR[i];
float etaRI = pixel.etaRI[i];
#else
float etaIR = pixel.etaIR;
float etaRI = pixel.etaRI;
#endif
Refraction ray;
#if REFRACTION_TYPE == REFRACTION_TYPE_SOLID
refractionSolidSphere(pixel, n0, -shading_view, ray);
refractionSolidSphere(etaIR, etaRI, pixel.thickness, n0, -shading_view, ray);
#elif REFRACTION_TYPE == REFRACTION_TYPE_THIN
refractionThinSphere(pixel, n0, -shading_view, ray);
refractionThinSphere(etaIR, pixel.uThickness, n0, -shading_view, ray);
#else
#error invalid REFRACTION_TYPE
#endif
// compute transmission T
// compute transmission T
#if defined(MATERIAL_HAS_ABSORPTION)
vec3 T = min(vec3(1.0), exp(-pixel.absorption * ray.d));
vec3 T = min(vec3(1.0), exp(-pixel.absorption * ray.d));
#endif
// Roughness remapping so that an IOR of 1.0 means no microfacet refraction and an IOR
// of 1.5 has full microfacet refraction
float perceptualRoughness = mix(pixel.perceptualRoughnessUnclamped, 0.0,
saturate(pixel.etaIR * 3.0 - 2.0));
#if REFRACTION_TYPE == REFRACTION_TYPE_THIN
// For thin surfaces, the light will bounce off at the second interface in the direction of
// the reflection, effectively adding to the specular, but this process will repeat itself.
// Each time the ray exits the surface on the front side after the first bounce,
// it's multiplied by E^2, and we get: E + E(1-E)^2 + E^3(1-E)^2 + ...
// This infinite series converges and is easy to simplify.
// Note: we calculate these bounces only on a single component,
// since it's a fairly subtle effect.
E *= 1.0 + pixel.transmission * (1.0 - E.g) / (1.0 + E.g);
#endif
// Roughness remapping so that an IOR of 1.0 means no microfacet refraction and an IOR
// of 1.5 has full microfacet refraction
float perceptualRoughness = mix(pixel.perceptualRoughnessUnclamped, 0.0,
saturate(etaIR * 3.0 - 2.0));
/* sample the cubemap or screen-space */
/* sample the cubemap or screen-space */
#if REFRACTION_MODE == REFRACTION_MODE_CUBEMAP
// when reading from the cubemap, we are not pre-exposed so we apply iblLuminance
// which is not the case when we'll read from the screen-space buffer
vec3 Ft = prefilteredRadiance(ray.direction, perceptualRoughness) * frameUniforms.iblLuminance;
// when reading from the cubemap, we are not pre-exposed so we apply iblLuminance
// which is not the case when we'll read from the screen-space buffer
vec3 t = prefilteredRadiance(ray.direction, perceptualRoughness) * frameUniforms.iblLuminance;
#else
vec3 Ft;
// compute the point where the ray exits the medium, if needed
vec4 p = vec4(getClipFromWorldMatrix() * vec4(ray.position, 1.0));
p.xy = uvToRenderTargetUV(p.xy * (0.5 / p.w) + 0.5);
// compute the point where the ray exits the medium, if needed
vec4 p = vec4(getClipFromWorldMatrix() * vec4(ray.position, 1.0));
p.xy = uvToRenderTargetUV(p.xy * (0.5 / p.w) + 0.5);
// distance to camera plane
const float invLog2sqrt5 = 0.8614;
float lod = max(0.0, (2.0 * log2(perceptualRoughness) + frameUniforms.refractionLodOffset) * invLog2sqrt5);
Ft = textureLod(sampler0_ssr, vec3(p.xy, 0.0), lod).rgb;
// distance to camera plane
const float invLog2sqrt5 = 0.8614;
float lod = max(0.0, (2.0 * log2(perceptualRoughness) + frameUniforms.refractionLodOffset) * invLog2sqrt5);
vec3 t = textureLod(sampler0_ssr, vec3(p.xy, 0.0), lod).rgb;
#endif
// base color changes the amount of light passing through the boundary
Ft *= pixel.diffuseColor;
// base color changes the amount of light passing through the boundary
t *= pixel.diffuseColor;
// fresnel from the first interface
Ft *= 1.0 - E;
// fresnel from the first interface
t *= 1.0 - E;
// apply absorption
// apply absorption
#if defined(MATERIAL_HAS_ABSORPTION)
Ft *= T;
t *= T;
#endif
#if defined(MATERIAL_HAS_DISPERSION) && (REFRACTION_TYPE == REFRACTION_TYPE_SOLID)
Ft[i] = t[i];
}
#else
Ft = t;
#endif
return Ft;

View File

@@ -62,8 +62,13 @@ struct PixelParams {
#endif
#if defined(MATERIAL_HAS_REFRACTION)
#if defined(MATERIAL_HAS_DISPERSION) && (REFRACTION_TYPE == REFRACTION_TYPE_SOLID)
vec3 etaRI;
vec3 etaIR;
#else
float etaRI;
float etaIR;
#endif
float transmission;
float uThickness;
vec3 absorption;

View File

@@ -77,6 +77,9 @@ struct MaterialInputs {
#if defined(MATERIAL_HAS_TRANSMISSION)
float transmission;
#endif
#if defined(MATERIAL_HAS_DISPERSION) && (REFRACTION_TYPE == REFRACTION_TYPE_SOLID)
float dispersion;
#endif
#if defined(MATERIAL_HAS_IOR)
float ior;
#endif
@@ -178,6 +181,9 @@ void initMaterial(out MaterialInputs material) {
#if defined(MATERIAL_HAS_TRANSMISSION)
material.transmission = 1.0;
#endif
#if defined(MATERIAL_HAS_DISPERSION) && (REFRACTION_TYPE == REFRACTION_TYPE_SOLID)
material.dispersion = 0.0f;
#endif
#if defined(MATERIAL_HAS_IOR)
material.ior = 1.5;
#endif

View File

@@ -104,13 +104,22 @@ void getCommonPixelParams(const MaterialInputs material, inout PixelParams pixel
const float airIor = 1.0;
#if !defined(MATERIAL_HAS_IOR)
// [common case] ior is not set in the material, deduce it from F0
float materialor = f0ToIor(pixel.f0.g);
float materialIor = f0ToIor(pixel.f0.g);
#else
// if ior is set in the material, use it (can lead to unrealistic materials)
float materialor = max(1.0, material.ior);
float materialIor = max(1.0, material.ior);
#endif
#if defined(MATERIAL_HAS_DISPERSION) && (REFRACTION_TYPE == REFRACTION_TYPE_SOLID)
float halfSpread = (materialIor - 1.0) * 0.025 * material.dispersion;
vec3 iors = vec3(materialIor - halfSpread, materialIor, materialIor + halfSpread);
pixel.etaIR = vec3(airIor) / iors; // air -> material
pixel.etaRI = iors / vec3(airIor); // material -> air
#else
pixel.etaIR = airIor / materialIor; // air -> material
pixel.etaRI = materialIor / airIor; // material -> air
#endif
pixel.etaIR = airIor / materialor; // air -> material
pixel.etaRI = materialor / airIor; // material -> air
#if defined(MATERIAL_HAS_TRANSMISSION)
pixel.transmission = saturate(material.transmission);
#else