bevy_core_pipeline/auto_exposure/
compensation_curve.rs

1use bevy_asset::prelude::*;
2use bevy_ecs::system::{lifetimeless::SRes, SystemParamItem};
3use bevy_math::{cubic_splines::CubicGenerator, FloatExt, Vec2};
4use bevy_reflect::prelude::*;
5use bevy_render::{
6    render_asset::{RenderAsset, RenderAssetUsages},
7    render_resource::{
8        Extent3d, ShaderType, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages,
9        TextureView, UniformBuffer,
10    },
11    renderer::{RenderDevice, RenderQueue},
12};
13use derive_more::derive::{Display, Error};
14
15const LUT_SIZE: usize = 256;
16
17/// An auto exposure compensation curve.
18/// This curve is used to map the average log luminance of a scene to an
19/// exposure compensation value, to allow for fine control over the final exposure.
20#[derive(Asset, Reflect, Debug, Clone)]
21#[reflect(Default)]
22pub struct AutoExposureCompensationCurve {
23    /// The minimum log luminance value in the curve. (the x-axis)
24    min_log_lum: f32,
25    /// The maximum log luminance value in the curve. (the x-axis)
26    max_log_lum: f32,
27    /// The minimum exposure compensation value in the curve. (the y-axis)
28    min_compensation: f32,
29    /// The maximum exposure compensation value in the curve. (the y-axis)
30    max_compensation: f32,
31    /// The lookup table for the curve. Uploaded to the GPU as a 1D texture.
32    /// Each value in the LUT is a `u8` representing a normalized exposure compensation value:
33    /// * `0` maps to `min_compensation`
34    /// * `255` maps to `max_compensation`
35    ///
36    /// The position in the LUT corresponds to the normalized log luminance value.
37    /// * `0` maps to `min_log_lum`
38    /// * `LUT_SIZE - 1` maps to `max_log_lum`
39    lut: [u8; LUT_SIZE],
40}
41
42/// Various errors that can occur when constructing an [`AutoExposureCompensationCurve`].
43#[derive(Error, Display, Debug)]
44pub enum AutoExposureCompensationCurveError {
45    /// The curve couldn't be built in the first place.
46    #[display("curve could not be constructed from the given data")]
47    InvalidCurve,
48    /// A discontinuity was found in the curve.
49    #[display("discontinuity found between curve segments")]
50    DiscontinuityFound,
51    /// The curve is not monotonically increasing on the x-axis.
52    #[display("curve is not monotonically increasing on the x-axis")]
53    NotMonotonic,
54}
55
56impl Default for AutoExposureCompensationCurve {
57    fn default() -> Self {
58        Self {
59            min_log_lum: 0.0,
60            max_log_lum: 0.0,
61            min_compensation: 0.0,
62            max_compensation: 0.0,
63            lut: [0; LUT_SIZE],
64        }
65    }
66}
67
68impl AutoExposureCompensationCurve {
69    const SAMPLES_PER_SEGMENT: usize = 64;
70
71    /// Build an [`AutoExposureCompensationCurve`] from a [`CubicGenerator<Vec2>`], where:
72    /// - x represents the average log luminance of the scene in EV-100;
73    /// - y represents the exposure compensation value in F-stops.
74    ///
75    /// # Errors
76    ///
77    /// If the curve is not monotonically increasing on the x-axis,
78    /// returns [`AutoExposureCompensationCurveError::NotMonotonic`].
79    ///
80    /// If a discontinuity is found between curve segments,
81    /// returns [`AutoExposureCompensationCurveError::DiscontinuityFound`].
82    ///
83    /// # Example
84    ///
85    /// ```
86    /// # use bevy_asset::prelude::*;
87    /// # use bevy_math::vec2;
88    /// # use bevy_math::cubic_splines::*;
89    /// # use bevy_core_pipeline::auto_exposure::AutoExposureCompensationCurve;
90    /// # let mut compensation_curves = Assets::<AutoExposureCompensationCurve>::default();
91    /// let curve: Handle<AutoExposureCompensationCurve> = compensation_curves.add(
92    ///     AutoExposureCompensationCurve::from_curve(LinearSpline::new([
93    ///         vec2(-4.0, -2.0),
94    ///         vec2(0.0, 0.0),
95    ///         vec2(2.0, 0.0),
96    ///         vec2(4.0, 2.0),
97    ///     ]))
98    ///     .unwrap()
99    /// );
100    /// ```
101    pub fn from_curve<T>(curve: T) -> Result<Self, AutoExposureCompensationCurveError>
102    where
103        T: CubicGenerator<Vec2>,
104    {
105        let Ok(curve) = curve.to_curve() else {
106            return Err(AutoExposureCompensationCurveError::InvalidCurve);
107        };
108
109        let min_log_lum = curve.position(0.0).x;
110        let max_log_lum = curve.position(curve.segments().len() as f32).x;
111        let log_lum_range = max_log_lum - min_log_lum;
112
113        let mut lut = [0.0; LUT_SIZE];
114
115        let mut previous = curve.position(0.0);
116        let mut min_compensation = previous.y;
117        let mut max_compensation = previous.y;
118
119        for segment in curve {
120            if segment.position(0.0) != previous {
121                return Err(AutoExposureCompensationCurveError::DiscontinuityFound);
122            }
123
124            for i in 1..Self::SAMPLES_PER_SEGMENT {
125                let current = segment.position(i as f32 / (Self::SAMPLES_PER_SEGMENT - 1) as f32);
126
127                if current.x < previous.x {
128                    return Err(AutoExposureCompensationCurveError::NotMonotonic);
129                }
130
131                // Find the range of LUT entries that this line segment covers.
132                let (lut_begin, lut_end) = (
133                    ((previous.x - min_log_lum) / log_lum_range) * (LUT_SIZE - 1) as f32,
134                    ((current.x - min_log_lum) / log_lum_range) * (LUT_SIZE - 1) as f32,
135                );
136                let lut_inv_range = 1.0 / (lut_end - lut_begin);
137
138                // Iterate over all LUT entries whose pixel centers fall within the current segment.
139                #[allow(clippy::needless_range_loop)]
140                for i in lut_begin.ceil() as usize..=lut_end.floor() as usize {
141                    let t = (i as f32 - lut_begin) * lut_inv_range;
142                    lut[i] = previous.y.lerp(current.y, t);
143                    min_compensation = min_compensation.min(lut[i]);
144                    max_compensation = max_compensation.max(lut[i]);
145                }
146
147                previous = current;
148            }
149        }
150
151        let compensation_range = max_compensation - min_compensation;
152
153        Ok(Self {
154            min_log_lum,
155            max_log_lum,
156            min_compensation,
157            max_compensation,
158            lut: if compensation_range > 0.0 {
159                let scale = 255.0 / compensation_range;
160                lut.map(|f: f32| ((f - min_compensation) * scale) as u8)
161            } else {
162                [0; LUT_SIZE]
163            },
164        })
165    }
166}
167
168/// The GPU-representation of an [`AutoExposureCompensationCurve`].
169/// Consists of a [`TextureView`] with the curve's data,
170/// and a [`UniformBuffer`] with the curve's extents.
171pub struct GpuAutoExposureCompensationCurve {
172    pub(super) texture_view: TextureView,
173    pub(super) extents: UniformBuffer<AutoExposureCompensationCurveUniform>,
174}
175
176#[derive(ShaderType, Clone, Copy)]
177pub(super) struct AutoExposureCompensationCurveUniform {
178    min_log_lum: f32,
179    inv_log_lum_range: f32,
180    min_compensation: f32,
181    compensation_range: f32,
182}
183
184impl RenderAsset for GpuAutoExposureCompensationCurve {
185    type SourceAsset = AutoExposureCompensationCurve;
186    type Param = (SRes<RenderDevice>, SRes<RenderQueue>);
187
188    fn asset_usage(_: &Self::SourceAsset) -> RenderAssetUsages {
189        RenderAssetUsages::RENDER_WORLD
190    }
191
192    fn prepare_asset(
193        source: Self::SourceAsset,
194        (render_device, render_queue): &mut SystemParamItem<Self::Param>,
195    ) -> Result<Self, bevy_render::render_asset::PrepareAssetError<Self::SourceAsset>> {
196        let texture = render_device.create_texture_with_data(
197            render_queue,
198            &TextureDescriptor {
199                label: None,
200                size: Extent3d {
201                    width: LUT_SIZE as u32,
202                    height: 1,
203                    depth_or_array_layers: 1,
204                },
205                mip_level_count: 1,
206                sample_count: 1,
207                dimension: TextureDimension::D1,
208                format: TextureFormat::R8Unorm,
209                usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING,
210                view_formats: &[TextureFormat::R8Unorm],
211            },
212            Default::default(),
213            &source.lut,
214        );
215
216        let texture_view = texture.create_view(&Default::default());
217
218        let mut extents = UniformBuffer::from(AutoExposureCompensationCurveUniform {
219            min_log_lum: source.min_log_lum,
220            inv_log_lum_range: 1.0 / (source.max_log_lum - source.min_log_lum),
221            min_compensation: source.min_compensation,
222            compensation_range: source.max_compensation - source.min_compensation,
223        });
224
225        extents.write_buffer(render_device, render_queue);
226
227        Ok(GpuAutoExposureCompensationCurve {
228            texture_view,
229            extents,
230        })
231    }
232}