Skip to main content

bevy_light/
atmosphere.rs

1//! Provides types to specify atmosphere lighting, scattering terms, etc.
2
3use alloc::{borrow::Cow, sync::Arc};
4use bevy_asset::{Asset, AssetEvent, AssetId, Handle};
5use bevy_color::{ColorToComponents, Gray, LinearRgba};
6use bevy_curve::Curve;
7use bevy_curve::{FunctionCurve, Interval, SampleAutoCurve};
8use bevy_ecs::{
9    component::Component,
10    lifecycle::HookContext,
11    message::MessageReader,
12    system::{Res, ResMut},
13    template::FromTemplate,
14    world::DeferredWorld,
15};
16use bevy_image::Image;
17use bevy_math::{ops, FloatPow, Vec3};
18use bevy_platform::collections::HashSet;
19use bevy_reflect::TypePath;
20use bevy_transform::components::GlobalTransform;
21use core::f32::{self, consts::PI};
22use smallvec::SmallVec;
23use wgpu_types::TextureFormat;
24
25/// Atmosphere for one planet. The entity's [`GlobalTransform`] is the planet center in world space.
26///
27/// Add `AtmosphereSettings` to each 3D camera that should use it, the nearest atmosphere is used for rendering.
28///
29/// If [`GlobalTransform`] is still [`Default`] when this component is first added, it is placed `radius` units directly below the origin on the `Y` axis, so that the planet's normal is roughly `Vec3::Y` around the origin, likely where your camera/scene is located. Unless you're making a game set in space, this is probably what you want. Otherwise, feel free to override this default by setting a transform manually.
30///
31/// The scale on [`GlobalTransform`] rescales the planet in world space. Tune it with the radius offset
32/// when your scene uses other units, like kilometer-sized scenes.
33/// The rotation is ignored because the atmosphere is spherically symmetric.
34#[derive(Clone, Component, FromTemplate)]
35#[require(GlobalTransform)]
36#[component(on_add = set_default_transform)]
37pub struct Atmosphere {
38    /// Radius of the planet
39    ///
40    /// units: m
41    pub inner_radius: f32,
42
43    /// Radius at which we consider the atmosphere to 'end' for our
44    /// calculations (from center of planet)
45    ///
46    /// units: m
47    pub outer_radius: f32,
48
49    /// An approximation of the average albedo (or color, roughly) of the
50    /// planet's surface. This is used when calculating multiscattering.
51    ///
52    /// units: N/A
53    pub ground_albedo: Vec3,
54
55    /// A handle to a [`ScatteringMedium`], which describes the substance
56    /// of the atmosphere and how it scatters light.
57    pub medium: Handle<ScatteringMedium>,
58}
59
60fn set_default_transform(mut world: DeferredWorld<'_>, HookContext { entity, .. }: HookContext) {
61    let Some(inner_radius) = world.get::<Atmosphere>(entity).map(|a| a.inner_radius) else {
62        unreachable!("on_add hooks guarantee the component is present");
63    };
64
65    if let Some(mut transform) = world.get_mut::<GlobalTransform>(entity)
66        && *transform == GlobalTransform::default()
67    {
68        *transform = GlobalTransform::from_translation(-Vec3::Y * inner_radius);
69    }
70}
71
72impl Atmosphere {
73    /// An atmosphere like that of earth. Use this with a [`ScatteringMedium::earth`] handle.
74    pub fn earth(medium: Handle<ScatteringMedium>) -> Self {
75        const EARTH_INNER_RADIUS: f32 = 6_360_000.0;
76        const EARTH_OUTER_RADIUS: f32 = 6_460_000.0;
77        const EARTH_ALBEDO: Vec3 = Vec3::splat(0.3);
78        Self {
79            inner_radius: EARTH_INNER_RADIUS,
80            outer_radius: EARTH_OUTER_RADIUS,
81            ground_albedo: EARTH_ALBEDO,
82            medium,
83        }
84    }
85
86    /// Martian atmosphere; use this with a [`ScatteringMedium::mars`] handle.
87    ///
88    /// Mean radius 3389.50 ± 0.2 km [Seidelmann et al. 2007, Table 4].
89    ///
90    /// [Seidelmann et al. 2007, Table 4]: https://doi.org/10.1007/s10569-007-9072-y
91    pub fn mars(medium: Handle<ScatteringMedium>) -> Self {
92        const MARS_INNER_RADIUS: f32 = 3_389_500.0;
93        const MARS_OUTER_RADIUS: f32 = 3_509_500.0;
94        const MARS_ALBEDO: Vec3 = Vec3::splat(0.1);
95        Self {
96            inner_radius: MARS_INNER_RADIUS,
97            outer_radius: MARS_OUTER_RADIUS,
98            ground_albedo: MARS_ALBEDO,
99            medium,
100        }
101    }
102}
103
104/// An asset that defines how a material scatters light.
105///
106/// In order to calculate how light passes through a medium,
107/// you need three pieces of information:
108/// - how much light the medium *absorbs* per unit length
109/// - how much light the medium *scatters* per unit length
110/// - what *directions* the medium is likely to scatter light in.
111///
112/// The first two are fairly simple, and are sometimes referred to together
113/// (accurately enough) as the medium's [optical density].
114///
115/// The last, defined by a [phase function], is the most important in creating
116/// the look of a medium. Our brains are very good at noticing (if unconsciously)
117/// that a dust storm scatters light differently than a rain cloud, for example.
118/// See the docs on [`PhaseFunction`] for more info.
119///
120/// In reality, media are often composed of multiple elements that scatter light
121/// independently, for Earth's atmosphere is composed of the gas itself, but also
122/// suspended dust and particulate. These each scatter light differently, and are
123/// distributed in different amounts at different altitudes. In a [`ScatteringMedium`],
124/// these are each represented by a [`ScatteringTerm`]
125///
126/// ## Technical Details
127///
128/// A [`ScatteringMedium`] is represented on the GPU by a set of two LUTs, which
129/// are re-created every time the asset is modified. See the docs on
130/// `bevy_pbr::GpuScatteringMedium` for more info.
131///
132/// [optical density]: https://en.wikipedia.org/wiki/Optical_Density
133/// [phase function]: https://www.pbr-book.org/4ed/Volume_Scattering/Phase_Functions
134#[derive(TypePath, Asset, Clone)]
135pub struct ScatteringMedium {
136    /// An optional label for the medium, used when creating the LUTs on the GPU.
137    pub label: Option<Cow<'static, str>>,
138    /// The resolution at which to sample the falloff distribution of each
139    /// scattering term. Custom or more detailed distributions may benefit
140    /// from a higher value, at the cost of more memory use.
141    pub falloff_resolution: u32,
142    /// The resolution at which to sample the phase function of each scattering
143    /// term. Custom or more detailed phase functions may benefit from a higher
144    /// value, at the cost of more memory use.
145    pub phase_resolution: u32,
146    /// The list of [`ScatteringTerm`]s that compose this [`ScatteringMedium`]
147    pub terms: SmallVec<[ScatteringTerm; 1]>,
148}
149
150impl Default for ScatteringMedium {
151    fn default() -> Self {
152        ScatteringMedium::earth(256, 256)
153    }
154}
155
156impl ScatteringMedium {
157    /// Returns a scattering medium with a default label and the
158    /// specified scattering terms.
159    pub fn new(
160        falloff_resolution: u32,
161        phase_resolution: u32,
162        terms: impl IntoIterator<Item = ScatteringTerm>,
163    ) -> Self {
164        Self {
165            label: None,
166            falloff_resolution,
167            phase_resolution,
168            terms: terms.into_iter().collect(),
169        }
170    }
171
172    /// Consumes and returns this scattering medium with a new label.
173    pub fn with_label(self, label: impl Into<Cow<'static, str>>) -> Self {
174        Self {
175            label: Some(label.into()),
176            ..self
177        }
178    }
179
180    /// Consumes and returns this scattering medium with each scattering terms'
181    /// densities multiplied by `multiplier`.
182    pub fn with_density_multiplier(mut self, multiplier: f32) -> Self {
183        self.terms.iter_mut().for_each(|term| {
184            term.absorption *= multiplier;
185            term.scattering *= multiplier;
186        });
187
188        self
189    }
190
191    /// Returns a scattering medium representing an earth atmosphere.
192    ///
193    /// Uses physically-based scale heights from Earth's atmosphere, assuming
194    /// a 60 km atmosphere height:
195    /// - Rayleigh (molecular) scattering: 8 km scale height
196    /// - Mie (aerosol) scattering: 1.2 km scale height
197    pub fn earth(falloff_resolution: u32, phase_resolution: u32) -> Self {
198        Self::new(
199            falloff_resolution,
200            phase_resolution,
201            [
202                // Rayleigh scattering Term
203                ScatteringTerm {
204                    absorption: Vec3::ZERO,
205                    scattering: Vec3::new(5.802e-6, 13.558e-6, 33.100e-6),
206                    falloff: Falloff::Exponential { scale: 8.0 / 60.0 },
207                    phase: PhaseFunction::Rayleigh,
208                },
209                // Mie scattering Term
210                ScatteringTerm {
211                    absorption: Vec3::splat(3.996e-6),
212                    scattering: Vec3::splat(0.444e-6),
213                    falloff: Falloff::Exponential { scale: 1.2 / 60.0 },
214                    phase: PhaseFunction::Mie { asymmetry: 0.8 },
215                },
216                // Ozone scattering Term
217                ScatteringTerm {
218                    absorption: Vec3::new(0.650e-6, 1.881e-6, 0.085e-6),
219                    scattering: Vec3::ZERO,
220                    falloff: Falloff::Tent {
221                        center: 0.75,
222                        width: 0.3,
223                    },
224                    phase: PhaseFunction::Isotropic,
225                },
226            ],
227        )
228        .with_label("earth_atmosphere")
229    }
230
231    /// Returns a scattering medium representing a Martian atmosphere [Schneegans et al. 2024].
232    ///
233    /// Constituents:
234    /// - Rayleigh: carbon dioxide
235    /// - Dust: wavelength-dependent Mie phase, double-exponential density
236    ///
237    /// Requires an Nx1 chromatic phase texture for the dust term.
238    ///
239    /// [Schneegans et al. 2024]: https://doi.org/10.1111/cgf.15010
240    pub fn mars(falloff_resolution: u32, phase_resolution: u32, dust_phase: Handle<Image>) -> Self {
241        const MARS_ATMOSPHERE_HEIGHT: f32 = 120_000.0;
242        const RAYLEIGH_SCALE_HEIGHT: f32 = 8_000.0;
243
244        // Dust density, from Fig. 8.
245        let dust_falloff = Falloff::from_curve(FunctionCurve::new(Interval::UNIT, |p| {
246            let h = (1.0 - p) * MARS_ATMOSPHERE_HEIGHT;
247            0.75 * ops::exp(1.0 - ops::exp(h / 4_000.0))
248                + 0.25 * ops::exp(1.0 - ops::exp(h / 20_000.0))
249        }));
250
251        Self::new(
252            falloff_resolution,
253            phase_resolution,
254            [
255                ScatteringTerm {
256                    // Table 1: Eq. 3 with delta=0.09, refractive index=1.00000337
257                    absorption: Vec3::ZERO,
258                    scattering: Vec3::new(9.91e-8, 2.32e-7, 5.65e-7),
259                    falloff: Falloff::Exponential {
260                        scale: RAYLEIGH_SCALE_HEIGHT / MARS_ATMOSPHERE_HEIGHT,
261                    },
262                    phase: PhaseFunction::Rayleigh,
263                },
264                ScatteringTerm {
265                    // Table 1: number density=5×10^9 m^-3, Mie Theory
266                    absorption: Vec3::new(1.26e-6, 5.25e-6, 9.33e-6), // beta_abs per channel
267                    scattering: Vec3::new(30.67e-6, 25.39e-6, 20.93e-6), // beta_sca per channel
268                    falloff: dust_falloff,
269                    phase: PhaseFunction::from_chromatic_texture(dust_phase),
270                },
271            ],
272        )
273        .with_label("mars_atmosphere")
274    }
275}
276
277/// An individual element of a [`ScatteringMedium`].
278///
279/// A [`ScatteringMedium`] can be built out of a number of simpler [`ScatteringTerm`]s,
280/// which correspond to an individual element of the medium. For example, Earth's
281/// atmosphere would be (roughly) composed of two [`ScatteringTerm`]s: the atmospheric
282/// gases themselves, which extend to the edge of space, and suspended dust particles,
283/// which are denser but lie closer to the ground.
284#[derive(Default, Clone)]
285pub struct ScatteringTerm {
286    /// This term's optical absorption density, or how much light of each wavelength
287    /// it absorbs per meter.
288    ///
289    /// units: m^-1
290    pub absorption: Vec3,
291    /// This term's optical scattering density, or how much light of each wavelength
292    /// it scatters per meter.
293    ///
294    /// units: m^-1
295    pub scattering: Vec3,
296    /// This term's falloff distribution. See the docs on [`Falloff`] for more info.
297    pub falloff: Falloff,
298    /// This term's [phase function], which determines the character of how it
299    /// scatters light. See the docs on [`PhaseFunction`] for more info.
300    ///
301    /// [phase function]: https://www.pbr-book.org/4ed/Volume_Scattering/Phase_Functions
302    pub phase: PhaseFunction,
303}
304
305/// Describes how the media in a [`ScatteringTerm`] is distributed.
306///
307/// This is closely related to the optical density values [`ScatteringTerm::absorption`] and
308/// [`ScatteringTerm::scattering`]. Most media aren't the same density everywhere;
309/// near the edge of space Earth's atmosphere is much less dense, and it absorbs
310/// and scatters less light.
311///
312/// [`Falloff`] determines how the density of a medium changes as a function of
313/// an abstract "falloff parameter" `p`. `p = 1` denotes where the medium is the
314/// densest, i.e. at the surface of the Earth, `p = 0` denotes where the medium
315/// fades away completely, i.e. at the edge of space, and values between scale
316/// linearly with distance, so `p = 0.5` would be halfway between the surface
317/// and the edge of space.
318///
319/// When processing a [`ScatteringMedium`], the `absorption` and `scattering` values
320/// for each [`ScatteringTerm`] are multiplied by the value of the falloff function, `f(p)`.
321#[derive(Default, Clone)]
322pub enum Falloff {
323    /// A simple linear falloff function, which essentially
324    /// passes the falloff parameter through unchanged.
325    ///
326    /// f(1) = 1
327    /// f(0) = 0
328    /// f(p) = p
329    #[default]
330    Linear,
331    /// An exponential falloff function parametrized by a proportional scale.
332    /// When paired with an absolute "falloff distance" like the distance from
333    /// Earth's surface to the edge of space, this is analogous to the "height
334    /// scale" value common in atmospheric scattering literature, though it will
335    /// diverge from this for large or negative `scale` values.
336    ///
337    /// f(1) = 1
338    /// f(0) = 0
339    /// f(p) = (e^((1-p)/s) - e^(1/s))/(e - e^(1/s))
340    Exponential {
341        /// The "scale" of the exponential falloff. Values closer to zero will
342        /// produce steeper falloff, and values farther from zero will produce
343        /// gentler falloff, approaching linear falloff as scale goes to `+-∞`.
344        ///
345        /// Negative values change the *concavity* of the falloff function:
346        /// rather than an initial narrow region of steep falloff followed by a
347        /// wide region of gentle falloff, there will be an initial wide region
348        /// of gentle falloff followed by a narrow region of steep falloff.
349        ///
350        /// domain: (-∞, ∞)
351        ///
352        /// NOTE, this function is not defined when `scale == 0`.
353        /// In that case, it will fall back to linear falloff.
354        scale: f32,
355    },
356    /// A tent-shaped falloff function, which produces a triangular
357    /// peak at the center and linearly falls off to either side.
358    ///
359    /// f(`center`) = 1
360    /// f(`center` +- `width` / 2) = 0
361    Tent {
362        /// The center of the tent function peak
363        ///
364        /// domain: [0, 1]
365        center: f32,
366        /// The total width of the tent function peak
367        ///
368        /// domain: [0, 1]
369        width: f32,
370    },
371    /// A falloff function defined by a custom curve.
372    ///
373    /// domain: [0, 1],
374    /// range: [0, 1],
375    Curve(Arc<dyn Curve<f32> + Send + Sync>),
376}
377
378impl Falloff {
379    /// Returns a falloff function corresponding to a custom curve.
380    pub fn from_curve(curve: impl Curve<f32> + Send + Sync + 'static) -> Self {
381        Self::Curve(Arc::new(curve))
382    }
383
384    /// Evaluates the falloff function at the given coordinate.
385    pub fn sample(&self, p: f32) -> f32 {
386        match self {
387            Falloff::Linear => p,
388            Falloff::Exponential { scale } => {
389                // fill discontinuity at scale == 0,
390                // arbitrarily choose linear falloff
391                if *scale == 0.0 {
392                    p
393                } else {
394                    let s = -1.0 / scale;
395                    let exp_p_s = ops::exp((1.0 - p) * s);
396                    let exp_s = ops::exp(s);
397                    (exp_p_s - exp_s) / (1.0 - exp_s)
398                }
399            }
400            Falloff::Tent { center, width } => (1.0 - (p - center).abs() / (0.5 * width)).max(0.0),
401            Falloff::Curve(curve) => curve.sample(p).unwrap_or(0.0),
402        }
403    }
404}
405
406/// Describes how a [`ScatteringTerm`] scatters light in different directions.
407///
408/// A [phase function] is a function `f: [-1, 1] -> [0, ∞)`, symmetric about `x=0`
409/// whose input is the cosine of the angle between an incoming light direction and
410/// and outgoing light direction, and whose output is the proportion of the incoming
411/// light that is actually scattered in that direction.
412///
413/// The phase function has an important effect on the "look" of a medium in a scene.
414/// Media consisting of particles of a different size or shape scatter light differently,
415/// and our brains are very good at telling the difference. A dust cloud, which might
416/// correspond roughly to `PhaseFunction::Mie { asymmetry: 0.8 }`, looks quite different
417/// from the rest of the sky (atmospheric gases), which correspond to `PhaseFunction::Rayleigh`
418///
419/// [phase function]: https://www.pbr-book.org/4ed/Volume_Scattering/Phase_Functions
420#[derive(Clone)]
421pub enum PhaseFunction {
422    /// A phase function that scatters light evenly in all directions.
423    Isotropic,
424
425    /// A phase function representing [Rayleigh scattering].
426    ///
427    /// Rayleigh scattering occurs naturally for particles much smaller than
428    /// the wavelengths of visible light, such as gas molecules in the atmosphere.
429    /// It's generally wavelength-dependent, where shorter wavelengths are scattered
430    /// more strongly, so [scattering](ScatteringTerm::scattering) should have
431    /// higher values for blue than green and green than red. Particles that
432    /// participate in Rayleigh scattering don't absorb any light, either.
433    ///
434    /// [Rayleigh scattering]: https://en.wikipedia.org/wiki/Rayleigh_scattering
435    Rayleigh,
436
437    /// The [Henyey-Greenstein phase function], which approximates [Mie scattering].
438    ///
439    /// Mie scattering occurs naturally for spherical particles of dust
440    /// and aerosols roughly the same size as the wavelengths of visible light,
441    /// so it's useful for representing dust or sea spray. It's generally
442    /// wavelength-independent, so [absorption](ScatteringTerm::absorption)
443    /// and [scattering](ScatteringTerm::scattering) should be set to a greyscale value.
444    ///
445    /// [Mie scattering]: https://en.wikipedia.org/wiki/Mie_scattering
446    /// [Henyey-Greenstein phase function]: https://www.oceanopticsbook.info/view/scattering/level-2/the-henyey-greenstein-phase-function
447    Mie {
448        /// Whether the Mie scattering function is biased towards scattering
449        /// light forwards (asymmetry > 0) or backwards (asymmetry < 0).
450        ///
451        /// domain: [-1, 1]
452        asymmetry: f32,
453    },
454
455    /// A phase function defined by a custom curve, where the input
456    /// is the cosine of the angle between the incoming light ray
457    /// and the scattered light ray, and the output is the fraction
458    /// of the incoming light scattered in that direction.
459    ///
460    /// Note: it's important for photorealism that the phase function
461    /// be *energy conserving*, meaning that in total no more light can
462    /// be scattered than actually entered the medium. For this to be
463    /// the case, the integral of the phase function over its domain must
464    /// be equal to 1/2π.
465    ///
466    ///   1
467    /// ∫   p(x) dx = 1/2π
468    ///  -1
469    ///
470    /// domain: [-1, 1]
471    /// range: [0, 1]
472    Curve(Arc<dyn Curve<f32> + Send + Sync>),
473
474    /// A wavelength-dependent (chromatic) phase function returning linear RGB
475    /// phase values per channel. Used when the phase varies with wavelength,
476    /// for instance Mie scattering on Martian dust.
477    ///
478    /// Energy conservation applies per channel. Each of the channels must independently
479    /// satisfy the equation above.
480    ///
481    /// domain: [-1, 1]
482    /// range: [0, 1] per channel (R, G, B)
483    ChromaticCurve(Arc<dyn Curve<LinearRgba> + Send + Sync>),
484
485    /// A chromatic phase function sampled from an N×1 texture (R,G,B per column).
486    ///
487    /// Use `Rgba32Float` format. Columns map linearly to cos θ. The LUT spans the
488    /// scattering hemisphere: first column is back-scattering (θ = π), last is
489    /// forward-scattering (θ = 0).
490    /// Resolved to [`PhaseFunction::ChromaticCurve`] when the image loads.
491    ///
492    /// To generate your own, compute the phase function using Mie theory (for example,
493    /// using the `miepython` package) and write it as a 32-bit float texture (`OpenEXR` or `KTX2`).
494    ChromaticTexture(Handle<Image>),
495}
496
497impl PhaseFunction {
498    /// A phase function defined by a custom curve.
499    pub fn from_curve(curve: impl Curve<f32> + Send + Sync + 'static) -> Self {
500        Self::Curve(Arc::new(curve))
501    }
502
503    /// A wavelength-dependent phase function from a curve that returns linear RGBA.
504    pub fn from_chromatic_curve(curve: impl Curve<LinearRgba> + Send + Sync + 'static) -> Self {
505        Self::ChromaticCurve(Arc::new(curve))
506    }
507
508    /// A chromatic phase function from an N×1 texture. Resolved to a curve when loaded.
509    pub fn from_chromatic_texture(image: Handle<Image>) -> Self {
510        Self::ChromaticTexture(image)
511    }
512
513    /// Samples the phase function at the given value in [-1, 1].
514    ///
515    /// Returns `Some(LinearRgba)` with per-channel phase values (scalar phases use R=G=B).
516    /// Returns `None` when the phase is not yet available (e.g. [`PhaseFunction::ChromaticTexture`] before load).
517    pub fn sample(&self, neg_l_dot_v: f32) -> Option<LinearRgba> {
518        const FRAC_4_PI: f32 = 0.25 / PI;
519        const FRAC_3_16_PI: f32 = 0.1875 / PI;
520        match self {
521            PhaseFunction::Isotropic => Some(LinearRgba::gray(FRAC_4_PI)),
522            PhaseFunction::Rayleigh => Some(LinearRgba::gray(
523                FRAC_3_16_PI * (1.0 + neg_l_dot_v * neg_l_dot_v),
524            )),
525            PhaseFunction::Mie { asymmetry } => {
526                let denom = 1.0 + asymmetry.squared() - 2.0 * asymmetry * neg_l_dot_v;
527                Some(LinearRgba::from_vec3(Vec3::splat(
528                    FRAC_4_PI * (1.0 - asymmetry.squared()) / (denom * denom.sqrt()),
529                )))
530            }
531            PhaseFunction::Curve(curve) => curve
532                .sample(neg_l_dot_v)
533                .map(LinearRgba::gray)
534                .or(Some(LinearRgba::gray(0.0))),
535            PhaseFunction::ChromaticCurve(curve) => {
536                curve.sample(neg_l_dot_v).or(Some(LinearRgba::gray(0.0)))
537            }
538            PhaseFunction::ChromaticTexture(_) => None,
539        }
540    }
541}
542
543impl Default for PhaseFunction {
544    fn default() -> Self {
545        Self::Mie { asymmetry: 0.8 }
546    }
547}
548
549/// Resolves [`PhaseFunction::ChromaticTexture`] to [`PhaseFunction::ChromaticCurve`] when the image loads.
550pub fn extract_chromatic_phase_textures(
551    mut reader: MessageReader<AssetEvent<Image>>,
552    images: Res<bevy_asset::Assets<Image>>,
553    mut scattering_media: ResMut<bevy_asset::Assets<ScatteringMedium>>,
554) {
555    let extract_ids: HashSet<AssetId<Image>> = scattering_media
556        .iter()
557        .flat_map(|(_, m)| m.terms.iter())
558        .filter_map(|t| {
559            let PhaseFunction::ChromaticTexture(h) = &t.phase else {
560                return None;
561            };
562            Some(h.id())
563        })
564        .collect();
565
566    for event in reader.read() {
567        let AssetEvent::LoadedWithDependencies { id } = event else {
568            continue;
569        };
570        if !extract_ids.contains(id) {
571            continue;
572        }
573
574        let Some(image) = images.get(*id) else {
575            continue;
576        };
577        if image.texture_descriptor.format != TextureFormat::Rgba32Float {
578            continue;
579        }
580
581        let width = image.texture_descriptor.size.width;
582        if width == 0 {
583            continue;
584        }
585
586        let Some(samples): Option<Vec<LinearRgba>> = (0..width)
587            .map(|x| image.get_color_at_1d(x).ok().map(|c| c.to_linear()))
588            .collect()
589        else {
590            continue;
591        };
592
593        let Ok(curve) = SampleAutoCurve::new(
594            Interval::new(-1.0, 1.0).expect("[-1, 1] valid for cos θ"),
595            samples,
596        ) else {
597            continue;
598        };
599
600        let new_phase = PhaseFunction::from_chromatic_curve(curve);
601
602        for (_id, medium) in scattering_media.iter_mut() {
603            for term in medium.terms.iter_mut() {
604                if let PhaseFunction::ChromaticTexture(handle) = &term.phase
605                    && handle.id() == *id
606                {
607                    term.phase = new_phase.clone();
608                }
609            }
610        }
611    }
612}