Skip to content

Commit 77ed72b

Browse files
authored
Implement clearcoat per the Filament and the KHR_materials_clearcoat specifications. (#13031)
Clearcoat is a separate material layer that represents a thin translucent layer of a material. Examples include (from the [Filament spec]) car paint, soda cans, and lacquered wood. This commit implements support for clearcoat following the Filament and Khronos specifications, marking the beginnings of support for multiple PBR layers in Bevy. The [`KHR_materials_clearcoat`] specification describes the clearcoat support in glTF. In Blender, applying a clearcoat to the Principled BSDF node causes the clearcoat settings to be exported via this extension. As of this commit, Bevy parses and reads the extension data when present in glTF. Note that the `gltf` crate has no support for `KHR_materials_clearcoat`; this patch therefore implements the JSON semantics manually. Clearcoat is integrated with `StandardMaterial`, but the code is behind a series of `#ifdef`s that only activate when clearcoat is present. Additionally, the `pbr_feature_layer_material_textures` Cargo feature must be active in order to enable support for clearcoat factor maps, clearcoat roughness maps, and clearcoat normal maps. This approach mirrors the same pattern used by the existing transmission feature and exists to avoid running out of texture bindings on platforms like WebGL and WebGPU. Note that constant clearcoat factors and roughness values *are* supported in the browser; only the relatively-less-common maps are disabled on those platforms. This patch refactors the lighting code in `StandardMaterial` significantly in order to better support multiple layers in a natural way. That code was due for a refactor in any case, so this is a nice improvement. A new demo, `clearcoat`, has been added. It's based on [the corresponding three.js demo], but all the assets (aside from the skybox and environment map) are my original work. [Filament spec]: https://google.github.io/filament/Filament.html#materialsystem/clearcoatmodel [`KHR_materials_clearcoat`]: https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_clearcoat/README.md [the corresponding three.js demo]: https://threejs.org/examples/webgl_materials_physical_clearcoat.html ![Screenshot 2024-04-19 101143](https://github.com/bevyengine/bevy/assets/157897/3444bcb5-5c20-490c-b0ad-53759bd47ae2) ![Screenshot 2024-04-19 102054](https://github.com/bevyengine/bevy/assets/157897/6e953944-75b8-49ef-bc71-97b0a53b3a27) ## Changelog ### Added * `StandardMaterial` now supports a clearcoat layer, which represents a thin translucent layer over an underlying material. * The glTF loader now supports the `KHR_materials_clearcoat` extension, representing materials with clearcoat layers. ## Migration Guide * The lighting functions in the `pbr_lighting` WGSL module now have clearcoat parameters, if `STANDARD_MATERIAL_CLEARCOAT` is defined. * The `R` reflection vector parameter has been removed from some lighting functions, as it was unused.
1 parent 89cd5f5 commit 77ed72b

File tree

21 files changed

+1382
-296
lines changed

21 files changed

+1382
-296
lines changed

Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ shader_format_spirv = ["bevy_internal/shader_format_spirv"]
302302
# Enable support for transmission-related textures in the `StandardMaterial`, at the risk of blowing past the global, per-shader texture limit on older/lower-end GPUs
303303
pbr_transmission_textures = ["bevy_internal/pbr_transmission_textures"]
304304

305+
# Enable support for multi-layer material textures in the `StandardMaterial`, at the risk of blowing past the global, per-shader texture limit on older/lower-end GPUs
306+
pbr_multi_layer_material_textures = [
307+
"bevy_internal/pbr_multi_layer_material_textures",
308+
]
309+
305310
# Enable some limitations to be able to use WebGL2. Please refer to the [WebGL2 and WebGPU](https://github.com/bevyengine/bevy/tree/latest/examples#webgl2-and-webgpu) section of the examples README for more information on how to run Wasm builds with WebGPU.
306311
webgl2 = ["bevy_internal/webgl"]
307312

@@ -2994,6 +2999,18 @@ description = "Demonstrates color grading"
29942999
category = "3D Rendering"
29953000
wasm = true
29963001

3002+
[[example]]
3003+
name = "clearcoat"
3004+
path = "examples/3d/clearcoat.rs"
3005+
doc-scrape-examples = true
3006+
required-features = ["pbr_multi_layer_material_textures"]
3007+
3008+
[package.metadata.example.clearcoat]
3009+
name = "Clearcoat"
3010+
description = "Demonstrates the clearcoat PBR feature"
3011+
category = "3D Rendering"
3012+
wasm = false
3013+
29973014
[profile.wasm-release]
29983015
inherits = "release"
29993016
opt-level = "z"

assets/models/GolfBall/GolfBall.glb

939 KB
Binary file not shown.

assets/shaders/array_texture.wgsl

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
mesh_view_bindings::view,
44
pbr_types::{STANDARD_MATERIAL_FLAGS_DOUBLE_SIDED_BIT, PbrInput, pbr_input_new},
55
pbr_functions as fns,
6+
pbr_bindings,
67
}
78
#import bevy_core_pipeline::tonemapping::tone_mapping
89

@@ -37,19 +38,21 @@ fn fragment(
3738

3839
pbr_input.is_orthographic = view.projection[3].w == 1.0;
3940

41+
pbr_input.N = normalize(pbr_input.world_normal);
42+
43+
#ifdef VERTEX_TANGENTS
44+
let Nt = textureSampleBias(pbr_bindings::normal_map_texture, pbr_bindings::normal_map_sampler, mesh.uv, view.mip_bias).rgb;
4045
pbr_input.N = fns::apply_normal_mapping(
4146
pbr_input.material.flags,
4247
mesh.world_normal,
4348
double_sided,
4449
is_front,
45-
#ifdef VERTEX_TANGENTS
46-
#ifdef STANDARD_MATERIAL_NORMAL_MAP
4750
mesh.world_tangent,
48-
#endif
49-
#endif
50-
mesh.uv,
51+
Nt,
5152
view.mip_bias,
5253
);
54+
#endif
55+
5356
pbr_input.V = fns::calculate_view(mesh.world_position, pbr_input.is_orthographic);
5457

5558
return tone_mapping(fns::apply_pbr_lighting(pbr_input), view.color_grading);

assets/textures/BlueNoise-Normal.png

551 KB
Loading
1.03 MB
Loading

crates/bevy_gltf/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ keywords = ["bevy"]
1111
[features]
1212
dds = ["bevy_render/dds"]
1313
pbr_transmission_textures = ["bevy_pbr/pbr_transmission_textures"]
14+
pbr_multi_layer_material_textures = []
1415

1516
[dependencies]
1617
# bevy

crates/bevy_gltf/src/loader.rs

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,18 @@ use bevy_tasks::IoTaskPool;
3838
use bevy_transform::components::Transform;
3939
use bevy_utils::tracing::{error, info_span, warn};
4040
use bevy_utils::{HashMap, HashSet};
41+
use gltf::image::Source;
4142
use gltf::{
4243
accessor::Iter,
4344
mesh::{util::ReadIndices, Mode},
4445
texture::{Info, MagFilter, MinFilter, TextureTransform, WrappingMode},
4546
Material, Node, Primitive, Semantic,
4647
};
48+
use gltf::{json, Document};
4749
use serde::{Deserialize, Serialize};
50+
#[cfg(feature = "pbr_multi_layer_material_textures")]
51+
use serde_json::value;
52+
use serde_json::{Map, Value};
4853
#[cfg(feature = "bevy_animation")]
4954
use smallvec::SmallVec;
5055
use std::io::Error;
@@ -214,6 +219,22 @@ async fn load_gltf<'a, 'b, 'c>(
214219
{
215220
linear_textures.insert(texture.texture().index());
216221
}
222+
223+
// None of the clearcoat maps should be loaded as sRGB.
224+
#[cfg(feature = "pbr_multi_layer_material_textures")]
225+
for texture_field_name in [
226+
"clearcoatTexture",
227+
"clearcoatRoughnessTexture",
228+
"clearcoatNormalTexture",
229+
] {
230+
if let Some(texture_index) = material_extension_texture_index(
231+
&material,
232+
"KHR_materials_clearcoat",
233+
texture_field_name,
234+
) {
235+
linear_textures.insert(texture_index);
236+
}
237+
}
217238
}
218239

219240
#[cfg(feature = "bevy_animation")]
@@ -390,7 +411,7 @@ async fn load_gltf<'a, 'b, 'c>(
390411
if !settings.load_materials.is_empty() {
391412
// NOTE: materials must be loaded after textures because image load() calls will happen before load_with_settings, preventing is_srgb from being set properly
392413
for material in gltf.materials() {
393-
let handle = load_material(&material, load_context, false);
414+
let handle = load_material(&material, load_context, &gltf.document, false);
394415
if let Some(name) = material.name() {
395416
named_materials.insert(name.into(), handle.clone());
396417
}
@@ -490,7 +511,7 @@ async fn load_gltf<'a, 'b, 'c>(
490511
{
491512
mesh.insert_attribute(Mesh::ATTRIBUTE_TANGENT, vertex_attribute);
492513
} else if mesh.attribute(Mesh::ATTRIBUTE_NORMAL).is_some()
493-
&& primitive.material().normal_texture().is_some()
514+
&& material_needs_tangents(&primitive.material())
494515
{
495516
bevy_utils::tracing::debug!(
496517
"Missing vertex tangents for {}, computing them using the mikktspace algorithm. Consider using a tool such as Blender to pre-compute the tangents.", file_name
@@ -609,6 +630,7 @@ async fn load_gltf<'a, 'b, 'c>(
609630
&animation_roots,
610631
#[cfg(feature = "bevy_animation")]
611632
None,
633+
&gltf.document,
612634
);
613635
if result.is_err() {
614636
err = Some(result);
@@ -815,6 +837,7 @@ async fn load_image<'a, 'b>(
815837
fn load_material(
816838
material: &Material,
817839
load_context: &mut LoadContext,
840+
document: &Document,
818841
is_scale_inverted: bool,
819842
) -> Handle<StandardMaterial> {
820843
let material_label = material_label(material, is_scale_inverted);
@@ -918,6 +941,10 @@ fn load_material(
918941

919942
let ior = material.ior().unwrap_or(1.5);
920943

944+
// Parse the `KHR_materials_clearcoat` extension data if necessary.
945+
let clearcoat = ClearcoatExtension::parse(load_context, document, material.extensions())
946+
.unwrap_or_default();
947+
921948
// We need to operate in the Linear color space and be willing to exceed 1.0 in our channels
922949
let base_emissive = LinearRgba::rgb(emissive[0], emissive[1], emissive[2]);
923950
let scaled_emissive = base_emissive * material.emissive_strength().unwrap_or(1.0);
@@ -957,6 +984,15 @@ fn load_material(
957984
unlit: material.unlit(),
958985
alpha_mode: alpha_mode(material),
959986
uv_transform,
987+
clearcoat: clearcoat.clearcoat_factor.unwrap_or_default() as f32,
988+
clearcoat_perceptual_roughness: clearcoat.clearcoat_roughness_factor.unwrap_or_default()
989+
as f32,
990+
#[cfg(feature = "pbr_multi_layer_material_textures")]
991+
clearcoat_texture: clearcoat.clearcoat_texture,
992+
#[cfg(feature = "pbr_multi_layer_material_textures")]
993+
clearcoat_roughness_texture: clearcoat.clearcoat_roughness_texture,
994+
#[cfg(feature = "pbr_multi_layer_material_textures")]
995+
clearcoat_normal_texture: clearcoat.clearcoat_normal_texture,
960996
..Default::default()
961997
}
962998
})
@@ -1015,6 +1051,7 @@ fn load_node(
10151051
parent_transform: &Transform,
10161052
#[cfg(feature = "bevy_animation")] animation_roots: &HashSet<usize>,
10171053
#[cfg(feature = "bevy_animation")] mut animation_context: Option<AnimationContext>,
1054+
document: &Document,
10181055
) -> Result<(), GltfError> {
10191056
let mut gltf_error = None;
10201057
let transform = node_transform(gltf_node);
@@ -1122,7 +1159,7 @@ fn load_node(
11221159
if !root_load_context.has_labeled_asset(&material_label)
11231160
&& !load_context.has_labeled_asset(&material_label)
11241161
{
1125-
load_material(&material, load_context, is_scale_inverted);
1162+
load_material(&material, load_context, document, is_scale_inverted);
11261163
}
11271164

11281165
let primitive_label = primitive_label(&mesh, &primitive);
@@ -1267,6 +1304,7 @@ fn load_node(
12671304
animation_roots,
12681305
#[cfg(feature = "bevy_animation")]
12691306
animation_context.clone(),
1307+
document,
12701308
) {
12711309
gltf_error = Some(err);
12721310
return;
@@ -1337,11 +1375,11 @@ fn texture_label(texture: &gltf::Texture) -> String {
13371375

13381376
fn texture_handle(load_context: &mut LoadContext, texture: &gltf::Texture) -> Handle<Image> {
13391377
match texture.source().source() {
1340-
gltf::image::Source::View { .. } => {
1378+
Source::View { .. } => {
13411379
let label = texture_label(texture);
13421380
load_context.get_label_handle(&label)
13431381
}
1344-
gltf::image::Source::Uri { uri, .. } => {
1382+
Source::Uri { uri, .. } => {
13451383
let uri = percent_encoding::percent_decode_str(uri)
13461384
.decode_utf8()
13471385
.unwrap();
@@ -1358,6 +1396,24 @@ fn texture_handle(load_context: &mut LoadContext, texture: &gltf::Texture) -> Ha
13581396
}
13591397
}
13601398

1399+
/// Given a [`json::texture::Info`], returns the handle of the texture that this
1400+
/// refers to.
1401+
///
1402+
/// This is a low-level function only used when the `gltf` crate has no support
1403+
/// for an extension, forcing us to parse its texture references manually.
1404+
#[allow(dead_code)]
1405+
fn texture_handle_from_info(
1406+
load_context: &mut LoadContext,
1407+
document: &Document,
1408+
texture_info: &json::texture::Info,
1409+
) -> Handle<Image> {
1410+
let texture = document
1411+
.textures()
1412+
.nth(texture_info.index.value())
1413+
.expect("Texture info references a nonexistent texture");
1414+
texture_handle(load_context, &texture)
1415+
}
1416+
13611417
/// Returns the label for the `node`.
13621418
fn node_label(node: &Node) -> String {
13631419
format!("Node{}", node.index())
@@ -1636,6 +1692,104 @@ struct AnimationContext {
16361692
path: SmallVec<[Name; 8]>,
16371693
}
16381694

1695+
/// Parsed data from the `KHR_materials_clearcoat` extension.
1696+
///
1697+
/// See the specification:
1698+
/// <https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_clearcoat/README.md>
1699+
#[derive(Default)]
1700+
struct ClearcoatExtension {
1701+
clearcoat_factor: Option<f64>,
1702+
#[cfg(feature = "pbr_multi_layer_material_textures")]
1703+
clearcoat_texture: Option<Handle<Image>>,
1704+
clearcoat_roughness_factor: Option<f64>,
1705+
#[cfg(feature = "pbr_multi_layer_material_textures")]
1706+
clearcoat_roughness_texture: Option<Handle<Image>>,
1707+
#[cfg(feature = "pbr_multi_layer_material_textures")]
1708+
clearcoat_normal_texture: Option<Handle<Image>>,
1709+
}
1710+
1711+
impl ClearcoatExtension {
1712+
#[allow(unused_variables)]
1713+
fn parse(
1714+
load_context: &mut LoadContext,
1715+
document: &Document,
1716+
material_extensions: Option<&Map<String, Value>>,
1717+
) -> Option<ClearcoatExtension> {
1718+
let extension = material_extensions?
1719+
.get("KHR_materials_clearcoat")?
1720+
.as_object()?;
1721+
1722+
Some(ClearcoatExtension {
1723+
clearcoat_factor: extension.get("clearcoatFactor").and_then(Value::as_f64),
1724+
clearcoat_roughness_factor: extension
1725+
.get("clearcoatRoughnessFactor")
1726+
.and_then(Value::as_f64),
1727+
#[cfg(feature = "pbr_multi_layer_material_textures")]
1728+
clearcoat_texture: extension
1729+
.get("clearcoatTexture")
1730+
.and_then(|value| value::from_value::<json::texture::Info>(value.clone()).ok())
1731+
.map(|json_info| texture_handle_from_info(load_context, document, &json_info)),
1732+
#[cfg(feature = "pbr_multi_layer_material_textures")]
1733+
clearcoat_roughness_texture: extension
1734+
.get("clearcoatRoughnessTexture")
1735+
.and_then(|value| value::from_value::<json::texture::Info>(value.clone()).ok())
1736+
.map(|json_info| texture_handle_from_info(load_context, document, &json_info)),
1737+
#[cfg(feature = "pbr_multi_layer_material_textures")]
1738+
clearcoat_normal_texture: extension
1739+
.get("clearcoatNormalTexture")
1740+
.and_then(|value| value::from_value::<json::texture::Info>(value.clone()).ok())
1741+
.map(|json_info| texture_handle_from_info(load_context, document, &json_info)),
1742+
})
1743+
}
1744+
}
1745+
1746+
/// Returns the index (within the `textures` array) of the texture with the
1747+
/// given field name in the data for the material extension with the given name,
1748+
/// if there is one.
1749+
#[cfg(feature = "pbr_multi_layer_material_textures")]
1750+
fn material_extension_texture_index(
1751+
material: &Material,
1752+
extension_name: &str,
1753+
texture_field_name: &str,
1754+
) -> Option<usize> {
1755+
Some(
1756+
value::from_value::<json::texture::Info>(
1757+
material
1758+
.extensions()?
1759+
.get(extension_name)?
1760+
.as_object()?
1761+
.get(texture_field_name)?
1762+
.clone(),
1763+
)
1764+
.ok()?
1765+
.index
1766+
.value(),
1767+
)
1768+
}
1769+
1770+
/// Returns true if the material needs mesh tangents in order to be successfully
1771+
/// rendered.
1772+
///
1773+
/// We generate them if this function returns true.
1774+
fn material_needs_tangents(material: &Material) -> bool {
1775+
if material.normal_texture().is_some() {
1776+
return true;
1777+
}
1778+
1779+
#[cfg(feature = "pbr_multi_layer_material_textures")]
1780+
if material_extension_texture_index(
1781+
material,
1782+
"KHR_materials_clearcoat",
1783+
"clearcoatNormalTexture",
1784+
)
1785+
.is_some()
1786+
{
1787+
return true;
1788+
}
1789+
1790+
false
1791+
}
1792+
16391793
#[cfg(test)]
16401794
mod test {
16411795
use std::path::PathBuf;

crates/bevy_internal/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ pbr_transmission_textures = [
9898
"bevy_gltf?/pbr_transmission_textures",
9999
]
100100

101+
# Multi-layer material textures in `StandardMaterial`:
102+
pbr_multi_layer_material_textures = [
103+
"bevy_pbr?/pbr_multi_layer_material_textures",
104+
"bevy_gltf?/pbr_multi_layer_material_textures",
105+
]
106+
101107
# Optimise for WebGL2
102108
webgl = [
103109
"bevy_core_pipeline?/webgl",

crates/bevy_pbr/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ keywords = ["bevy"]
1212
webgl = []
1313
webgpu = []
1414
pbr_transmission_textures = []
15+
pbr_multi_layer_material_textures = []
1516
shader_format_glsl = ["bevy_render/shader_format_glsl"]
1617
trace = ["bevy_render/trace"]
1718
ios_simulator = ["bevy_render/ios_simulator"]

0 commit comments

Comments
 (0)