bevy_pbr/light_probe/
irradiance_volume.rs

1//! Irradiance volumes, also known as voxel global illumination.
2//!
3//! An *irradiance volume* is a cuboid voxel region consisting of
4//! regularly-spaced precomputed samples of diffuse indirect light. They're
5//! ideal if you have a dynamic object such as a character that can move about
6//! static non-moving geometry such as a level in a game, and you want that
7//! dynamic object to be affected by the light bouncing off that static
8//! geometry.
9//!
10//! To use irradiance volumes, you need to precompute, or *bake*, the indirect
11//! light in your scene. Bevy doesn't currently come with a way to do this.
12//! Fortunately, [Blender] provides a [baking tool] as part of the Eevee
13//! renderer, and its irradiance volumes are compatible with those used by Bevy.
14//! The [`bevy-baked-gi`] project provides a tool, `export-blender-gi`, that can
15//! extract the baked irradiance volumes from the Blender `.blend` file and
16//! package them up into a `.ktx2` texture for use by the engine. See the
17//! documentation in the `bevy-baked-gi` project for more details on this
18//! workflow.
19//!
20//! Like all light probes in Bevy, irradiance volumes are 1×1×1 cubes, centered
21//! on the origin, that can be arbitrarily scaled, rotated, and positioned in a
22//! scene with the [`bevy_transform::components::Transform`] component. The 3D
23//! voxel grid will be stretched to fill the interior of the cube, with linear
24//! interpolation, and the illumination from the irradiance volume will apply to
25//! all fragments within that bounding region.
26//!
27//! Bevy's irradiance volumes are based on Valve's [*ambient cubes*] as used in
28//! *Half-Life 2* ([Mitchell 2006, slide 27]). These encode a single color of
29//! light from the six 3D cardinal directions and blend the sides together
30//! according to the surface normal. For an explanation of why ambient cubes
31//! were chosen over spherical harmonics, see [Why ambient cubes?] below.
32//!
33//! If you wish to use a tool other than `export-blender-gi` to produce the
34//! irradiance volumes, you'll need to pack the irradiance volumes in the
35//! following format. The irradiance volume of resolution *(Rx, Ry, Rz)* is
36//! expected to be a 3D texture of dimensions *(Rx, 2Ry, 3Rz)*. The unnormalized
37//! texture coordinate *(s, t, p)* of the voxel at coordinate *(x, y, z)* with
38//! side *S* ∈ *{-X, +X, -Y, +Y, -Z, +Z}* is as follows:
39//!
40//! ```text
41//! s = x
42//!
43//! t = y + ⎰  0 if S ∈ {-X, -Y, -Z}
44//!         ⎱ Ry if S ∈ {+X, +Y, +Z}
45//!
46//!         ⎧   0 if S ∈ {-X, +X}
47//! p = z + ⎨  Rz if S ∈ {-Y, +Y}
48//!         ⎩ 2Rz if S ∈ {-Z, +Z}
49//! ```
50//!
51//! Visually, in a left-handed coordinate system with Y up, viewed from the
52//! right, the 3D texture looks like a stacked series of voxel grids, one for
53//! each cube side, in this order:
54//!
55//! | **+X** | **+Y** | **+Z** |
56//! | ------ | ------ | ------ |
57//! | **-X** | **-Y** | **-Z** |
58//!
59//! A terminology note: Other engines may refer to irradiance volumes as *voxel
60//! global illumination*, *VXGI*, or simply as *light probes*. Sometimes *light
61//! probe* refers to what Bevy calls a reflection probe. In Bevy, *light probe*
62//! is a generic term that encompasses all cuboid bounding regions that capture
63//! indirect illumination, whether based on voxels or not.
64//!
65//! Note that, if binding arrays aren't supported (e.g. on WebGPU or WebGL 2),
66//! then only the closest irradiance volume to the view will be taken into
67//! account during rendering. The required `wgpu` features are
68//! [`bevy_render::settings::WgpuFeatures::TEXTURE_BINDING_ARRAY`] and
69//! [`bevy_render::settings::WgpuFeatures::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING`].
70//!
71//! ## Why ambient cubes?
72//!
73//! This section describes the motivation behind the decision to use ambient
74//! cubes in Bevy. It's not needed to use the feature; feel free to skip it
75//! unless you're interested in its internal design.
76//!
77//! Bevy uses *Half-Life 2*-style ambient cubes (usually abbreviated as *HL2*)
78//! as the representation of irradiance for light probes instead of the
79//! more-popular spherical harmonics (*SH*). This might seem to be a surprising
80//! choice, but it turns out to work well for the specific case of voxel
81//! sampling on the GPU. Spherical harmonics have two problems that make them
82//! less ideal for this use case:
83//!
84//! 1. The level 1 spherical harmonic coefficients can be negative. That
85//!    prevents the use of the efficient [RGB9E5 texture format], which only
86//!    encodes unsigned floating point numbers, and forces the use of the
87//!    less-efficient [RGBA16F format] if hardware interpolation is desired.
88//!
89//! 2. As an alternative to RGBA16F, level 1 spherical harmonics can be
90//!    normalized and scaled to the SH0 base color, as [Frostbite] does. This
91//!    allows them to be packed in standard LDR RGBA8 textures. However, this
92//!    prevents the use of hardware trilinear filtering, as the nonuniform scale
93//!    factor means that hardware interpolation no longer produces correct results.
94//!    The 8 texture fetches needed to interpolate between voxels can be upwards of
95//!    twice as slow as the hardware interpolation.
96//!
97//! The following chart summarizes the costs and benefits of ambient cubes,
98//! level 1 spherical harmonics, and level 2 spherical harmonics:
99//!
100//! | Technique                | HW-interpolated samples | Texel fetches | Bytes per voxel | Quality |
101//! | ------------------------ | ----------------------- | ------------- | --------------- | ------- |
102//! | Ambient cubes            |                       3 |             0 |              24 | Medium  |
103//! | Level 1 SH, compressed   |                       0 |            36 |              16 | Low     |
104//! | Level 1 SH, uncompressed |                       4 |             0 |              24 | Low     |
105//! | Level 2 SH, compressed   |                       0 |            72 |              28 | High    |
106//! | Level 2 SH, uncompressed |                       9 |             0 |              54 | High    |
107//!
108//! (Note that the number of bytes per voxel can be reduced using various
109//! texture compression methods, but the overall ratios remain similar.)
110//!
111//! From these data, we can see that ambient cubes balance fast lookups (from
112//! leveraging hardware interpolation) with relatively-small storage
113//! requirements and acceptable quality. Hence, they were chosen for irradiance
114//! volumes in Bevy.
115//!
116//! [*ambient cubes*]: https://advances.realtimerendering.com/s2006/Mitchell-ShadingInValvesSourceEngine.pdf
117//!
118//! [spherical harmonics]: https://en.wikipedia.org/wiki/Spherical_harmonic_lighting
119//!
120//! [RGB9E5 texture format]: https://www.khronos.org/opengl/wiki/Small_Float_Formats#RGB9_E5
121//!
122//! [RGBA16F format]: https://www.khronos.org/opengl/wiki/Small_Float_Formats#Low-bitdepth_floats
123//!
124//! [Frostbite]: https://media.contentapi.ea.com/content/dam/eacom/frostbite/files/gdc2018-precomputedgiobalilluminationinfrostbite.pdf#page=53
125//!
126//! [Mitchell 2006, slide 27]: https://advances.realtimerendering.com/s2006/Mitchell-ShadingInValvesSourceEngine.pdf#page=27
127//!
128//! [Blender]: http://blender.org/
129//!
130//! [baking tool]: https://docs.blender.org/manual/en/latest/render/eevee/light_probes/volume.html
131//!
132//! [`bevy-baked-gi`]: https://github.com/pcwalton/bevy-baked-gi
133//!
134//! [Why ambient cubes?]: #why-ambient-cubes
135
136use bevy_image::Image;
137use bevy_light::IrradianceVolume;
138use bevy_render::{
139    render_asset::RenderAssets,
140    render_resource::{
141        binding_types, BindGroupLayoutEntryBuilder, Sampler, SamplerBindingType, TextureSampleType,
142        TextureView,
143    },
144    renderer::{RenderAdapter, RenderDevice},
145    texture::{FallbackImage, GpuImage},
146};
147use core::{num::NonZero, ops::Deref};
148
149use bevy_asset::AssetId;
150
151use crate::{
152    add_cubemap_texture_view, binding_arrays_are_usable, RenderViewLightProbes,
153    MAX_VIEW_LIGHT_PROBES,
154};
155
156use super::LightProbeComponent;
157
158/// On WebGL and WebGPU, we must disable irradiance volumes, as otherwise we can
159/// overflow the number of texture bindings when deferred rendering is in use
160/// (see issue #11885).
161pub(crate) const IRRADIANCE_VOLUMES_ARE_USABLE: bool = cfg!(not(target_arch = "wasm32"));
162
163/// All the bind group entries necessary for PBR shaders to access the
164/// irradiance volumes exposed to a view.
165pub(crate) enum RenderViewIrradianceVolumeBindGroupEntries<'a> {
166    /// The version used when binding arrays aren't available on the current platform.
167    Single {
168        /// The texture view of the closest light probe.
169        texture_view: &'a TextureView,
170        /// A sampler used to sample voxels of the irradiance volume.
171        sampler: &'a Sampler,
172    },
173
174    /// The version used when binding arrays are available on the current
175    /// platform.
176    Multiple {
177        /// A texture view of the voxels of each irradiance volume, in the same
178        /// order that they are supplied to the view (i.e. in the same order as
179        /// `binding_index_to_cubemap` in [`RenderViewLightProbes`]).
180        ///
181        /// This is a vector of `wgpu::TextureView`s. But we don't want to import
182        /// `wgpu` in this crate, so we refer to it indirectly like this.
183        texture_views: Vec<&'a <TextureView as Deref>::Target>,
184
185        /// A sampler used to sample voxels of the irradiance volumes.
186        sampler: &'a Sampler,
187    },
188}
189
190impl<'a> RenderViewIrradianceVolumeBindGroupEntries<'a> {
191    /// Looks up and returns the bindings for any irradiance volumes visible in
192    /// the view, as well as the sampler.
193    pub(crate) fn get(
194        render_view_irradiance_volumes: Option<&RenderViewLightProbes<IrradianceVolume>>,
195        images: &'a RenderAssets<GpuImage>,
196        fallback_image: &'a FallbackImage,
197        render_device: &RenderDevice,
198        render_adapter: &RenderAdapter,
199    ) -> RenderViewIrradianceVolumeBindGroupEntries<'a> {
200        if binding_arrays_are_usable(render_device, render_adapter) {
201            RenderViewIrradianceVolumeBindGroupEntries::get_multiple(
202                render_view_irradiance_volumes,
203                images,
204                fallback_image,
205            )
206        } else {
207            RenderViewIrradianceVolumeBindGroupEntries::single(
208                render_view_irradiance_volumes,
209                images,
210                fallback_image,
211            )
212        }
213    }
214
215    /// Looks up and returns the bindings for any irradiance volumes visible in
216    /// the view, as well as the sampler. This is the version used when binding
217    /// arrays are available on the current platform.
218    fn get_multiple(
219        render_view_irradiance_volumes: Option<&RenderViewLightProbes<IrradianceVolume>>,
220        images: &'a RenderAssets<GpuImage>,
221        fallback_image: &'a FallbackImage,
222    ) -> RenderViewIrradianceVolumeBindGroupEntries<'a> {
223        let mut texture_views = vec![];
224        let mut sampler = None;
225
226        if let Some(irradiance_volumes) = render_view_irradiance_volumes {
227            for &cubemap_id in &irradiance_volumes.binding_index_to_textures {
228                add_cubemap_texture_view(
229                    &mut texture_views,
230                    &mut sampler,
231                    cubemap_id,
232                    images,
233                    fallback_image,
234                );
235            }
236        }
237
238        // Pad out the bindings to the size of the binding array using fallback
239        // textures. This is necessary on D3D12 and Metal.
240        texture_views.resize(MAX_VIEW_LIGHT_PROBES, &*fallback_image.d3.texture_view);
241
242        RenderViewIrradianceVolumeBindGroupEntries::Multiple {
243            texture_views,
244            sampler: sampler.unwrap_or(&fallback_image.d3.sampler),
245        }
246    }
247
248    /// Looks up and returns the bindings for any irradiance volumes visible in
249    /// the view, as well as the sampler. This is the version used when binding
250    /// arrays aren't available on the current platform.
251    fn single(
252        render_view_irradiance_volumes: Option<&RenderViewLightProbes<IrradianceVolume>>,
253        images: &'a RenderAssets<GpuImage>,
254        fallback_image: &'a FallbackImage,
255    ) -> RenderViewIrradianceVolumeBindGroupEntries<'a> {
256        if let Some(irradiance_volumes) = render_view_irradiance_volumes
257            && let Some(irradiance_volume) = irradiance_volumes.render_light_probes.first()
258            && irradiance_volume.texture_index >= 0
259            && let Some(image_id) = irradiance_volumes
260                .binding_index_to_textures
261                .get(irradiance_volume.texture_index as usize)
262            && let Some(image) = images.get(*image_id)
263        {
264            return RenderViewIrradianceVolumeBindGroupEntries::Single {
265                texture_view: &image.texture_view,
266                sampler: &image.sampler,
267            };
268        }
269
270        RenderViewIrradianceVolumeBindGroupEntries::Single {
271            texture_view: &fallback_image.d3.texture_view,
272            sampler: &fallback_image.d3.sampler,
273        }
274    }
275}
276
277/// Returns the bind group layout entries for the voxel texture and sampler
278/// respectively.
279pub(crate) fn get_bind_group_layout_entries(
280    render_device: &RenderDevice,
281    render_adapter: &RenderAdapter,
282) -> [BindGroupLayoutEntryBuilder; 2] {
283    let mut texture_3d_binding =
284        binding_types::texture_3d(TextureSampleType::Float { filterable: true });
285    if binding_arrays_are_usable(render_device, render_adapter) {
286        texture_3d_binding =
287            texture_3d_binding.count(NonZero::<u32>::new(MAX_VIEW_LIGHT_PROBES as _).unwrap());
288    }
289
290    [
291        texture_3d_binding,
292        binding_types::sampler(SamplerBindingType::Filtering),
293    ]
294}
295
296impl LightProbeComponent for IrradianceVolume {
297    type AssetId = AssetId<Image>;
298
299    // Irradiance volumes can't be attached to the view, so we store nothing
300    // here.
301    type ViewLightProbeInfo = ();
302
303    fn id(&self, image_assets: &RenderAssets<GpuImage>) -> Option<Self::AssetId> {
304        if image_assets.get(&self.voxels).is_none() {
305            None
306        } else {
307            Some(self.voxels.id())
308        }
309    }
310
311    fn intensity(&self) -> f32 {
312        self.intensity
313    }
314
315    fn affects_lightmapped_mesh_diffuse(&self) -> bool {
316        self.affects_lightmapped_meshes
317    }
318
319    fn create_render_view_light_probes(
320        _: Option<&Self>,
321        _: &RenderAssets<GpuImage>,
322    ) -> RenderViewLightProbes<Self> {
323        RenderViewLightProbes::new()
324    }
325}