From cd924fc2cf1c367dc7e43b810a7174c374ff54df Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Tue, 23 Apr 2024 14:36:27 -0700 Subject: [PATCH 1/3] Implement filmic color grading. This commit expands Bevy's existing tonemapping feature to a complete set of filmic color grading tools, matching those of engines like Unity, Unreal, and Godot. The following features are supported: * White point adjustment. This is inspired by Unity's implementation of the feature, but simplified and optimized. *Temperature* and *tint* control the adjustments to the *x* and *y* chromaticity values of [CIE 1931]. Following Unity, the adjustments are made relative to the [D65 standard illuminant] in the [LMS color space]. * Hue rotation. This simply converts the RGB value to [HSV], alters the hue, and converts back. * Color correction. This allows the *gamma*, *gain*, and *lift* values to be adjusted according to the standard [ASC CDL combined function]. * Separate color correction for shadows, midtones, and highlights. Blender's source code was used as a reference for the implementation of this. The midtone ranges can be adjusted by the user. To avoid abrupt color changes, a small crossfade is used between the different sections of the image, again following Blender's formulas. A new example, `color_grading`, has been added, offering a GUI to change all the color grading settings. It uses the same test scene as the existing `tonemapping` example, which has been factored out into a shared glTF scene. [CIE 1931]: https://en.wikipedia.org/wiki/CIE_1931_color_space [D65 standard illuminant]: https://en.wikipedia.org/wiki/Standard_illuminant#Illuminant_series_D [LMS color space]: https://en.wikipedia.org/wiki/LMS_color_space [HSV]: https://en.wikipedia.org/wiki/HSL_and_HSV [ASC CDL combined function]: https://en.wikipedia.org/wiki/ASC_CDL#Combined_Function --- Cargo.toml | 11 + assets/models/TonemappingTest/TestPattern.png | Bin 0 -> 361 bytes .../TonemappingTest/TonemappingTest.bin | Bin 0 -> 130868 bytes .../TonemappingTest/TonemappingTest.gltf | 679 +++++++++++++++++ .../bevy_core_pipeline/src/tonemapping/mod.rs | 67 +- .../src/tonemapping/tonemapping_shared.wgsl | 106 ++- .../src/render/clustered_forward.wgsl | 10 +- crates/bevy_pbr/src/render/shadows.wgsl | 8 +- crates/bevy_pbr/src/render/utils.wgsl | 61 +- crates/bevy_render/src/camera/camera.rs | 2 +- crates/bevy_render/src/view/mod.rs | 324 ++++++++- crates/bevy_render/src/view/view.wgsl | 10 +- examples/3d/color_grading.rs | 680 ++++++++++++++++++ examples/3d/tonemapping.rs | 173 ++--- examples/3d/transmission.rs | 7 +- examples/README.md | 1 + 16 files changed, 1952 insertions(+), 187 deletions(-) create mode 100644 assets/models/TonemappingTest/TestPattern.png create mode 100644 assets/models/TonemappingTest/TonemappingTest.bin create mode 100644 assets/models/TonemappingTest/TonemappingTest.gltf create mode 100644 examples/3d/color_grading.rs diff --git a/Cargo.toml b/Cargo.toml index 56dcf8e5314e3..4bd9fce93d8ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2910,6 +2910,17 @@ description = "Demonstrates FPS overlay" category = "Dev tools" wasm = true +[[example]] +name = "color_grading" +path = "examples/3d/color_grading.rs" +doc-scrape-examples = true + +[package.metadata.example.color_grading] +name = "Color grading" +description = "Demonstrates color grading" +category = "3D Rendering" +wasm = true + [profile.wasm-release] inherits = "release" opt-level = "z" diff --git a/assets/models/TonemappingTest/TestPattern.png b/assets/models/TonemappingTest/TestPattern.png new file mode 100644 index 0000000000000000000000000000000000000000..b52f22d2f2a8366eab26a3ccd2209d28cc62f89c GIT binary patch literal 361 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqW~mXLX}-P;T0pi05PJYIBb;Vp zlwx3oif}MW!`T8rF@^~h@Am*H&U`nwVh|q;ZfR{h2V`&-ctipPxrIQO@zUM8KS04I zPZ!4!3CZL?^UeO-H$N?G+s?_uvzLd5CttFm=kYY2L);P)U5z!*VjlDI#`xbeD%e|l zC?Vm%fdWR3{inMOYQ!g|?kHef(QtFe!h;P58yKf6sl~W)8#BCRh&r2Nb9^sjnvB`x z8F!^-NKbg{5`JXQ!h<^)?bP0 Hl+XkK`jc9q literal 0 HcmV?d00001 diff --git a/assets/models/TonemappingTest/TonemappingTest.bin b/assets/models/TonemappingTest/TonemappingTest.bin new file mode 100644 index 0000000000000000000000000000000000000000..3796fd5f69c270eb1874b7cc7186f4d717fe7ddd GIT binary patch literal 130868 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 literal 0 HcmV?d00001 diff --git a/assets/models/TonemappingTest/TonemappingTest.gltf b/assets/models/TonemappingTest/TonemappingTest.gltf new file mode 100644 index 0000000000000..c659ee426ce42 --- /dev/null +++ b/assets/models/TonemappingTest/TonemappingTest.gltf @@ -0,0 +1,679 @@ +{ + "asset":{ + "generator":"Khronos glTF Blender I/O v4.0.44", + "version":"2.0" + }, + "scene":0, + "scenes":[ + { + "name":"Scene", + "nodes":[ + 0, + 1, + 2 + ] + } + ], + "nodes":[ + { + "mesh":0, + "name":"Plane", + "scale":[ + 50, + 1, + 50 + ] + }, + { + "mesh":1, + "name":"Cube", + "translation":[ + -1, + 0.125, + 0 + ] + }, + { + "mesh":2, + "name":"Sphere", + "translation":[ + 0, + 0.125, + 0 + ] + } + ], + "materials":[ + { + "doubleSided":true, + "name":"Material.001", + "pbrMetallicRoughness":{ + "baseColorFactor":[ + 0.10000000149011612, + 0.20000000298023224, + 0.10000000149011612, + 1 + ], + "metallicFactor":0, + "roughnessFactor":0.5 + } + }, + { + "doubleSided":true, + "name":"Material.002", + "pbrMetallicRoughness":{ + "baseColorTexture":{ + "index":0 + }, + "metallicFactor":0, + "roughnessFactor":0.5 + } + }, + { + "doubleSided":true, + "name":"Sphere0", + "pbrMetallicRoughness":{ + "baseColorFactor":[ + 0, + 0, + 1, + 1 + ], + "metallicFactor":0, + "roughnessFactor":0.08900000154972076 + } + }, + { + "doubleSided":true, + "name":"Sphere1", + "pbrMetallicRoughness":{ + "baseColorFactor":[ + 0, + 1, + 0, + 1 + ], + "metallicFactor":0, + "roughnessFactor":0.08900000154972076 + } + }, + { + "doubleSided":true, + "name":"Sphere2", + "pbrMetallicRoughness":{ + "baseColorFactor":[ + 1, + 0, + 0, + 1 + ], + "metallicFactor":0, + "roughnessFactor":0.08900000154972076 + } + }, + { + "doubleSided":true, + "name":"Sphere3", + "pbrMetallicRoughness":{ + "baseColorFactor":[ + 0.20000000298023224, + 0.20000000298023224, + 1, + 1 + ], + "metallicFactor":0, + "roughnessFactor":0.08900000154972076 + } + }, + { + "doubleSided":true, + "name":"Sphere4", + "pbrMetallicRoughness":{ + "baseColorFactor":[ + 0.20000000298023224, + 1, + 0.20000000298023224, + 1 + ], + "metallicFactor":0, + "roughnessFactor":0.08900000154972076 + } + }, + { + "doubleSided":true, + "name":"Sphere5", + "pbrMetallicRoughness":{ + "baseColorFactor":[ + 1, + 0.20000000298023224, + 0.20000000298023224, + 1 + ], + "metallicFactor":0, + "roughnessFactor":0.08900000154972076 + } + } + ], + "meshes":[ + { + "name":"Plane", + "primitives":[ + { + "attributes":{ + "POSITION":0, + "NORMAL":1, + "TEXCOORD_0":2 + }, + "indices":3, + "material":0 + } + ] + }, + { + "name":"Cube.001", + "primitives":[ + { + "attributes":{ + "POSITION":4, + "NORMAL":5, + "TEXCOORD_0":6 + }, + "indices":7, + "material":1 + } + ] + }, + { + "name":"Sphere", + "primitives":[ + { + "attributes":{ + "POSITION":8, + "NORMAL":9, + "TEXCOORD_0":10 + }, + "indices":11, + "material":2 + }, + { + "attributes":{ + "POSITION":12, + "NORMAL":13, + "TEXCOORD_0":14 + }, + "indices":11, + "material":3 + }, + { + "attributes":{ + "POSITION":15, + "NORMAL":16, + "TEXCOORD_0":17 + }, + "indices":11, + "material":4 + }, + { + "attributes":{ + "POSITION":18, + "NORMAL":19, + "TEXCOORD_0":20 + }, + "indices":11, + "material":5 + }, + { + "attributes":{ + "POSITION":21, + "NORMAL":22, + "TEXCOORD_0":23 + }, + "indices":11, + "material":6 + }, + { + "attributes":{ + "POSITION":24, + "NORMAL":25, + "TEXCOORD_0":26 + }, + "indices":11, + "material":7 + } + ] + } + ], + "textures":[ + { + "sampler":0, + "source":0 + } + ], + "images":[ + { + "mimeType":"image/png", + "name":"TestPattern", + "uri":"TestPattern.png" + } + ], + "accessors":[ + { + "bufferView":0, + "componentType":5126, + "count":4, + "max":[ + 1, + 0, + 1 + ], + "min":[ + -1, + 0, + -1 + ], + "type":"VEC3" + }, + { + "bufferView":1, + "componentType":5126, + "count":4, + "type":"VEC3" + }, + { + "bufferView":2, + "componentType":5126, + "count":4, + "type":"VEC2" + }, + { + "bufferView":3, + "componentType":5123, + "count":6, + "type":"SCALAR" + }, + { + "bufferView":4, + "componentType":5126, + "count":120, + "max":[ + 1.125, + 0.125, + 0.125 + ], + "min":[ + -0.125, + -0.125, + -2.125 + ], + "type":"VEC3" + }, + { + "bufferView":5, + "componentType":5126, + "count":120, + "type":"VEC3" + }, + { + "bufferView":6, + "componentType":5126, + "count":120, + "type":"VEC2" + }, + { + "bufferView":7, + "componentType":5123, + "count":180, + "type":"SCALAR" + }, + { + "bufferView":8, + "componentType":5126, + "count":625, + "max":[ + -0.42500004172325134, + 0.125, + 0.37499991059303284 + ], + "min":[ + -0.6749998927116394, + -0.125, + 0.125 + ], + "type":"VEC3" + }, + { + "bufferView":9, + "componentType":5126, + "count":625, + "type":"VEC3" + }, + { + "bufferView":10, + "componentType":5126, + "count":625, + "type":"VEC2" + }, + { + "bufferView":11, + "componentType":5123, + "count":3264, + "type":"SCALAR" + }, + { + "bufferView":12, + "componentType":5126, + "count":625, + "max":[ + -0.17500004172325134, + 0.125, + 0.12499991804361343 + ], + "min":[ + -0.4249998927116394, + -0.125, + -0.125 + ], + "type":"VEC3" + }, + { + "bufferView":13, + "componentType":5126, + "count":625, + "type":"VEC3" + }, + { + "bufferView":14, + "componentType":5126, + "count":625, + "type":"VEC2" + }, + { + "bufferView":15, + "componentType":5126, + "count":625, + "max":[ + 0.07499995082616806, + 0.125, + -0.12500005960464478 + ], + "min":[ + -0.1749998927116394, + -0.125, + -0.3749999701976776 + ], + "type":"VEC3" + }, + { + "bufferView":16, + "componentType":5126, + "count":625, + "type":"VEC3" + }, + { + "bufferView":17, + "componentType":5126, + "count":625, + "type":"VEC2" + }, + { + "bufferView":18, + "componentType":5126, + "count":625, + "max":[ + -0.1250000298023224, + 0.125, + 0.6749999523162842 + ], + "min":[ + -0.37499988079071045, + -0.125, + 0.42500001192092896 + ], + "type":"VEC3" + }, + { + "bufferView":19, + "componentType":5126, + "count":625, + "type":"VEC3" + }, + { + "bufferView":20, + "componentType":5126, + "count":625, + "type":"VEC2" + }, + { + "bufferView":21, + "componentType":5126, + "count":625, + "max":[ + 0.12499996274709702, + 0.125, + 0.4249999225139618 + ], + "min":[ + -0.12499988079071045, + -0.125, + 0.17500001192092896 + ], + "type":"VEC3" + }, + { + "bufferView":22, + "componentType":5126, + "count":625, + "type":"VEC3" + }, + { + "bufferView":23, + "componentType":5126, + "count":625, + "type":"VEC2" + }, + { + "bufferView":24, + "componentType":5126, + "count":625, + "max":[ + 0.3749999403953552, + 0.125, + 0.1749999225139618 + ], + "min":[ + 0.12500008940696716, + -0.125, + -0.07499998807907104 + ], + "type":"VEC3" + }, + { + "bufferView":25, + "componentType":5126, + "count":625, + "type":"VEC3" + }, + { + "bufferView":26, + "componentType":5126, + "count":625, + "type":"VEC2" + } + ], + "bufferViews":[ + { + "buffer":0, + "byteLength":48, + "byteOffset":0, + "target":34962 + }, + { + "buffer":0, + "byteLength":48, + "byteOffset":48, + "target":34962 + }, + { + "buffer":0, + "byteLength":32, + "byteOffset":96, + "target":34962 + }, + { + "buffer":0, + "byteLength":12, + "byteOffset":128, + "target":34963 + }, + { + "buffer":0, + "byteLength":1440, + "byteOffset":140, + "target":34962 + }, + { + "buffer":0, + "byteLength":1440, + "byteOffset":1580, + "target":34962 + }, + { + "buffer":0, + "byteLength":960, + "byteOffset":3020, + "target":34962 + }, + { + "buffer":0, + "byteLength":360, + "byteOffset":3980, + "target":34963 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":4340, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":11840, + "target":34962 + }, + { + "buffer":0, + "byteLength":5000, + "byteOffset":19340, + "target":34962 + }, + { + "buffer":0, + "byteLength":6528, + "byteOffset":24340, + "target":34963 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":30868, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":38368, + "target":34962 + }, + { + "buffer":0, + "byteLength":5000, + "byteOffset":45868, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":50868, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":58368, + "target":34962 + }, + { + "buffer":0, + "byteLength":5000, + "byteOffset":65868, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":70868, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":78368, + "target":34962 + }, + { + "buffer":0, + "byteLength":5000, + "byteOffset":85868, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":90868, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":98368, + "target":34962 + }, + { + "buffer":0, + "byteLength":5000, + "byteOffset":105868, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":110868, + "target":34962 + }, + { + "buffer":0, + "byteLength":7500, + "byteOffset":118368, + "target":34962 + }, + { + "buffer":0, + "byteLength":5000, + "byteOffset":125868, + "target":34962 + } + ], + "samplers":[ + { + "magFilter":9728, + "minFilter":9984 + } + ], + "buffers":[ + { + "byteLength":130868, + "uri":"TonemappingTest.bin" + } + ] +} diff --git a/crates/bevy_core_pipeline/src/tonemapping/mod.rs b/crates/bevy_core_pipeline/src/tonemapping/mod.rs index bd6ca24960a04..217de8222261d 100644 --- a/crates/bevy_core_pipeline/src/tonemapping/mod.rs +++ b/crates/bevy_core_pipeline/src/tonemapping/mod.rs @@ -11,11 +11,12 @@ use bevy_render::render_resource::binding_types::{ }; use bevy_render::renderer::RenderDevice; use bevy_render::texture::{CompressedImageFormats, GpuImage, Image, ImageSampler, ImageType}; -use bevy_render::view::{ViewTarget, ViewUniform}; +use bevy_render::view::{ExtractedView, ViewTarget, ViewUniform}; use bevy_render::{camera::Camera, texture::FallbackImage}; use bevy_render::{render_resource::*, Render, RenderApp, RenderSet}; #[cfg(not(feature = "tonemapping_luts"))] use bevy_utils::tracing::error; +use bitflags::bitflags; mod node; @@ -179,10 +180,27 @@ impl Tonemapping { } } +bitflags! { + /// Various flags describing what tonemapping needs to do. + /// + /// This allows the shader to skip unneeded steps. + #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] + pub struct TonemappingPipelineKeyFlags: u8 { + /// The hue needs to be changed. + const HUE_ROTATE = 0x01; + /// The white balance needs to be adjusted. + const WHITE_BALANCE = 0x02; + /// Saturation/contrast/gamma/gain/lift for one or more sections + /// (shadows, midtones, highlights) need to be adjusted. + const SECTIONAL_COLOR_GRADING = 0x04; + } +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub struct TonemappingPipelineKey { deband_dither: DebandDither, tonemapping: Tonemapping, + flags: TonemappingPipelineKeyFlags, } impl SpecializedRenderPipeline for TonemappingPipeline { @@ -194,6 +212,23 @@ impl SpecializedRenderPipeline for TonemappingPipeline { shader_defs.push("DEBAND_DITHER".into()); } + // Define shader flags depending on the color grading options in use. + if key.flags.contains(TonemappingPipelineKeyFlags::HUE_ROTATE) { + shader_defs.push("HUE_ROTATE".into()); + } + if key + .flags + .contains(TonemappingPipelineKeyFlags::WHITE_BALANCE) + { + shader_defs.push("WHITE_BALANCE".into()); + } + if key + .flags + .contains(TonemappingPipelineKeyFlags::SECTIONAL_COLOR_GRADING) + { + shader_defs.push("SECTIONAL_COLOR_GRADING".into()); + } + match key.tonemapping { Tonemapping::None => shader_defs.push("TONEMAP_METHOD_NONE".into()), Tonemapping::Reinhard => shader_defs.push("TONEMAP_METHOD_REINHARD".into()), @@ -292,12 +327,38 @@ pub fn prepare_view_tonemapping_pipelines( pipeline_cache: Res, mut pipelines: ResMut>, upscaling_pipeline: Res, - view_targets: Query<(Entity, Option<&Tonemapping>, Option<&DebandDither>), With>, + view_targets: Query< + ( + Entity, + &ExtractedView, + Option<&Tonemapping>, + Option<&DebandDither>, + ), + With, + >, ) { - for (entity, tonemapping, dither) in view_targets.iter() { + for (entity, view, tonemapping, dither) in view_targets.iter() { + // As an optimization, we omit parts of the shader that are unneeded. + let mut flags = TonemappingPipelineKeyFlags::empty(); + flags.set( + TonemappingPipelineKeyFlags::HUE_ROTATE, + view.color_grading.global.hue != 0.0, + ); + flags.set( + TonemappingPipelineKeyFlags::WHITE_BALANCE, + view.color_grading.global.temperature != 0.0 || view.color_grading.global.tint != 0.0, + ); + flags.set( + TonemappingPipelineKeyFlags::SECTIONAL_COLOR_GRADING, + view.color_grading + .all_sections() + .any(|section| *section != default()), + ); + let key = TonemappingPipelineKey { deband_dither: *dither.unwrap_or(&DebandDither::Disabled), tonemapping: *tonemapping.unwrap_or(&Tonemapping::None), + flags, }; let pipeline = pipelines.specialize(&pipeline_cache, &upscaling_pipeline, key); diff --git a/crates/bevy_core_pipeline/src/tonemapping/tonemapping_shared.wgsl b/crates/bevy_core_pipeline/src/tonemapping/tonemapping_shared.wgsl index 395d22698b21c..ef59418ea9a8d 100644 --- a/crates/bevy_core_pipeline/src/tonemapping/tonemapping_shared.wgsl +++ b/crates/bevy_core_pipeline/src/tonemapping/tonemapping_shared.wgsl @@ -1,6 +1,7 @@ #define_import_path bevy_core_pipeline::tonemapping #import bevy_render::view::ColorGrading +#import bevy_pbr::utils::{PI_2, hsv_to_rgb, rgb_to_hsv}; // hack !! not sure what to do with this #ifdef TONEMAPPING_PASS @@ -11,6 +12,15 @@ @group(0) @binding(19) var dt_lut_sampler: sampler; #endif +// Half the size of the crossfade region between shadows and midtones and +// between midtones and highlights. This value, 0.1, corresponds to 10% of the +// gamut on either side of the cutoff point. +const LEVEL_MARGIN: f32 = 0.1; + +// The inverse reciprocal of twice the above, used when scaling the midtone +// region. +const LEVEL_MARGIN_DIV: f32 = 0.5 / LEVEL_MARGIN; + fn sample_current_lut(p: vec3) -> vec3 { // Don't include code that will try to sample from LUTs if tonemap method doesn't require it // Allows this file to be imported without necessarily needing the lut texture bindings @@ -273,22 +283,92 @@ fn screen_space_dither(frag_coord: vec2) -> vec3 { return (dither - 0.5) / 255.0; } -fn tone_mapping(in: vec4, color_grading: ColorGrading) -> vec4 { - var color = max(in.rgb, vec3(0.0)); - - // Possible future grading: +// Performs the "sectional" color grading: i.e. the color grading that applies +// individually to shadows, midtones, and highlights. +fn sectional_color_grading( + in: vec3, + color_grading: ptr, +) -> vec3 { + var color = in; + + // Determine whether the color is a shadow, midtone, or highlight. Colors + // close to the edges are considered a mix of both, to avoid sharp + // discontinuities. The formulas are taken from Blender's compositor. + + let level = (color.r + color.g + color.b) / 3.0; + + // Determine whether this color is a shadow, midtone, or highlight. If close + // to the cutoff points, blend between the two to avoid sharp color + // discontinuities. + var levels = vec3(0.0); + let midtone_range = (*color_grading).midtone_range; + if (level < midtone_range.x - LEVEL_MARGIN) { + levels.x = 1.0; + } else if (level < midtone_range.x + LEVEL_MARGIN) { + levels.y = ((level - midtone_range.x) * LEVEL_MARGIN_DIV) + 0.5; + levels.z = 1.0 - levels.y; + } else if (level < midtone_range.y - LEVEL_MARGIN) { + levels.y = 1.0; + } else if (level < midtone_range.y + LEVEL_MARGIN) { + levels.z = ((level - midtone_range.y) * LEVEL_MARGIN_DIV) + 0.5; + levels.y = 1.0 - levels.z; + } else { + levels.z = 1.0; + } + + // Calculate contrast/saturation/gamma/gain/lift. + let contrast = dot(levels, (*color_grading).contrast); + let saturation = dot(levels, (*color_grading).saturation); + let gamma = dot(levels, (*color_grading).gamma); + let gain = dot(levels, (*color_grading).gain); + let lift = dot(levels, (*color_grading).lift); + + // Adjust saturation and contrast. + let luma = tonemapping_luminance(color); + color = luma + saturation * (color - luma); + color = 0.5 + (color - 0.5) * contrast; + + // The [ASC CDL] formula for color correction. Given *i*, an input color, we + // have: + // + // out = (i × s + o)ⁿ + // + // Following the normal photographic naming convention, *gain* is the *s* + // factor, *lift* is the *o* term, and the inverse of *gamma* is the *n* + // exponent. + // + // [ASC CDL]: https://en.wikipedia.org/wiki/ASC_CDL#Combined_Function + color = powsafe(color * gain + lift, 1.0 / gamma); + + // Account for exposure. + color = color * powsafe(vec3(2.0), (*color_grading).exposure); + return max(color, vec3(0.0)); +} - // highlight gain gamma: 0.. - // let luma = powsafe(vec3(tonemapping_luminance(color)), 1.0); +fn tone_mapping(in: vec4, in_color_grading: ColorGrading) -> vec4 { + var color = max(in.rgb, vec3(0.0)); + var color_grading = in_color_grading; // So we can take pointers to it. + + // Rotate hue if needed, by converting to and from HSV. Remember that hue is + // an angle, so it needs to be modulo 2π. +#ifdef HUE_ROTATE + var hsv = rgb_to_hsv(color); + hsv.r = (hsv.r + color_grading.hue) % PI_2; + color = hsv_to_rgb(hsv); +#endif - // highlight gain: 0.. - // color += color * luma.xxx * 1.0; + // Perform white balance correction. Conveniently, this is a linear + // transform. The matrix was pre-calculated from the temperature and tint + // values on the CPU. +#ifdef WHITE_BALANCE + color = max(color_grading.balance * color, vec3(0.0)); +#endif - // Linear pre tonemapping grading - color = saturation(color, color_grading.pre_saturation); - color = powsafe(color, color_grading.gamma); - color = color * powsafe(vec3(2.0), color_grading.exposure); - color = max(color, vec3(0.0)); + // Perform the "sectional" color grading: i.e. the color grading that + // applies individually to shadows, midtones, and highlights. +#ifdef SECTIONAL_COLOR_GRADING + color = sectional_color_grading(color, &color_grading); +#endif // tone_mapping #ifdef TONEMAP_METHOD_NONE diff --git a/crates/bevy_pbr/src/render/clustered_forward.wgsl b/crates/bevy_pbr/src/render/clustered_forward.wgsl index 179ffbb46e997..ae142f2eb5485 100644 --- a/crates/bevy_pbr/src/render/clustered_forward.wgsl +++ b/crates/bevy_pbr/src/render/clustered_forward.wgsl @@ -2,7 +2,7 @@ #import bevy_pbr::{ mesh_view_bindings as bindings, - utils::{hsv2rgb, rand_f}, + utils::{PI_2, hsv_to_rgb, rand_f}, } // NOTE: Keep in sync with bevy_pbr/src/light.rs @@ -78,7 +78,11 @@ fn cluster_debug_visualization( if (z_slice & 1u) == 1u { z_slice = z_slice + bindings::lights.cluster_dimensions.z / 2u; } - let slice_color = hsv2rgb(f32(z_slice) / f32(bindings::lights.cluster_dimensions.z + 1u), 1.0, 0.5); + let slice_color = hsv_to_rgb( + f32(z_slice) / f32(bindings::lights.cluster_dimensions.z + 1u) * PI_2, + 1.0, + 0.5 + ); output_color = vec4( (1.0 - cluster_overlay_alpha) * output_color.rgb + cluster_overlay_alpha * slice_color, output_color.a @@ -96,7 +100,7 @@ fn cluster_debug_visualization( // NOTE: Visualizes the cluster to which the fragment belongs let cluster_overlay_alpha = 0.1; var rng = cluster_index; - let cluster_color = hsv2rgb(rand_f(&rng), 1.0, 0.5); + let cluster_color = hsv_to_rgb(rand_f(&rng) * PI_2, 1.0, 0.5); output_color = vec4( (1.0 - cluster_overlay_alpha) * output_color.rgb + cluster_overlay_alpha * cluster_color, output_color.a diff --git a/crates/bevy_pbr/src/render/shadows.wgsl b/crates/bevy_pbr/src/render/shadows.wgsl index edc5b5d9e9c1d..bec8b90d262d0 100644 --- a/crates/bevy_pbr/src/render/shadows.wgsl +++ b/crates/bevy_pbr/src/render/shadows.wgsl @@ -3,7 +3,7 @@ #import bevy_pbr::{ mesh_view_types::POINT_LIGHT_FLAGS_SPOT_LIGHT_Y_NEGATIVE, mesh_view_bindings as view_bindings, - utils::hsv2rgb, + utils::{hsv_to_rgb, PI_2}, shadow_sampling::{SPOT_SHADOW_TEXEL_SIZE, sample_shadow_cubemap, sample_shadow_map} } @@ -170,7 +170,11 @@ fn cascade_debug_visualization( ) -> vec3 { let overlay_alpha = 0.95; let cascade_index = get_cascade_index(light_id, view_z); - let cascade_color = hsv2rgb(f32(cascade_index) / f32(#{MAX_CASCADES_PER_LIGHT}u + 1u), 1.0, 0.5); + let cascade_color = hsv_to_rgb( + f32(cascade_index) / f32(#{MAX_CASCADES_PER_LIGHT}u + 1u) * PI_2, + 1.0, + 0.5 + ); return vec3( (1.0 - overlay_alpha) * output_color.rgb + overlay_alpha * cascade_color ); diff --git a/crates/bevy_pbr/src/render/utils.wgsl b/crates/bevy_pbr/src/render/utils.wgsl index 031a858a3f77c..057c2f734cdfa 100644 --- a/crates/bevy_pbr/src/render/utils.wgsl +++ b/crates/bevy_pbr/src/render/utils.wgsl @@ -2,20 +2,53 @@ #import bevy_pbr::rgb9e5 -const PI: f32 = 3.141592653589793; -const HALF_PI: f32 = 1.57079632679; -const E: f32 = 2.718281828459045; - -fn hsv2rgb(hue: f32, saturation: f32, value: f32) -> vec3 { - let rgb = clamp( - abs( - ((hue * 6.0 + vec3(0.0, 4.0, 2.0)) % 6.0) - 3.0 - ) - 1.0, - vec3(0.0), - vec3(1.0) - ); - - return value * mix(vec3(1.0), rgb, vec3(saturation)); +const PI: f32 = 3.141592653589793; // π +const PI_2: f32 = 6.283185307179586; // 2π +const HALF_PI: f32 = 1.57079632679; // π/2 +const FRAC_PI_3: f32 = 1.0471975512; // π/3 +const E: f32 = 2.718281828459045; // exp(1) + +// Converts HSV to RGB. +// +// Input: H ∈ [0, 2π), S ∈ [0, 1], V ∈ [0, 1]. +// Output: R ∈ [0, 1], G ∈ [0, 1], B ∈ [0, 1]. +// +// +fn hsv_to_rgb(hsv: vec3) -> vec3 { + let n = vec3(5.0, 3.0, 1.0); + let k = (n + hsv.x / FRAC_PI_3) % 6.0; + return hsv.z - hsv.z * hsv.y * max(vec3(0.0), min(k, min(4.0 - k, vec3(1.0)))); +} + +// Converts RGB to HSV. +// +// Input: R ∈ [0, 1], G ∈ [0, 1], B ∈ [0, 1]. +// Output: H ∈ [0, 2π), S ∈ [0, 1], V ∈ [0, 1]. +// +// +fn rgb_to_hsv(rgb: vec3) -> vec3 { + let x_max = max(rgb.r, max(rgb.g, rgb.b)); // i.e. V + let x_min = min(rgb.r, min(rgb.g, rgb.b)); + let c = x_max - x_min; // chroma + + var swizzle = vec3(0.0); + if (x_max == rgb.r) { + swizzle = vec3(rgb.gb, 0.0); + } else if (x_max == rgb.g) { + swizzle = vec3(rgb.br, 2.0); + } else { + swizzle = vec3(rgb.rg, 4.0); + } + + let h = FRAC_PI_3 * (((swizzle.x - swizzle.y) / c + swizzle.z) % 6.0); + + // Avoid division by zero. + var s = 0.0; + if (x_max > 0.0) { + s = c / x_max; + } + + return vec3(h, s, x_max); } // Generates a random u32 in range [0, u32::MAX]. diff --git a/crates/bevy_render/src/camera/camera.rs b/crates/bevy_render/src/camera/camera.rs index 215aa2bb3911c..66cf24dcdcb87 100644 --- a/crates/bevy_render/src/camera/camera.rs +++ b/crates/bevy_render/src/camera/camera.rs @@ -846,7 +846,7 @@ pub fn extract_cameras( projection, ) in query.iter() { - let color_grading = *color_grading.unwrap_or(&ColorGrading::default()); + let color_grading = color_grading.unwrap_or(&ColorGrading::default()).clone(); if !camera.is_active { continue; diff --git a/crates/bevy_render/src/view/mod.rs b/crates/bevy_render/src/view/mod.rs index 1fef36c5844a0..710a9a0861d1b 100644 --- a/crates/bevy_render/src/view/mod.rs +++ b/crates/bevy_render/src/view/mod.rs @@ -24,13 +24,16 @@ use crate::{ }; use bevy_app::{App, Plugin}; use bevy_ecs::prelude::*; -use bevy_math::{Mat4, UVec4, Vec3, Vec4, Vec4Swizzles}; +use bevy_math::{mat3, vec2, vec3, Mat3, Mat4, UVec4, Vec2, Vec3, Vec4, Vec4Swizzles}; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_transform::components::GlobalTransform; use bevy_utils::HashMap; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, +use std::{ + ops::Range, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, }; use wgpu::{ Extent3d, RenderPassColorAttachment, RenderPassDepthStencilAttachment, StoreOp, @@ -39,6 +42,43 @@ use wgpu::{ pub const VIEW_TYPE_HANDLE: Handle = Handle::weak_from_u128(15421373904451797197); +/// The matrix that converts from the RGB to the LMS color space. +/// +/// To derive this, first we convert from RGB to [CIE 1931 XYZ]: +/// +/// ⎡ X ⎤ ⎡ 0.490 0.310 0.200 ⎤ ⎡ R ⎤ +/// ⎢ Y ⎥ = ⎢ 0.177 0.812 0.011 ⎥ ⎢ G ⎥ +/// ⎣ Z ⎦ ⎣ 0.000 0.010 0.990 ⎦ ⎣ B ⎦ +/// +/// Then we convert to LMS according to the [CAM16 standard matrix]: +/// +/// ⎡ L ⎤ ⎡ 0.401 0.650 -0.051 ⎤ ⎡ X ⎤ +/// ⎢ M ⎥ = ⎢ -0.250 1.204 0.046 ⎥ ⎢ Y ⎥ +/// ⎣ S ⎦ ⎣ -0.002 0.049 0.953 ⎦ ⎣ Z ⎦ +/// +/// The resulting matrix is just the concatenation of these two matrices, to do +/// the conversion in one step. +/// +/// [CIE 1931 XYZ]: https://en.wikipedia.org/wiki/CIE_1931_color_space +/// [CAM16 standard matrix]: https://en.wikipedia.org/wiki/LMS_color_space +static RGB_TO_LMS: Mat3 = mat3( + vec3(0.311692, 0.0905138, 0.00764433), + vec3(0.652085, 0.901341, 0.0486554), + vec3(0.0362225, 0.00814478, 0.943700), +); + +/// The inverse of the [`RGB_TO_LMS`] matrix, converting from the LMS color +/// space back to RGB. +static LMS_TO_RGB: Mat3 = mat3( + vec3(4.06305, -0.40791, -0.0118812), + vec3(-2.93241, 1.40437, -0.0486532), + vec3(-0.130646, 0.00353630, 1.0605344), +); + +/// The +static D65_XY: Vec2 = vec2(0.31272, 0.32903); +static D65_LMS: Vec3 = vec3(0.975538, 1.01648, 1.08475); + pub struct ViewPlugin; impl Plugin for ViewPlugin { @@ -129,40 +169,209 @@ impl ExtractedView { } } -/// Configures basic color grading parameters to adjust the image appearance. Grading is applied just before/after tonemapping for a given [`Camera`](crate::camera::Camera) entity. -#[derive(Component, Reflect, Debug, Copy, Clone, ShaderType)] +/// Configures filmic color grading parameters to adjust the image appearance. +/// +/// Grading is applied just before/after tonemapping for a given +/// [`Camera`](crate::camera::Camera) entity. +#[derive(Component, Reflect, Debug, Default, Clone)] #[reflect(Component, Default)] pub struct ColorGrading { + /// Filmic color grading values applied to the image as a whole (as opposed + /// to individual sections, like shadows and highlights). + pub global: ColorGradingGlobal, + + /// Color grading values that are applied to the darker parts of the image. + /// + /// The cutoff points can be customized with the + /// [`ColorGradingGlobal::midtones_range`] field. + pub shadows: ColorGradingSection, + + /// Color grading values that are applied to the parts of the image with + /// intermediate brightness. + /// + /// The cutoff points can be customized with the + /// [`ColorGradingGlobal::midtones_range`] field. + pub midtones: ColorGradingSection, + + /// Color grading values that are applied to the lighter parts of the image. + /// + /// The cutoff points can be customized with the + /// [`ColorGradingGlobal::midtones_range`] field. + pub highlights: ColorGradingSection, +} + +/// Filmic color grading values applied to the image as a whole (as opposed to +/// individual sections, like shadows and highlights). +#[derive(Clone, Debug, Reflect)] +#[reflect(Default)] +pub struct ColorGradingGlobal { /// Exposure value (EV) offset, measured in stops. pub exposure: f32, - /// Non-linear luminance adjustment applied before tonemapping. y = pow(x, gamma) - pub gamma: f32, + /// An adjustment made to the [CIE 1931] chromaticity *x* value. + /// + /// Positive values make the colors redder. Negative values make the colors + /// bluer. This has no effect on luminance (brightness). + /// + /// [CIE 1931]: https://en.wikipedia.org/wiki/CIE_1931_color_space#CIE_xy_chromaticity_diagram_and_the_CIE_xyY_color_space + pub temperature: f32, - /// Saturation adjustment applied before tonemapping. - /// Values below 1.0 desaturate, with a value of 0.0 resulting in a grayscale image - /// with luminance defined by ITU-R BT.709. - /// Values above 1.0 increase saturation. - pub pre_saturation: f32, + /// An adjustment made to the [CIE 1931] chromaticity *y* value. + /// + /// Positive values make the colors more magenta. Negative values make the + /// colors greener. This has no effect on luminance (brightness). + /// + /// [CIE 1931]: https://en.wikipedia.org/wiki/CIE_1931_color_space#CIE_xy_chromaticity_diagram_and_the_CIE_xyY_color_space + pub tint: f32, + + /// An adjustment to the [hue], in radians. + /// + /// Adjusting this value changes the perceived colors in the image: red to + /// yellow to green to blue, etc. It has no effect on the saturation or + /// brightness of the colors. + /// + /// [hue]: https://en.wikipedia.org/wiki/HSL_and_HSV#Formal_derivation + pub hue: f32, /// Saturation adjustment applied after tonemapping. /// Values below 1.0 desaturate, with a value of 0.0 resulting in a grayscale image /// with luminance defined by ITU-R BT.709 /// Values above 1.0 increase saturation. pub post_saturation: f32, + + /// The luminance (brightness) ranges that are considered part of the + /// "midtones" of the image. + /// + /// This affects which [`ColorGradingSection`]s apply to which colors. Note + /// that the sections smoothly blend into one another, to avoid abrupt + /// transitions. + /// + /// The default value is 0.2 to 0.7. + pub midtones_range: Range, } -impl Default for ColorGrading { +/// The [`ColorGrading`] structure, packed into the most efficient form for the +/// GPU. +#[derive(Clone, Copy, Debug, ShaderType)] +struct ColorGradingUniform { + balance: Mat3, + saturation: Vec3, + contrast: Vec3, + gamma: Vec3, + gain: Vec3, + lift: Vec3, + midtone_range: Vec2, + exposure: f32, + hue: f32, + post_saturation: f32, +} + +/// A section of color grading values that can be selectively applied to +/// shadows, midtones, and highlights. +#[derive(Reflect, Debug, Copy, Clone, PartialEq)] +pub struct ColorGradingSection { + /// Values below 1.0 desaturate, with a value of 0.0 resulting in a grayscale image + /// with luminance defined by ITU-R BT.709. + /// Values above 1.0 increase saturation. + pub saturation: f32, + + /// Adjusts the range of colors. + /// + /// A value of 1.0 applies no changes. Values below 1.0 move the colors more + /// toward a neutral gray. Values above 1.0 spread the colors out away from + /// the neutral gray. + pub contrast: f32, + + /// A nonlinear luminance adjustment, mainly affecting the high end of the + /// range. + /// + /// This is the *n* exponent in the standard [ASC CDL] formula for color + /// correction: + /// + /// out = (i × s + o)ⁿ + /// + /// [ASC CDL]: https://en.wikipedia.org/wiki/ASC_CDL#Combined_Function + pub gamma: f32, + + /// A linear luminance adjustment, mainly affecting the middle part of the + /// range. + /// + /// This is the *s* exponent in the standard [ASC CDL] formula for color + /// correction: + /// + /// out = (i × s + o)ⁿ + /// + /// [ASC CDL]: https://en.wikipedia.org/wiki/ASC_CDL#Combined_Function + pub gain: f32, + + /// A fixed luminance adjustment, mainly affecting the lower part of the + /// range. + /// + /// This is the *o* term in the standard [ASC CDL] formula for color + /// correction: + /// + /// out = (i × s + o)ⁿ + /// + /// [ASC CDL]: https://en.wikipedia.org/wiki/ASC_CDL#Combined_Function + pub lift: f32, +} + +impl Default for ColorGradingGlobal { fn default() -> Self { Self { exposure: 0.0, - gamma: 1.0, - pre_saturation: 1.0, + temperature: 0.0, + tint: 0.0, + hue: 0.0, post_saturation: 1.0, + midtones_range: 0.2..0.7, + } + } +} + +impl Default for ColorGradingSection { + fn default() -> Self { + Self { + saturation: 1.0, + contrast: 1.0, + gamma: 1.0, + gain: 1.0, + lift: 0.0, } } } +impl ColorGrading { + /// Creates a new [`ColorGrading`] instance in which shadows, midtones, and + /// highlights all have the same set of color grading values. + pub fn with_identical_sections( + global: ColorGradingGlobal, + section: ColorGradingSection, + ) -> ColorGrading { + ColorGrading { + global, + highlights: section, + midtones: section, + shadows: section, + } + } + + /// Returns an iterator that visits the shadows, midtones, and highlights + /// sections, in that order. + pub fn all_sections(&self) -> impl Iterator { + [&self.shadows, &self.midtones, &self.highlights].into_iter() + } + + /// Applies the given mutating function to the shadows, midtones, and + /// highlights sections, in that order. + /// + /// Returns an array composed of the results of such evaluation, in that + /// order. + pub fn all_sections_mut(&mut self) -> impl Iterator { + [&mut self.shadows, &mut self.midtones, &mut self.highlights].into_iter() + } +} + #[derive(Clone, ShaderType)] pub struct ViewUniform { view_proj: Mat4, @@ -177,7 +386,7 @@ pub struct ViewUniform { // viewport(x_origin, y_origin, width, height) viewport: Vec4, frustum: [Vec4; 6], - color_grading: ColorGrading, + color_grading: ColorGradingUniform, mip_bias: f32, render_layers: u32, } @@ -208,6 +417,85 @@ pub struct PostProcessWrite<'a> { pub destination: &'a TextureView, } +impl From for ColorGradingUniform { + fn from(component: ColorGrading) -> Self { + // Compute the balance matrix that will be used to apply the white + // balance adjustment to an RGB color. Our general approach will be to + // convert both the color and the developer-supplied white point to the + // LMS color space, apply the conversion, and then convert back. + // + // First, we start with the CIE 1931 *xy* values of the standard D65 + // illuminant: + // + // + // We then adjust them based on the developer's requested white balance. + let white_point_xy = D65_XY + vec2(-component.global.temperature, component.global.tint); + + // Convert the white point from CIE 1931 *xy* to LMS. First, we convert to XYZ: + // + // Y Y + // Y = 1 X = ─ x Z = ─ (1 - x - y) + // y y + // + // Then we convert from XYZ to LMS color space, using the CAM16 matrix + // from : + // + // ⎡ L ⎤ ⎡ 0.401 0.650 -0.051 ⎤ ⎡ X ⎤ + // ⎢ M ⎥ = ⎢ -0.250 1.204 0.046 ⎥ ⎢ Y ⎥ + // ⎣ S ⎦ ⎣ -0.002 0.049 0.953 ⎦ ⎣ Z ⎦ + // + // The following formula is just a simplification of the above. + + let white_point_lms = vec3(0.701634, 1.15856, -0.904175) + + (vec3(-0.051461, 0.045854, 0.953127) + + vec3(0.452749, -0.296122, -0.955206) * white_point_xy.x) + / white_point_xy.y; + + // Now that we're in LMS space, perform the white point scaling. + let white_point_adjustment = Mat3::from_diagonal(D65_LMS / white_point_lms); + + // Finally, combine the RGB → LMS → corrected LMS → corrected RGB + // pipeline into a single 3×3 matrix. + let balance = LMS_TO_RGB * white_point_adjustment * RGB_TO_LMS; + + Self { + balance, + saturation: vec3( + component.shadows.saturation, + component.midtones.saturation, + component.highlights.saturation, + ), + contrast: vec3( + component.shadows.contrast, + component.midtones.contrast, + component.highlights.contrast, + ), + gamma: vec3( + component.shadows.gamma, + component.midtones.gamma, + component.highlights.gamma, + ), + gain: vec3( + component.shadows.gain, + component.midtones.gain, + component.highlights.gain, + ), + lift: vec3( + component.shadows.lift, + component.midtones.lift, + component.highlights.lift, + ), + midtone_range: vec2( + component.global.midtones_range.start, + component.global.midtones_range.end, + ), + exposure: component.global.exposure, + hue: component.global.hue, + post_saturation: component.global.post_saturation, + } + } +} + impl ViewTarget { pub const TEXTURE_FORMAT_HDR: TextureFormat = TextureFormat::Rgba16Float; @@ -439,7 +727,7 @@ pub fn prepare_view_uniforms( .unwrap_or_else(|| Exposure::default().exposure()), viewport, frustum, - color_grading: extracted_view.color_grading, + color_grading: extracted_view.color_grading.clone().into(), mip_bias: mip_bias.unwrap_or(&MipBias(0.0)).0, render_layers: maybe_layers.copied().unwrap_or_default().bits(), }), diff --git a/crates/bevy_render/src/view/view.wgsl b/crates/bevy_render/src/view/view.wgsl index 237113b713a0d..6cf0b75a48f0a 100644 --- a/crates/bevy_render/src/view/view.wgsl +++ b/crates/bevy_render/src/view/view.wgsl @@ -1,9 +1,15 @@ #define_import_path bevy_render::view struct ColorGrading { + balance: mat3x3, + saturation: vec3, + contrast: vec3, + gamma: vec3, + gain: vec3, + lift: vec3, + midtone_range: vec2, exposure: f32, - gamma: f32, - pre_saturation: f32, + hue: f32, post_saturation: f32, } diff --git a/examples/3d/color_grading.rs b/examples/3d/color_grading.rs new file mode 100644 index 0000000000000..7b0b9894a12fd --- /dev/null +++ b/examples/3d/color_grading.rs @@ -0,0 +1,680 @@ +//! Demonstrates color grading with an interactive adjustment UI. + +use std::{ + f32::consts::PI, + fmt::{self, Formatter}, +}; + +use bevy::{ + ecs::system::EntityCommands, + pbr::CascadeShadowConfigBuilder, + prelude::*, + render::view::{ColorGrading, ColorGradingGlobal, ColorGradingSection}, +}; +use std::fmt::Display; + +static FONT_PATH: &str = "fonts/FiraMono-Medium.ttf"; + +/// How quickly the value changes per frame. +const OPTION_ADJUSTMENT_SPEED: f32 = 0.003; + +/// The color grading section that the user has selected: highlights, midtones, +/// or shadows. +#[derive(Clone, Copy, PartialEq)] +enum SelectedColorGradingSection { + Highlights, + Midtones, + Shadows, +} + +/// The global option that the user has selected. +/// +/// See the documentation of [`ColorGradingGlobal`] for more information about +/// each field here. +#[derive(Clone, Copy, PartialEq, Default)] +enum SelectedGlobalColorGradingOption { + #[default] + Exposure, + Temperature, + Tint, + Hue, +} + +/// The section-specific option that the user has selected. +/// +/// See the documentation of [`ColorGradingSection`] for more information about +/// each field here. +#[derive(Clone, Copy, PartialEq)] +enum SelectedSectionColorGradingOption { + Saturation, + Contrast, + Gamma, + Gain, + Lift, +} + +/// The color grading option that the user has selected. +#[derive(Clone, Copy, PartialEq, Resource)] +enum SelectedColorGradingOption { + /// The user has selected a global color grading option: one that applies to + /// the whole image as opposed to specifically to highlights, midtones, or + /// shadows. + Global(SelectedGlobalColorGradingOption), + + /// The user has selected a color grading option that applies only to + /// highlights, midtones, or shadows. + Section( + SelectedColorGradingSection, + SelectedSectionColorGradingOption, + ), +} + +impl Default for SelectedColorGradingOption { + fn default() -> Self { + Self::Global(default()) + } +} + +/// Buttons consist of three parts: the button itself, a label child, and a +/// value child. This specifies one of the three entities. +#[derive(Clone, Copy, PartialEq, Component)] +enum ColorGradingOptionWidgetType { + /// The parent button. + Button, + /// The label of the button. + Label, + /// The numerical value that the button displays. + Value, +} + +#[derive(Clone, Copy, Component)] +struct ColorGradingOptionWidget { + widget_type: ColorGradingOptionWidgetType, + option: SelectedColorGradingOption, +} + +/// A marker component for the help text at the top left of the screen. +#[derive(Clone, Copy, Component)] +struct HelpText; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .init_resource::() + .add_systems(Startup, setup) + .add_systems( + Update, + ( + handle_button_presses, + adjust_color_grading_option, + update_ui_state, + ) + .chain(), + ) + .run(); +} + +fn setup( + mut commands: Commands, + currently_selected_option: Res, + asset_server: Res, +) { + // Create the scene. + add_basic_scene(&mut commands, &asset_server); + + // Create the root UI element. + let font = asset_server.load(FONT_PATH); + let color_grading = ColorGrading::default(); + add_buttons( + &mut commands, + ¤tly_selected_option, + &font, + &color_grading, + ); + + // Spawn help text. + add_help_text(&mut commands, &font, ¤tly_selected_option); + + // Spawn the camera. + add_camera(&mut commands, &asset_server, color_grading); +} + +/// Adds all the buttons on the bottom of the scene. +fn add_buttons( + commands: &mut Commands, + currently_selected_option: &SelectedColorGradingOption, + font: &Handle, + color_grading: &ColorGrading, +) { + // Spawn the parent node that contains all the buttons. + commands + .spawn(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + position_type: PositionType::Absolute, + row_gap: Val::Px(6.0), + left: Val::Px(10.0), + bottom: Val::Px(10.0), + ..default() + }, + ..default() + }) + .with_children(|parent| { + // Create the first row, which contains the global controls. + add_buttons_for_global_controls( + parent, + *currently_selected_option, + color_grading, + font, + ); + + // Create the rows for individual controls. + for section in [ + SelectedColorGradingSection::Highlights, + SelectedColorGradingSection::Midtones, + SelectedColorGradingSection::Shadows, + ] { + add_buttons_for_section( + parent, + section, + *currently_selected_option, + color_grading, + font, + ); + } + }); +} + +/// Adds the buttons for the global controls (those that control the scene as a +/// whole as opposed to shadows, midtones, or highlights). +fn add_buttons_for_global_controls( + parent: &mut ChildBuilder, + currently_selected_option: SelectedColorGradingOption, + color_grading: &ColorGrading, + font: &Handle, +) { + // Add the parent node for the row. + parent + .spawn(NodeBundle { + style: Style::default(), + ..default() + }) + .with_children(|parent| { + // Add some placeholder text to fill this column. + parent.spawn(NodeBundle { + style: Style { + width: Val::Px(125.0), + ..default() + }, + ..default() + }); + + // Add each global color grading option button. + for option in [ + SelectedGlobalColorGradingOption::Exposure, + SelectedGlobalColorGradingOption::Temperature, + SelectedGlobalColorGradingOption::Tint, + SelectedGlobalColorGradingOption::Hue, + ] { + add_button_for_value( + parent, + SelectedColorGradingOption::Global(option), + currently_selected_option, + color_grading, + font, + ); + } + }); +} + +/// Adds the buttons that control color grading for individual sections +/// (highlights, midtones, shadows). +fn add_buttons_for_section( + parent: &mut ChildBuilder, + section: SelectedColorGradingSection, + currently_selected_option: SelectedColorGradingOption, + color_grading: &ColorGrading, + font: &Handle, +) { + // Spawn the row container. + parent + .spawn(NodeBundle { + style: Style { + align_items: AlignItems::Center, + ..default() + }, + ..default() + }) + .with_children(|parent| { + // Spawn the label ("Highlights", etc.) + add_text(parent, §ion.to_string(), font, Color::WHITE).insert(Style { + width: Val::Px(125.0), + ..default() + }); + + // Spawn the buttons. + for option in [ + SelectedSectionColorGradingOption::Saturation, + SelectedSectionColorGradingOption::Contrast, + SelectedSectionColorGradingOption::Gamma, + SelectedSectionColorGradingOption::Gain, + SelectedSectionColorGradingOption::Lift, + ] { + add_button_for_value( + parent, + SelectedColorGradingOption::Section(section, option), + currently_selected_option, + color_grading, + font, + ); + } + }); +} + +/// Adds a button that controls one of the color grading values. +fn add_button_for_value( + parent: &mut ChildBuilder, + option: SelectedColorGradingOption, + currently_selected_option: SelectedColorGradingOption, + color_grading: &ColorGrading, + font: &Handle, +) { + let is_selected = currently_selected_option == option; + + let (bg_color, fg_color) = if is_selected { + (Color::WHITE, Color::BLACK) + } else { + (Color::BLACK, Color::WHITE) + }; + + // Add the button node. + parent + .spawn(ButtonBundle { + style: Style { + border: UiRect::all(Val::Px(1.0)), + width: Val::Px(200.0), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)), + margin: UiRect::right(Val::Px(12.0)), + ..default() + }, + border_color: BorderColor(Color::WHITE), + border_radius: BorderRadius::MAX, + image: UiImage::default().with_color(bg_color), + ..default() + }) + .insert(ColorGradingOptionWidget { + widget_type: ColorGradingOptionWidgetType::Button, + option, + }) + .with_children(|parent| { + // Add the button label. + let label = match option { + SelectedColorGradingOption::Global(option) => option.to_string(), + SelectedColorGradingOption::Section(_, option) => option.to_string(), + }; + add_text(parent, &label, font, fg_color).insert(ColorGradingOptionWidget { + widget_type: ColorGradingOptionWidgetType::Label, + option, + }); + + // Add a spacer. + parent.spawn(NodeBundle { + style: Style { + flex_grow: 1.0, + ..default() + }, + ..default() + }); + + // Add the value text. + add_text( + parent, + &format!("{:.3}", option.get(color_grading)), + font, + fg_color, + ) + .insert(ColorGradingOptionWidget { + widget_type: ColorGradingOptionWidgetType::Value, + option, + }); + }); +} + +/// Creates the help text at the top of the screen. +fn add_help_text( + commands: &mut Commands, + font: &Handle, + currently_selected_option: &SelectedColorGradingOption, +) { + commands + .spawn(TextBundle { + style: Style { + position_type: PositionType::Absolute, + left: Val::Px(10.0), + top: Val::Px(10.0), + ..default() + }, + ..TextBundle::from_section( + create_help_text(currently_selected_option), + TextStyle { + font: font.clone(), + font_size: 24.0, + color: Color::WHITE, + }, + ) + }) + .insert(HelpText); +} + +/// Adds some text to the scene. +fn add_text<'a>( + parent: &'a mut ChildBuilder, + label: &str, + font: &Handle, + color: Color, +) -> EntityCommands<'a> { + parent.spawn(TextBundle::from_section( + label, + TextStyle { + font: font.clone(), + font_size: 18.0, + color, + }, + )) +} + +fn add_camera(commands: &mut Commands, asset_server: &AssetServer, color_grading: ColorGrading) { + commands.spawn(( + Camera3dBundle { + camera: Camera { + hdr: true, + ..default() + }, + transform: Transform::from_xyz(0.7, 0.7, 1.0) + .looking_at(Vec3::new(0.0, 0.3, 0.0), Vec3::Y), + color_grading, + ..default() + }, + FogSettings { + color: Color::srgb_u8(43, 44, 47), + falloff: FogFalloff::Linear { + start: 1.0, + end: 8.0, + }, + ..default() + }, + EnvironmentMapLight { + diffuse_map: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"), + specular_map: asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"), + intensity: 2000.0, + }, + )); +} + +fn add_basic_scene(commands: &mut Commands, asset_server: &AssetServer) { + // Spawn the main scene. + commands.spawn(SceneBundle { + scene: asset_server.load("models/TonemappingTest/TonemappingTest.gltf#Scene0"), + ..default() + }); + + // Spawn the flight helmet. + commands.spawn(SceneBundle { + scene: asset_server.load("models/FlightHelmet/FlightHelmet.gltf#Scene0"), + transform: Transform::from_xyz(0.5, 0.0, -0.5) + .with_rotation(Quat::from_rotation_y(-0.15 * PI)), + ..default() + }); + + // Spawn the light. + commands.spawn(DirectionalLightBundle { + directional_light: DirectionalLight { + illuminance: 15000.0, + shadows_enabled: true, + ..default() + }, + transform: Transform::from_rotation(Quat::from_euler( + EulerRot::ZYX, + 0.0, + PI * -0.15, + PI * -0.15, + )), + cascade_shadow_config: CascadeShadowConfigBuilder { + maximum_distance: 3.0, + first_cascade_far_bound: 0.9, + ..default() + } + .into(), + ..default() + }); +} + +impl Display for SelectedGlobalColorGradingOption { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let name = match *self { + SelectedGlobalColorGradingOption::Exposure => "Exposure", + SelectedGlobalColorGradingOption::Temperature => "Temperature", + SelectedGlobalColorGradingOption::Tint => "Tint", + SelectedGlobalColorGradingOption::Hue => "Hue", + }; + f.write_str(name) + } +} + +impl Display for SelectedColorGradingSection { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let name = match *self { + SelectedColorGradingSection::Highlights => "Highlights", + SelectedColorGradingSection::Midtones => "Midtones", + SelectedColorGradingSection::Shadows => "Shadows", + }; + f.write_str(name) + } +} + +impl Display for SelectedSectionColorGradingOption { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let name = match *self { + SelectedSectionColorGradingOption::Saturation => "Saturation", + SelectedSectionColorGradingOption::Contrast => "Contrast", + SelectedSectionColorGradingOption::Gamma => "Gamma", + SelectedSectionColorGradingOption::Gain => "Gain", + SelectedSectionColorGradingOption::Lift => "Lift", + }; + f.write_str(name) + } +} + +impl Display for SelectedColorGradingOption { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + SelectedColorGradingOption::Global(option) => write!(f, "\"{}\"", option), + SelectedColorGradingOption::Section(section, option) => { + write!(f, "\"{}\" for \"{}\"", option, section) + } + } + } +} + +impl SelectedSectionColorGradingOption { + /// Returns the appropriate value in the given color grading section. + fn get(&self, section: &ColorGradingSection) -> f32 { + match *self { + SelectedSectionColorGradingOption::Saturation => section.saturation, + SelectedSectionColorGradingOption::Contrast => section.contrast, + SelectedSectionColorGradingOption::Gamma => section.gamma, + SelectedSectionColorGradingOption::Gain => section.gain, + SelectedSectionColorGradingOption::Lift => section.lift, + } + } + + fn set(&self, section: &mut ColorGradingSection, value: f32) { + match *self { + SelectedSectionColorGradingOption::Saturation => section.saturation = value, + SelectedSectionColorGradingOption::Contrast => section.contrast = value, + SelectedSectionColorGradingOption::Gamma => section.gamma = value, + SelectedSectionColorGradingOption::Gain => section.gain = value, + SelectedSectionColorGradingOption::Lift => section.lift = value, + } + } +} + +impl SelectedGlobalColorGradingOption { + /// Returns the appropriate value in the given set of global color grading + /// values. + fn get(&self, global: &ColorGradingGlobal) -> f32 { + match *self { + SelectedGlobalColorGradingOption::Exposure => global.exposure, + SelectedGlobalColorGradingOption::Temperature => global.temperature, + SelectedGlobalColorGradingOption::Tint => global.tint, + SelectedGlobalColorGradingOption::Hue => global.hue, + } + } + + /// Sets the appropriate value in the given set of global color grading + /// values. + fn set(&self, global: &mut ColorGradingGlobal, value: f32) { + match *self { + SelectedGlobalColorGradingOption::Exposure => global.exposure = value, + SelectedGlobalColorGradingOption::Temperature => global.temperature = value, + SelectedGlobalColorGradingOption::Tint => global.tint = value, + SelectedGlobalColorGradingOption::Hue => global.hue = value, + } + } +} + +impl SelectedColorGradingOption { + /// Returns the appropriate value in the given set of color grading values. + fn get(&self, color_grading: &ColorGrading) -> f32 { + match self { + SelectedColorGradingOption::Global(option) => option.get(&color_grading.global), + SelectedColorGradingOption::Section( + SelectedColorGradingSection::Highlights, + option, + ) => option.get(&color_grading.highlights), + SelectedColorGradingOption::Section(SelectedColorGradingSection::Midtones, option) => { + option.get(&color_grading.midtones) + } + SelectedColorGradingOption::Section(SelectedColorGradingSection::Shadows, option) => { + option.get(&color_grading.shadows) + } + } + } + + /// Sets the appropriate value in the given set of color grading values. + fn set(&self, color_grading: &mut ColorGrading, value: f32) { + match self { + SelectedColorGradingOption::Global(option) => { + option.set(&mut color_grading.global, value); + } + SelectedColorGradingOption::Section( + SelectedColorGradingSection::Highlights, + option, + ) => option.set(&mut color_grading.highlights, value), + SelectedColorGradingOption::Section(SelectedColorGradingSection::Midtones, option) => { + option.set(&mut color_grading.midtones, value); + } + SelectedColorGradingOption::Section(SelectedColorGradingSection::Shadows, option) => { + option.set(&mut color_grading.shadows, value); + } + } + } +} + +/// Handles mouse clicks on the buttons when the user clicks on a new one. +fn handle_button_presses( + mut interactions: Query<(&Interaction, &ColorGradingOptionWidget), Changed>, + mut currently_selected_option: ResMut, +) { + for (interaction, widget) in interactions.iter_mut() { + if widget.widget_type == ColorGradingOptionWidgetType::Button + && *interaction == Interaction::Pressed + { + *currently_selected_option = widget.option; + } + } +} + +/// Updates the state of the UI based on the current state. +fn update_ui_state( + mut buttons: Query<(&mut UiImage, &ColorGradingOptionWidget)>, + mut button_text: Query<(&mut Text, &ColorGradingOptionWidget), Without>, + mut help_text: Query<&mut Text, With>, + cameras: Query<&ColorGrading>, + currently_selected_option: Res, +) { + // The currently-selected option is drawn with inverted colors. + for (mut image, widget) in buttons.iter_mut() { + image.color = if *currently_selected_option == widget.option { + Color::WHITE + } else { + Color::BLACK + }; + } + + let value_label = cameras + .iter() + .next() + .map(|color_grading| format!("{:.3}", currently_selected_option.get(color_grading))); + + // Update the buttons. + for (mut text, widget) in button_text.iter_mut() { + // Set the text color. + + let color = if *currently_selected_option == widget.option { + Color::BLACK + } else { + Color::WHITE + }; + + for section in &mut text.sections { + section.style.color = color; + } + + // Update the displayed value, if this is the currently-selected option. + if widget.widget_type == ColorGradingOptionWidgetType::Value + && *currently_selected_option == widget.option + { + if let Some(ref value_label) = value_label { + for section in &mut text.sections { + section.value = value_label.clone(); + } + } + } + } + + // Update the help text. + for mut help_text in help_text.iter_mut() { + for section in &mut help_text.sections { + section.value = create_help_text(¤tly_selected_option); + } + } +} + +/// Creates the help text at the top left of the window. +fn create_help_text(currently_selected_option: &SelectedColorGradingOption) -> String { + format!("Press Left/Right to adjust {}", currently_selected_option) +} + +/// Processes keyboard input to change the value of the currently-selected color +/// grading option. +fn adjust_color_grading_option( + mut cameras: Query<&mut ColorGrading>, + input: Res>, + currently_selected_option: Res, +) { + let mut delta = 0.0; + if input.pressed(KeyCode::ArrowLeft) { + delta -= OPTION_ADJUSTMENT_SPEED; + } + if input.pressed(KeyCode::ArrowRight) { + delta += OPTION_ADJUSTMENT_SPEED; + } + + for mut color_grading in cameras.iter_mut() { + let new_value = currently_selected_option.get(&color_grading) + delta; + currently_selected_option.set(&mut color_grading, new_value); + } +} diff --git a/examples/3d/tonemapping.rs b/examples/3d/tonemapping.rs index 1f34b1fb81a21..5de7795de57ee 100644 --- a/examples/3d/tonemapping.rs +++ b/examples/3d/tonemapping.rs @@ -6,10 +6,8 @@ use bevy::{ prelude::*, reflect::TypePath, render::{ - render_asset::RenderAssetUsages, - render_resource::{AsBindGroup, Extent3d, ShaderRef, TextureDimension, TextureFormat}, - texture::{ImageSampler, ImageSamplerDescriptor}, - view::ColorGrading, + render_resource::{AsBindGroup, ShaderRef}, + view::{ColorGrading, ColorGradingGlobal, ColorGradingSection}, }, utils::HashMap, }; @@ -98,83 +96,14 @@ fn setup( ); } -fn setup_basic_scene( - mut commands: Commands, - mut meshes: ResMut>, - mut materials: ResMut>, - mut images: ResMut>, - asset_server: Res, -) { - // plane - commands.spawn(( - PbrBundle { - mesh: meshes.add(Plane3d::default().mesh().size(50.0, 50.0)), - material: materials.add(Color::srgb(0.1, 0.2, 0.1)), +fn setup_basic_scene(mut commands: Commands, asset_server: Res) { + // Main scene + commands + .spawn(SceneBundle { + scene: asset_server.load("models/TonemappingTest/TonemappingTest.gltf#Scene0"), ..default() - }, - SceneNumber(1), - )); - - // cubes - let cube_material = materials.add(StandardMaterial { - base_color_texture: Some(images.add(uv_debug_texture())), - ..default() - }); - - let cube_mesh = meshes.add(Cuboid::new(0.25, 0.25, 0.25)); - for i in 0..5 { - commands.spawn(( - PbrBundle { - mesh: cube_mesh.clone(), - material: cube_material.clone(), - transform: Transform::from_xyz(i as f32 * 0.25 - 1.0, 0.125, -i as f32 * 0.5), - ..default() - }, - SceneNumber(1), - )); - } - - // spheres - let sphere_mesh = meshes.add(Sphere::new(0.125).mesh().uv(32, 18)); - for i in 0..6 { - let j = i % 3; - let s_val = if i < 3 { 0.0 } else { 0.2 }; - let material = if j == 0 { - materials.add(StandardMaterial { - base_color: Color::srgb(s_val, s_val, 1.0), - perceptual_roughness: 0.089, - metallic: 0.0, - ..default() - }) - } else if j == 1 { - materials.add(StandardMaterial { - base_color: Color::srgb(s_val, 1.0, s_val), - perceptual_roughness: 0.089, - metallic: 0.0, - ..default() - }) - } else { - materials.add(StandardMaterial { - base_color: Color::srgb(1.0, s_val, s_val), - perceptual_roughness: 0.089, - metallic: 0.0, - ..default() - }) - }; - commands.spawn(( - PbrBundle { - mesh: sphere_mesh.clone(), - material, - transform: Transform::from_xyz( - j as f32 * 0.25 + if i < 3 { -0.15 } else { 0.15 } - 0.4, - 0.125, - -j as f32 * 0.25 + if i < 3 { -0.15 } else { 0.15 } + 0.4, - ), - ..default() - }, - SceneNumber(1), - )); - } + }) + .insert(SceneNumber(1)); // Flight Helmet commands.spawn(( @@ -403,10 +332,12 @@ fn toggle_tonemapping_method( *method = Tonemapping::BlenderFilmic; } - *color_grading = *per_method_settings + *color_grading = (*per_method_settings .settings .get::(&method) - .unwrap(); + .as_ref() + .unwrap()) + .clone(); } #[derive(Resource)] @@ -448,16 +379,20 @@ fn update_color_grading_settings( if keys.pressed(KeyCode::ArrowLeft) || keys.pressed(KeyCode::ArrowRight) { match selected_parameter.value { 0 => { - color_grading.exposure += dt; + color_grading.global.exposure += dt; } 1 => { - color_grading.gamma += dt; + color_grading + .all_sections_mut() + .for_each(|section| section.gamma += dt); } 2 => { - color_grading.pre_saturation += dt; + color_grading + .all_sections_mut() + .for_each(|section| section.saturation += dt); } 3 => { - color_grading.post_saturation += dt; + color_grading.global.post_saturation += dt; } _ => {} } @@ -577,24 +512,24 @@ fn update_ui( if selected_parameter.value == 0 { text.push_str("> "); } - text.push_str(&format!("Exposure: {}\n", color_grading.exposure)); + text.push_str(&format!("Exposure: {}\n", color_grading.global.exposure)); if selected_parameter.value == 1 { text.push_str("> "); } - text.push_str(&format!("Gamma: {}\n", color_grading.gamma)); + text.push_str(&format!("Gamma: {}\n", color_grading.shadows.gamma)); if selected_parameter.value == 2 { text.push_str("> "); } text.push_str(&format!( "PreSaturation: {}\n", - color_grading.pre_saturation + color_grading.shadows.saturation )); if selected_parameter.value == 3 { text.push_str("> "); } text.push_str(&format!( "PostSaturation: {}\n", - color_grading.post_saturation + color_grading.global.post_saturation )); text.push_str("(Space) Reset all to default\n"); @@ -614,19 +549,30 @@ impl PerMethodSettings { fn basic_scene_recommendation(method: Tonemapping) -> ColorGrading { match method { Tonemapping::Reinhard | Tonemapping::ReinhardLuminance => ColorGrading { - exposure: 0.5, + global: ColorGradingGlobal { + exposure: 0.5, + ..default() + }, ..default() }, Tonemapping::AcesFitted => ColorGrading { - exposure: 0.35, + global: ColorGradingGlobal { + exposure: 0.35, + ..default() + }, ..default() }, - Tonemapping::AgX => ColorGrading { - exposure: -0.2, - gamma: 1.0, - pre_saturation: 1.1, - post_saturation: 1.1, - }, + Tonemapping::AgX => ColorGrading::with_identical_sections( + ColorGradingGlobal { + exposure: -0.2, + post_saturation: 1.1, + ..default() + }, + ColorGradingSection { + saturation: 1.1, + ..default() + }, + ), _ => ColorGrading::default(), } } @@ -656,37 +602,6 @@ impl Default for PerMethodSettings { } } -/// Creates a colorful test pattern -fn uv_debug_texture() -> Image { - const TEXTURE_SIZE: usize = 8; - - let mut palette: [u8; 32] = [ - 255, 102, 159, 255, 255, 159, 102, 255, 236, 255, 102, 255, 121, 255, 102, 255, 102, 255, - 198, 255, 102, 198, 255, 255, 121, 102, 255, 255, 236, 102, 255, 255, - ]; - - let mut texture_data = [0; TEXTURE_SIZE * TEXTURE_SIZE * 4]; - for y in 0..TEXTURE_SIZE { - let offset = TEXTURE_SIZE * y * 4; - texture_data[offset..(offset + TEXTURE_SIZE * 4)].copy_from_slice(&palette); - palette.rotate_right(4); - } - - let mut img = Image::new_fill( - Extent3d { - width: TEXTURE_SIZE as u32, - height: TEXTURE_SIZE as u32, - depth_or_array_layers: 1, - }, - TextureDimension::D2, - &texture_data, - TextureFormat::Rgba8UnormSrgb, - RenderAssetUsages::RENDER_WORLD, - ); - img.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor::default()); - img -} - impl Material for ColorGradientMaterial { fn fragment_shader() -> ShaderRef { "shaders/tonemapping_test_patterns.wgsl".into() diff --git a/examples/3d/transmission.rs b/examples/3d/transmission.rs index e2cf9481ecb9c..b5fe128f9fe93 100644 --- a/examples/3d/transmission.rs +++ b/examples/3d/transmission.rs @@ -30,7 +30,7 @@ use bevy::{ prelude::*, render::{ camera::{Exposure, TemporalJitter}, - view::ColorGrading, + view::{ColorGrading, ColorGradingGlobal}, }, }; @@ -345,7 +345,10 @@ fn setup( }, transform: Transform::from_xyz(1.0, 1.8, 7.0).looking_at(Vec3::ZERO, Vec3::Y), color_grading: ColorGrading { - post_saturation: 1.2, + global: ColorGradingGlobal { + post_saturation: 1.2, + ..default() + }, ..default() }, tonemapping: Tonemapping::TonyMcMapface, diff --git a/examples/README.md b/examples/README.md index 9f0c3f51c7598..a4e9b95e1ce3a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -129,6 +129,7 @@ Example | Description [Anti-aliasing](../examples/3d/anti_aliasing.rs) | Compares different anti-aliasing methods [Atmospheric Fog](../examples/3d/atmospheric_fog.rs) | A scene showcasing the atmospheric fog effect [Blend Modes](../examples/3d/blend_modes.rs) | Showcases different blend modes +[Color grading](../examples/3d/color_grading.rs) | Demonstrates color grading [Deferred Rendering](../examples/3d/deferred_rendering.rs) | Renders meshes with both forward and deferred pipelines [Fog](../examples/3d/fog.rs) | A scene showcasing the distance fog effect [Generate Custom Mesh](../examples/3d/generate_custom_mesh.rs) | Simple showcase of how to generate a custom mesh with a custom texture From 1e6026020e03d6aa71ec1778dc0701d8b166375b Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Mon, 29 Apr 2024 21:06:34 -0700 Subject: [PATCH 2/3] Address review comments --- crates/bevy_render/src/view/mod.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/bevy_render/src/view/mod.rs b/crates/bevy_render/src/view/mod.rs index 87f010458fe2f..bf1d31a9eeca5 100644 --- a/crates/bevy_render/src/view/mod.rs +++ b/crates/bevy_render/src/view/mod.rs @@ -75,8 +75,16 @@ static LMS_TO_RGB: Mat3 = mat3( vec3(-0.130646, 0.00353630, 1.0605344), ); -/// The +/// The [CIE 1931] *xy* chromaticity coordinates of the [D65 white point]. +/// +/// [CIE 1931]: https://en.wikipedia.org/wiki/CIE_1931_color_space +/// [D65 white point]: https://en.wikipedia.org/wiki/Standard_illuminant#D65_values static D65_XY: Vec2 = vec2(0.31272, 0.32903); + +/// The [D65 white point] in [LMS color space]. +/// +/// [LMS color space]: https://en.wikipedia.org/wiki/LMS_color_space +/// [D65 white point]: https://en.wikipedia.org/wiki/Standard_illuminant#D65_values static D65_LMS: Vec3 = vec3(0.975538, 1.01648, 1.08475); pub struct ViewPlugin; @@ -171,8 +179,10 @@ impl ExtractedView { /// Configures filmic color grading parameters to adjust the image appearance. /// -/// Grading is applied just before/after tonemapping for a given -/// [`Camera`](crate::camera::Camera) entity. +/// Color grading is applied just before tonemapping for a given +/// [`Camera`](crate::camera::Camera) entity, with the sole exception of the +/// `post_saturation` value in [`ColorGradingGlobal`], which is applied after +/// tonemapping. #[derive(Component, Reflect, Debug, Default, Clone)] #[reflect(Component, Default)] pub struct ColorGrading { @@ -296,7 +306,7 @@ pub struct ColorGradingSection { /// A linear luminance adjustment, mainly affecting the middle part of the /// range. /// - /// This is the *s* exponent in the standard [ASC CDL] formula for color + /// This is the *s* factor in the standard [ASC CDL] formula for color /// correction: /// /// out = (i × s + o)ⁿ From 82fee54cb72290c7729a62c68baf78333c842275 Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Wed, 1 May 2024 18:44:28 -0700 Subject: [PATCH 3/3] Doc check police --- crates/bevy_render/src/view/mod.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/bevy_render/src/view/mod.rs b/crates/bevy_render/src/view/mod.rs index bf1d31a9eeca5..dbe8779c6707f 100644 --- a/crates/bevy_render/src/view/mod.rs +++ b/crates/bevy_render/src/view/mod.rs @@ -46,15 +46,19 @@ pub const VIEW_TYPE_HANDLE: Handle = Handle::weak_from_u128(154213739044 /// /// To derive this, first we convert from RGB to [CIE 1931 XYZ]: /// -/// ⎡ X ⎤ ⎡ 0.490 0.310 0.200 ⎤ ⎡ R ⎤ -/// ⎢ Y ⎥ = ⎢ 0.177 0.812 0.011 ⎥ ⎢ G ⎥ -/// ⎣ Z ⎦ ⎣ 0.000 0.010 0.990 ⎦ ⎣ B ⎦ +/// ```text +/// ⎡ X ⎤ ⎡ 0.490 0.310 0.200 ⎤ ⎡ R ⎤ +/// ⎢ Y ⎥ = ⎢ 0.177 0.812 0.011 ⎥ ⎢ G ⎥ +/// ⎣ Z ⎦ ⎣ 0.000 0.010 0.990 ⎦ ⎣ B ⎦ +/// ``` /// /// Then we convert to LMS according to the [CAM16 standard matrix]: /// -/// ⎡ L ⎤ ⎡ 0.401 0.650 -0.051 ⎤ ⎡ X ⎤ -/// ⎢ M ⎥ = ⎢ -0.250 1.204 0.046 ⎥ ⎢ Y ⎥ -/// ⎣ S ⎦ ⎣ -0.002 0.049 0.953 ⎦ ⎣ Z ⎦ +/// ```text +/// ⎡ L ⎤ ⎡ 0.401 0.650 -0.051 ⎤ ⎡ X ⎤ +/// ⎢ M ⎥ = ⎢ -0.250 1.204 0.046 ⎥ ⎢ Y ⎥ +/// ⎣ S ⎦ ⎣ -0.002 0.049 0.953 ⎦ ⎣ Z ⎦ +/// ``` /// /// The resulting matrix is just the concatenation of these two matrices, to do /// the conversion in one step. @@ -298,7 +302,9 @@ pub struct ColorGradingSection { /// This is the *n* exponent in the standard [ASC CDL] formula for color /// correction: /// - /// out = (i × s + o)ⁿ + /// ```text + /// out = (i × s + o)ⁿ + /// ``` /// /// [ASC CDL]: https://en.wikipedia.org/wiki/ASC_CDL#Combined_Function pub gamma: f32, @@ -309,7 +315,9 @@ pub struct ColorGradingSection { /// This is the *s* factor in the standard [ASC CDL] formula for color /// correction: /// - /// out = (i × s + o)ⁿ + /// ```text + /// out = (i × s + o)ⁿ + /// ``` /// /// [ASC CDL]: https://en.wikipedia.org/wiki/ASC_CDL#Combined_Function pub gain: f32, @@ -320,7 +328,9 @@ pub struct ColorGradingSection { /// This is the *o* term in the standard [ASC CDL] formula for color /// correction: /// - /// out = (i × s + o)ⁿ + /// ```text + /// out = (i × s + o)ⁿ + /// ``` /// /// [ASC CDL]: https://en.wikipedia.org/wiki/ASC_CDL#Combined_Function pub lift: f32,