Skip to content

Commit

Permalink
Implement clearcoat per the Filament and the `KHR_materials_clearcoat…
Browse files Browse the repository at this point in the history
…` 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.
  • Loading branch information
pcwalton authored May 5, 2024
1 parent 89cd5f5 commit 77ed72b
Show file tree
Hide file tree
Showing 21 changed files with 1,382 additions and 296 deletions.
17 changes: 17 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,11 @@ shader_format_spirv = ["bevy_internal/shader_format_spirv"]
# 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
pbr_transmission_textures = ["bevy_internal/pbr_transmission_textures"]

# 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
pbr_multi_layer_material_textures = [
"bevy_internal/pbr_multi_layer_material_textures",
]

# 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.
webgl2 = ["bevy_internal/webgl"]

Expand Down Expand Up @@ -2994,6 +2999,18 @@ description = "Demonstrates color grading"
category = "3D Rendering"
wasm = true

[[example]]
name = "clearcoat"
path = "examples/3d/clearcoat.rs"
doc-scrape-examples = true
required-features = ["pbr_multi_layer_material_textures"]

[package.metadata.example.clearcoat]
name = "Clearcoat"
description = "Demonstrates the clearcoat PBR feature"
category = "3D Rendering"
wasm = false

[profile.wasm-release]
inherits = "release"
opt-level = "z"
Expand Down
Binary file added assets/models/GolfBall/GolfBall.glb
Binary file not shown.
13 changes: 8 additions & 5 deletions assets/shaders/array_texture.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
mesh_view_bindings::view,
pbr_types::{STANDARD_MATERIAL_FLAGS_DOUBLE_SIDED_BIT, PbrInput, pbr_input_new},
pbr_functions as fns,
pbr_bindings,
}
#import bevy_core_pipeline::tonemapping::tone_mapping

Expand Down Expand Up @@ -37,19 +38,21 @@ fn fragment(

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

pbr_input.N = normalize(pbr_input.world_normal);

#ifdef VERTEX_TANGENTS
let Nt = textureSampleBias(pbr_bindings::normal_map_texture, pbr_bindings::normal_map_sampler, mesh.uv, view.mip_bias).rgb;
pbr_input.N = fns::apply_normal_mapping(
pbr_input.material.flags,
mesh.world_normal,
double_sided,
is_front,
#ifdef VERTEX_TANGENTS
#ifdef STANDARD_MATERIAL_NORMAL_MAP
mesh.world_tangent,
#endif
#endif
mesh.uv,
Nt,
view.mip_bias,
);
#endif

pbr_input.V = fns::calculate_view(mesh.world_position, pbr_input.is_orthographic);

return tone_mapping(fns::apply_pbr_lighting(pbr_input), view.color_grading);
Expand Down
Binary file added assets/textures/BlueNoise-Normal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/textures/ScratchedGold-Normal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions crates/bevy_gltf/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ keywords = ["bevy"]
[features]
dds = ["bevy_render/dds"]
pbr_transmission_textures = ["bevy_pbr/pbr_transmission_textures"]
pbr_multi_layer_material_textures = []

[dependencies]
# bevy
Expand Down
164 changes: 159 additions & 5 deletions crates/bevy_gltf/src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,18 @@ use bevy_tasks::IoTaskPool;
use bevy_transform::components::Transform;
use bevy_utils::tracing::{error, info_span, warn};
use bevy_utils::{HashMap, HashSet};
use gltf::image::Source;
use gltf::{
accessor::Iter,
mesh::{util::ReadIndices, Mode},
texture::{Info, MagFilter, MinFilter, TextureTransform, WrappingMode},
Material, Node, Primitive, Semantic,
};
use gltf::{json, Document};
use serde::{Deserialize, Serialize};
#[cfg(feature = "pbr_multi_layer_material_textures")]
use serde_json::value;
use serde_json::{Map, Value};
#[cfg(feature = "bevy_animation")]
use smallvec::SmallVec;
use std::io::Error;
Expand Down Expand Up @@ -214,6 +219,22 @@ async fn load_gltf<'a, 'b, 'c>(
{
linear_textures.insert(texture.texture().index());
}

// None of the clearcoat maps should be loaded as sRGB.
#[cfg(feature = "pbr_multi_layer_material_textures")]
for texture_field_name in [
"clearcoatTexture",
"clearcoatRoughnessTexture",
"clearcoatNormalTexture",
] {
if let Some(texture_index) = material_extension_texture_index(
&material,
"KHR_materials_clearcoat",
texture_field_name,
) {
linear_textures.insert(texture_index);
}
}
}

#[cfg(feature = "bevy_animation")]
Expand Down Expand Up @@ -390,7 +411,7 @@ async fn load_gltf<'a, 'b, 'c>(
if !settings.load_materials.is_empty() {
// NOTE: materials must be loaded after textures because image load() calls will happen before load_with_settings, preventing is_srgb from being set properly
for material in gltf.materials() {
let handle = load_material(&material, load_context, false);
let handle = load_material(&material, load_context, &gltf.document, false);
if let Some(name) = material.name() {
named_materials.insert(name.into(), handle.clone());
}
Expand Down Expand Up @@ -490,7 +511,7 @@ async fn load_gltf<'a, 'b, 'c>(
{
mesh.insert_attribute(Mesh::ATTRIBUTE_TANGENT, vertex_attribute);
} else if mesh.attribute(Mesh::ATTRIBUTE_NORMAL).is_some()
&& primitive.material().normal_texture().is_some()
&& material_needs_tangents(&primitive.material())
{
bevy_utils::tracing::debug!(
"Missing vertex tangents for {}, computing them using the mikktspace algorithm. Consider using a tool such as Blender to pre-compute the tangents.", file_name
Expand Down Expand Up @@ -609,6 +630,7 @@ async fn load_gltf<'a, 'b, 'c>(
&animation_roots,
#[cfg(feature = "bevy_animation")]
None,
&gltf.document,
);
if result.is_err() {
err = Some(result);
Expand Down Expand Up @@ -815,6 +837,7 @@ async fn load_image<'a, 'b>(
fn load_material(
material: &Material,
load_context: &mut LoadContext,
document: &Document,
is_scale_inverted: bool,
) -> Handle<StandardMaterial> {
let material_label = material_label(material, is_scale_inverted);
Expand Down Expand Up @@ -918,6 +941,10 @@ fn load_material(

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

// Parse the `KHR_materials_clearcoat` extension data if necessary.
let clearcoat = ClearcoatExtension::parse(load_context, document, material.extensions())
.unwrap_or_default();

// We need to operate in the Linear color space and be willing to exceed 1.0 in our channels
let base_emissive = LinearRgba::rgb(emissive[0], emissive[1], emissive[2]);
let scaled_emissive = base_emissive * material.emissive_strength().unwrap_or(1.0);
Expand Down Expand Up @@ -957,6 +984,15 @@ fn load_material(
unlit: material.unlit(),
alpha_mode: alpha_mode(material),
uv_transform,
clearcoat: clearcoat.clearcoat_factor.unwrap_or_default() as f32,
clearcoat_perceptual_roughness: clearcoat.clearcoat_roughness_factor.unwrap_or_default()
as f32,
#[cfg(feature = "pbr_multi_layer_material_textures")]
clearcoat_texture: clearcoat.clearcoat_texture,
#[cfg(feature = "pbr_multi_layer_material_textures")]
clearcoat_roughness_texture: clearcoat.clearcoat_roughness_texture,
#[cfg(feature = "pbr_multi_layer_material_textures")]
clearcoat_normal_texture: clearcoat.clearcoat_normal_texture,
..Default::default()
}
})
Expand Down Expand Up @@ -1015,6 +1051,7 @@ fn load_node(
parent_transform: &Transform,
#[cfg(feature = "bevy_animation")] animation_roots: &HashSet<usize>,
#[cfg(feature = "bevy_animation")] mut animation_context: Option<AnimationContext>,
document: &Document,
) -> Result<(), GltfError> {
let mut gltf_error = None;
let transform = node_transform(gltf_node);
Expand Down Expand Up @@ -1122,7 +1159,7 @@ fn load_node(
if !root_load_context.has_labeled_asset(&material_label)
&& !load_context.has_labeled_asset(&material_label)
{
load_material(&material, load_context, is_scale_inverted);
load_material(&material, load_context, document, is_scale_inverted);
}

let primitive_label = primitive_label(&mesh, &primitive);
Expand Down Expand Up @@ -1267,6 +1304,7 @@ fn load_node(
animation_roots,
#[cfg(feature = "bevy_animation")]
animation_context.clone(),
document,
) {
gltf_error = Some(err);
return;
Expand Down Expand Up @@ -1337,11 +1375,11 @@ fn texture_label(texture: &gltf::Texture) -> String {

fn texture_handle(load_context: &mut LoadContext, texture: &gltf::Texture) -> Handle<Image> {
match texture.source().source() {
gltf::image::Source::View { .. } => {
Source::View { .. } => {
let label = texture_label(texture);
load_context.get_label_handle(&label)
}
gltf::image::Source::Uri { uri, .. } => {
Source::Uri { uri, .. } => {
let uri = percent_encoding::percent_decode_str(uri)
.decode_utf8()
.unwrap();
Expand All @@ -1358,6 +1396,24 @@ fn texture_handle(load_context: &mut LoadContext, texture: &gltf::Texture) -> Ha
}
}

/// Given a [`json::texture::Info`], returns the handle of the texture that this
/// refers to.
///
/// This is a low-level function only used when the `gltf` crate has no support
/// for an extension, forcing us to parse its texture references manually.
#[allow(dead_code)]
fn texture_handle_from_info(
load_context: &mut LoadContext,
document: &Document,
texture_info: &json::texture::Info,
) -> Handle<Image> {
let texture = document
.textures()
.nth(texture_info.index.value())
.expect("Texture info references a nonexistent texture");
texture_handle(load_context, &texture)
}

/// Returns the label for the `node`.
fn node_label(node: &Node) -> String {
format!("Node{}", node.index())
Expand Down Expand Up @@ -1636,6 +1692,104 @@ struct AnimationContext {
path: SmallVec<[Name; 8]>,
}

/// Parsed data from the `KHR_materials_clearcoat` extension.
///
/// See the specification:
/// <https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Khronos/KHR_materials_clearcoat/README.md>
#[derive(Default)]
struct ClearcoatExtension {
clearcoat_factor: Option<f64>,
#[cfg(feature = "pbr_multi_layer_material_textures")]
clearcoat_texture: Option<Handle<Image>>,
clearcoat_roughness_factor: Option<f64>,
#[cfg(feature = "pbr_multi_layer_material_textures")]
clearcoat_roughness_texture: Option<Handle<Image>>,
#[cfg(feature = "pbr_multi_layer_material_textures")]
clearcoat_normal_texture: Option<Handle<Image>>,
}

impl ClearcoatExtension {
#[allow(unused_variables)]
fn parse(
load_context: &mut LoadContext,
document: &Document,
material_extensions: Option<&Map<String, Value>>,
) -> Option<ClearcoatExtension> {
let extension = material_extensions?
.get("KHR_materials_clearcoat")?
.as_object()?;

Some(ClearcoatExtension {
clearcoat_factor: extension.get("clearcoatFactor").and_then(Value::as_f64),
clearcoat_roughness_factor: extension
.get("clearcoatRoughnessFactor")
.and_then(Value::as_f64),
#[cfg(feature = "pbr_multi_layer_material_textures")]
clearcoat_texture: extension
.get("clearcoatTexture")
.and_then(|value| value::from_value::<json::texture::Info>(value.clone()).ok())
.map(|json_info| texture_handle_from_info(load_context, document, &json_info)),
#[cfg(feature = "pbr_multi_layer_material_textures")]
clearcoat_roughness_texture: extension
.get("clearcoatRoughnessTexture")
.and_then(|value| value::from_value::<json::texture::Info>(value.clone()).ok())
.map(|json_info| texture_handle_from_info(load_context, document, &json_info)),
#[cfg(feature = "pbr_multi_layer_material_textures")]
clearcoat_normal_texture: extension
.get("clearcoatNormalTexture")
.and_then(|value| value::from_value::<json::texture::Info>(value.clone()).ok())
.map(|json_info| texture_handle_from_info(load_context, document, &json_info)),
})
}
}

/// Returns the index (within the `textures` array) of the texture with the
/// given field name in the data for the material extension with the given name,
/// if there is one.
#[cfg(feature = "pbr_multi_layer_material_textures")]
fn material_extension_texture_index(
material: &Material,
extension_name: &str,
texture_field_name: &str,
) -> Option<usize> {
Some(
value::from_value::<json::texture::Info>(
material
.extensions()?
.get(extension_name)?
.as_object()?
.get(texture_field_name)?
.clone(),
)
.ok()?
.index
.value(),
)
}

/// Returns true if the material needs mesh tangents in order to be successfully
/// rendered.
///
/// We generate them if this function returns true.
fn material_needs_tangents(material: &Material) -> bool {
if material.normal_texture().is_some() {
return true;
}

#[cfg(feature = "pbr_multi_layer_material_textures")]
if material_extension_texture_index(
material,
"KHR_materials_clearcoat",
"clearcoatNormalTexture",
)
.is_some()
{
return true;
}

false
}

#[cfg(test)]
mod test {
use std::path::PathBuf;
Expand Down
6 changes: 6 additions & 0 deletions crates/bevy_internal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ pbr_transmission_textures = [
"bevy_gltf?/pbr_transmission_textures",
]

# Multi-layer material textures in `StandardMaterial`:
pbr_multi_layer_material_textures = [
"bevy_pbr?/pbr_multi_layer_material_textures",
"bevy_gltf?/pbr_multi_layer_material_textures",
]

# Optimise for WebGL2
webgl = [
"bevy_core_pipeline?/webgl",
Expand Down
1 change: 1 addition & 0 deletions crates/bevy_pbr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ keywords = ["bevy"]
webgl = []
webgpu = []
pbr_transmission_textures = []
pbr_multi_layer_material_textures = []
shader_format_glsl = ["bevy_render/shader_format_glsl"]
trace = ["bevy_render/trace"]
ios_simulator = ["bevy_render/ios_simulator"]
Expand Down
Loading

0 comments on commit 77ed72b

Please sign in to comment.