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