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