bevy/crates/bevy_light/src/cluster/assign.rs
atlv 537adcc3f7
bevy_light (#19991)
# Objective

- make lights usable without bevy_render

## Solution

- make a new crate for lights to live in

## Testing

- 3d_scene, lighting, volumetric_fog, ssr, transmission, pcss,
light_textures

Note: no breaking changes because of re-exports, except for light
textures, which were introduced this cycle so it doesn't matter anyways
2025-07-07 00:07:38 +00:00

1182 lines
51 KiB
Rust

//! Assigning objects to clusters.
use bevy_camera::{
primitives::{Aabb, Frustum, HalfSpace, Sphere},
visibility::{RenderLayers, ViewVisibility},
Camera,
};
use bevy_ecs::{
entity::Entity,
query::{Has, With},
system::{Commands, Local, Query, Res, ResMut},
};
use bevy_math::{
ops::{self, sin_cos},
Mat4, UVec3, Vec2, Vec3, Vec3A, Vec3Swizzles as _, Vec4, Vec4Swizzles as _,
};
use bevy_transform::components::GlobalTransform;
use bevy_utils::prelude::default;
use tracing::warn;
use super::{
ClusterConfig, ClusterFarZMode, ClusteredDecal, Clusters, GlobalClusterSettings,
GlobalVisibleClusterableObjects, VisibleClusterableObjects,
};
use crate::{EnvironmentMapLight, LightProbe, PointLight, SpotLight, VolumetricLight};
const NDC_MIN: Vec2 = Vec2::NEG_ONE;
const NDC_MAX: Vec2 = Vec2::ONE;
const VEC2_HALF: Vec2 = Vec2::splat(0.5);
const VEC2_HALF_NEGATIVE_Y: Vec2 = Vec2::new(0.5, -0.5);
/// Data required for assigning objects to clusters.
#[derive(Clone, Debug)]
pub(crate) struct ClusterableObjectAssignmentData {
entity: Entity,
// TODO: We currently ignore the scale on the transform. This is confusing.
// Replace with an `Isometry3d`.
transform: GlobalTransform,
range: f32,
object_type: ClusterableObjectType,
render_layers: RenderLayers,
}
impl ClusterableObjectAssignmentData {
pub fn sphere(&self) -> Sphere {
Sphere {
center: self.transform.translation_vec3a(),
radius: self.range,
}
}
}
/// Data needed to assign objects to clusters that's specific to the type of
/// clusterable object.
#[derive(Clone, Copy, Debug)]
pub enum ClusterableObjectType {
/// Data needed to assign point lights to clusters.
PointLight {
/// Whether shadows are enabled for this point light.
///
/// This is used for sorting the light list.
shadows_enabled: bool,
/// Whether this light interacts with volumetrics.
///
/// This is used for sorting the light list.
volumetric: bool,
},
/// Data needed to assign spot lights to clusters.
SpotLight {
/// Whether shadows are enabled for this spot light.
///
/// This is used for sorting the light list.
shadows_enabled: bool,
/// Whether this light interacts with volumetrics.
///
/// This is used for sorting the light list.
volumetric: bool,
/// The outer angle of the light cone in radians.
outer_angle: f32,
},
/// Marks that the clusterable object is a reflection probe.
ReflectionProbe,
/// Marks that the clusterable object is an irradiance volume.
IrradianceVolume,
/// Marks that the clusterable object is a decal.
Decal,
}
impl ClusterableObjectType {
/// Returns a tuple that can be sorted to obtain the order in which indices
/// to clusterable objects must be stored in the cluster offsets and counts
/// list.
///
/// Generally, we sort first by type, then, for lights, by whether shadows
/// are enabled (enabled before disabled), and then whether volumetrics are
/// enabled (enabled before disabled).
pub fn ordering(&self) -> (u8, bool, bool) {
match *self {
ClusterableObjectType::PointLight {
shadows_enabled,
volumetric,
} => (0, !shadows_enabled, !volumetric),
ClusterableObjectType::SpotLight {
shadows_enabled,
volumetric,
..
} => (1, !shadows_enabled, !volumetric),
ClusterableObjectType::ReflectionProbe => (2, false, false),
ClusterableObjectType::IrradianceVolume => (3, false, false),
ClusterableObjectType::Decal => (4, false, false),
}
}
}
// NOTE: Run this before update_point_light_frusta!
pub(crate) fn assign_objects_to_clusters(
mut commands: Commands,
mut global_clusterable_objects: ResMut<GlobalVisibleClusterableObjects>,
mut views: Query<(
Entity,
&GlobalTransform,
&Camera,
&Frustum,
&ClusterConfig,
&mut Clusters,
Option<&RenderLayers>,
Option<&mut VisibleClusterableObjects>,
)>,
point_lights_query: Query<(
Entity,
&GlobalTransform,
&PointLight,
Option<&RenderLayers>,
Option<&VolumetricLight>,
&ViewVisibility,
)>,
spot_lights_query: Query<(
Entity,
&GlobalTransform,
&SpotLight,
Option<&RenderLayers>,
Option<&VolumetricLight>,
&ViewVisibility,
)>,
light_probes_query: Query<
(Entity, &GlobalTransform, Has<EnvironmentMapLight>),
With<LightProbe>,
>,
decals_query: Query<(Entity, &GlobalTransform), With<ClusteredDecal>>,
mut clusterable_objects: Local<Vec<ClusterableObjectAssignmentData>>,
mut cluster_aabb_spheres: Local<Vec<Option<Sphere>>>,
mut max_clusterable_objects_warning_emitted: Local<bool>,
global_cluster_settings: Option<Res<GlobalClusterSettings>>,
) {
let Some(global_cluster_settings) = global_cluster_settings else {
return;
};
global_clusterable_objects.entities.clear();
clusterable_objects.clear();
// collect just the relevant query data into a persisted vec to avoid reallocating each frame
clusterable_objects.extend(
point_lights_query
.iter()
.filter(|(.., visibility)| visibility.get())
.map(
|(entity, transform, point_light, maybe_layers, volumetric, _visibility)| {
ClusterableObjectAssignmentData {
entity,
transform: GlobalTransform::from_translation(transform.translation()),
range: point_light.range,
object_type: ClusterableObjectType::PointLight {
shadows_enabled: point_light.shadows_enabled,
volumetric: volumetric.is_some(),
},
render_layers: maybe_layers.unwrap_or_default().clone(),
}
},
),
);
clusterable_objects.extend(
spot_lights_query
.iter()
.filter(|(.., visibility)| visibility.get())
.map(
|(entity, transform, spot_light, maybe_layers, volumetric, _visibility)| {
ClusterableObjectAssignmentData {
entity,
transform: *transform,
range: spot_light.range,
object_type: ClusterableObjectType::SpotLight {
outer_angle: spot_light.outer_angle,
shadows_enabled: spot_light.shadows_enabled,
volumetric: volumetric.is_some(),
},
render_layers: maybe_layers.unwrap_or_default().clone(),
}
},
),
);
// Gather up light probes, but only if we're clustering them.
//
// UBOs aren't large enough to hold indices for light probes, so we can't
// cluster light probes on such platforms (mainly WebGL 2). Besides, those
// platforms typically lack bindless textures, so multiple light probes
// wouldn't be supported anyhow.
if global_cluster_settings.supports_storage_buffers {
clusterable_objects.extend(light_probes_query.iter().map(
|(entity, transform, is_reflection_probe)| ClusterableObjectAssignmentData {
entity,
transform: *transform,
range: transform.radius_vec3a(Vec3A::ONE),
object_type: if is_reflection_probe {
ClusterableObjectType::ReflectionProbe
} else {
ClusterableObjectType::IrradianceVolume
},
render_layers: RenderLayers::default(),
},
));
}
// Add decals if the current platform supports them.
if global_cluster_settings.clustered_decals_are_usable {
clusterable_objects.extend(decals_query.iter().map(|(entity, transform)| {
ClusterableObjectAssignmentData {
entity,
transform: *transform,
range: transform.scale().length(),
object_type: ClusterableObjectType::Decal,
render_layers: RenderLayers::default(),
}
}));
}
if clusterable_objects.len() > global_cluster_settings.max_uniform_buffer_clusterable_objects
&& !global_cluster_settings.supports_storage_buffers
{
clusterable_objects.sort_by_cached_key(|clusterable_object| {
(
clusterable_object.object_type.ordering(),
clusterable_object.entity,
)
});
// check each clusterable object against each view's frustum, keep only
// those that affect at least one of our views
let frusta: Vec<_> = views
.iter()
.map(|(_, _, _, frustum, _, _, _, _)| *frustum)
.collect();
let mut clusterable_objects_in_view_count = 0;
clusterable_objects.retain(|clusterable_object| {
// take one extra clusterable object to check if we should emit the warning
if clusterable_objects_in_view_count
== global_cluster_settings.max_uniform_buffer_clusterable_objects + 1
{
false
} else {
let clusterable_object_sphere = clusterable_object.sphere();
let clusterable_object_in_view = frusta
.iter()
.any(|frustum| frustum.intersects_sphere(&clusterable_object_sphere, true));
if clusterable_object_in_view {
clusterable_objects_in_view_count += 1;
}
clusterable_object_in_view
}
});
if clusterable_objects.len()
> global_cluster_settings.max_uniform_buffer_clusterable_objects
&& !*max_clusterable_objects_warning_emitted
{
warn!(
"max_uniform_buffer_clusterable_objects ({}) exceeded",
global_cluster_settings.max_uniform_buffer_clusterable_objects
);
*max_clusterable_objects_warning_emitted = true;
}
clusterable_objects
.truncate(global_cluster_settings.max_uniform_buffer_clusterable_objects);
}
for (
view_entity,
camera_transform,
camera,
frustum,
config,
clusters,
maybe_layers,
mut visible_clusterable_objects,
) in &mut views
{
let view_layers = maybe_layers.unwrap_or_default();
let clusters = clusters.into_inner();
if matches!(config, ClusterConfig::None) {
if visible_clusterable_objects.is_some() {
commands
.entity(view_entity)
.remove::<VisibleClusterableObjects>();
}
clusters.clear();
continue;
}
let screen_size = match camera.physical_viewport_size() {
Some(screen_size) if screen_size.x != 0 && screen_size.y != 0 => screen_size,
_ => {
clusters.clear();
continue;
}
};
let mut requested_cluster_dimensions = config.dimensions_for_screen_size(screen_size);
let world_from_view = camera_transform.to_matrix();
let view_from_world_scale = camera_transform.compute_transform().scale.recip();
let view_from_world_scale_max = view_from_world_scale.abs().max_element();
let view_from_world = world_from_view.inverse();
let is_orthographic = camera.clip_from_view().w_axis.w == 1.0;
let far_z = match config.far_z_mode() {
ClusterFarZMode::MaxClusterableObjectRange => {
let view_from_world_row_2 = view_from_world.row(2);
clusterable_objects
.iter()
.map(|object| {
-view_from_world_row_2.dot(object.transform.translation().extend(1.0))
+ object.range * view_from_world_scale.z
})
.reduce(f32::max)
.unwrap_or(0.0)
}
ClusterFarZMode::Constant(far) => far,
};
let first_slice_depth = match (is_orthographic, requested_cluster_dimensions.z) {
(true, _) => {
// NOTE: Based on glam's Mat4::orthographic_rh(), as used to calculate the orthographic projection
// matrix, we can calculate the projection's view-space near plane as follows:
// component 3,2 = r * near and 2,2 = r where r = 1.0 / (near - far)
// There is a caveat here that when calculating the projection matrix, near and far were swapped to give
// reversed z, consistent with the perspective projection. So,
// 3,2 = r * far and 2,2 = r where r = 1.0 / (far - near)
// rearranging r = 1.0 / (far - near), r * (far - near) = 1.0, r * far - 1.0 = r * near, near = (r * far - 1.0) / r
// = (3,2 - 1.0) / 2,2
(camera.clip_from_view().w_axis.z - 1.0) / camera.clip_from_view().z_axis.z
}
(false, 1) => config.first_slice_depth().max(far_z),
_ => config.first_slice_depth(),
};
let first_slice_depth = first_slice_depth * view_from_world_scale.z;
// NOTE: Ensure the far_z is at least as far as the first_depth_slice to avoid clustering problems.
let far_z = far_z.max(first_slice_depth);
let cluster_factors = calculate_cluster_factors(
first_slice_depth,
far_z,
requested_cluster_dimensions.z as f32,
is_orthographic,
);
if config.dynamic_resizing() {
let mut cluster_index_estimate = 0.0;
for clusterable_object in &clusterable_objects {
let clusterable_object_sphere = clusterable_object.sphere();
// Check if the clusterable object is within the view frustum
if !frustum.intersects_sphere(&clusterable_object_sphere, true) {
continue;
}
// calculate a conservative aabb estimate of number of clusters affected by this light
// this overestimates index counts by at most 50% (and typically much less) when the whole light range is in view
// it can overestimate more significantly when light ranges are only partially in view
let (clusterable_object_aabb_min, clusterable_object_aabb_max) =
cluster_space_clusterable_object_aabb(
view_from_world,
view_from_world_scale,
camera.clip_from_view(),
&clusterable_object_sphere,
);
// since we won't adjust z slices we can calculate exact number of slices required in z dimension
let z_cluster_min = view_z_to_z_slice(
cluster_factors,
requested_cluster_dimensions.z,
clusterable_object_aabb_min.z,
is_orthographic,
);
let z_cluster_max = view_z_to_z_slice(
cluster_factors,
requested_cluster_dimensions.z,
clusterable_object_aabb_max.z,
is_orthographic,
);
let z_count =
z_cluster_min.max(z_cluster_max) - z_cluster_min.min(z_cluster_max) + 1;
// calculate x/y count using floats to avoid overestimating counts due to large initial tile sizes
let xy_min = clusterable_object_aabb_min.xy();
let xy_max = clusterable_object_aabb_max.xy();
// multiply by 0.5 to move from [-1,1] to [-0.5, 0.5], max extent of 1 in each dimension
let xy_count = (xy_max - xy_min)
* 0.5
* Vec2::new(
requested_cluster_dimensions.x as f32,
requested_cluster_dimensions.y as f32,
);
// add up to 2 to each axis to account for overlap
let x_overlap = if xy_min.x <= -1.0 { 0.0 } else { 1.0 }
+ if xy_max.x >= 1.0 { 0.0 } else { 1.0 };
let y_overlap = if xy_min.y <= -1.0 { 0.0 } else { 1.0 }
+ if xy_max.y >= 1.0 { 0.0 } else { 1.0 };
cluster_index_estimate +=
(xy_count.x + x_overlap) * (xy_count.y + y_overlap) * z_count as f32;
}
if cluster_index_estimate
> global_cluster_settings.view_cluster_bindings_max_indices as f32
{
// scale x and y cluster count to be able to fit all our indices
// we take the ratio of the actual indices over the index estimate.
// this is not guaranteed to be small enough due to overlapped tiles, but
// the conservative estimate is more than sufficient to cover the
// difference
let index_ratio = global_cluster_settings.view_cluster_bindings_max_indices as f32
/ cluster_index_estimate;
let xy_ratio = index_ratio.sqrt();
requested_cluster_dimensions.x =
((requested_cluster_dimensions.x as f32 * xy_ratio).floor() as u32).max(1);
requested_cluster_dimensions.y =
((requested_cluster_dimensions.y as f32 * xy_ratio).floor() as u32).max(1);
}
}
clusters.update(screen_size, requested_cluster_dimensions);
clusters.near = first_slice_depth;
clusters.far = far_z;
// NOTE: Maximum 4096 clusters due to uniform buffer size constraints
debug_assert!(
clusters.dimensions.x * clusters.dimensions.y * clusters.dimensions.z <= 4096
);
let view_from_clip = camera.clip_from_view().inverse();
for clusterable_objects in &mut clusters.clusterable_objects {
clusterable_objects.entities.clear();
clusterable_objects.counts = default();
}
let cluster_count =
(clusters.dimensions.x * clusters.dimensions.y * clusters.dimensions.z) as usize;
clusters
.clusterable_objects
.resize_with(cluster_count, VisibleClusterableObjects::default);
// initialize empty cluster bounding spheres
cluster_aabb_spheres.clear();
cluster_aabb_spheres.extend(core::iter::repeat_n(None, cluster_count));
// Calculate the x/y/z cluster frustum planes in view space
let mut x_planes = Vec::with_capacity(clusters.dimensions.x as usize + 1);
let mut y_planes = Vec::with_capacity(clusters.dimensions.y as usize + 1);
let mut z_planes = Vec::with_capacity(clusters.dimensions.z as usize + 1);
if is_orthographic {
let x_slices = clusters.dimensions.x as f32;
for x in 0..=clusters.dimensions.x {
let x_proportion = x as f32 / x_slices;
let x_pos = x_proportion * 2.0 - 1.0;
let view_x = clip_to_view(view_from_clip, Vec4::new(x_pos, 0.0, 1.0, 1.0)).x;
let normal = Vec3::X;
let d = view_x * normal.x;
x_planes.push(HalfSpace::new(normal.extend(d)));
}
let y_slices = clusters.dimensions.y as f32;
for y in 0..=clusters.dimensions.y {
let y_proportion = 1.0 - y as f32 / y_slices;
let y_pos = y_proportion * 2.0 - 1.0;
let view_y = clip_to_view(view_from_clip, Vec4::new(0.0, y_pos, 1.0, 1.0)).y;
let normal = Vec3::Y;
let d = view_y * normal.y;
y_planes.push(HalfSpace::new(normal.extend(d)));
}
} else {
let x_slices = clusters.dimensions.x as f32;
for x in 0..=clusters.dimensions.x {
let x_proportion = x as f32 / x_slices;
let x_pos = x_proportion * 2.0 - 1.0;
let nb = clip_to_view(view_from_clip, Vec4::new(x_pos, -1.0, 1.0, 1.0)).xyz();
let nt = clip_to_view(view_from_clip, Vec4::new(x_pos, 1.0, 1.0, 1.0)).xyz();
let normal = nb.cross(nt);
let d = nb.dot(normal);
x_planes.push(HalfSpace::new(normal.extend(d)));
}
let y_slices = clusters.dimensions.y as f32;
for y in 0..=clusters.dimensions.y {
let y_proportion = 1.0 - y as f32 / y_slices;
let y_pos = y_proportion * 2.0 - 1.0;
let nl = clip_to_view(view_from_clip, Vec4::new(-1.0, y_pos, 1.0, 1.0)).xyz();
let nr = clip_to_view(view_from_clip, Vec4::new(1.0, y_pos, 1.0, 1.0)).xyz();
let normal = nr.cross(nl);
let d = nr.dot(normal);
y_planes.push(HalfSpace::new(normal.extend(d)));
}
}
let z_slices = clusters.dimensions.z;
for z in 0..=z_slices {
let view_z = z_slice_to_view_z(first_slice_depth, far_z, z_slices, z, is_orthographic);
let normal = -Vec3::Z;
let d = view_z * normal.z;
z_planes.push(HalfSpace::new(normal.extend(d)));
}
let mut update_from_object_intersections = |visible_clusterable_objects: &mut Vec<
Entity,
>| {
for clusterable_object in &clusterable_objects {
// check if the clusterable light layers overlap the view layers
if !view_layers.intersects(&clusterable_object.render_layers) {
continue;
}
let clusterable_object_sphere = clusterable_object.sphere();
// Check if the clusterable object is within the view frustum
if !frustum.intersects_sphere(&clusterable_object_sphere, true) {
continue;
}
// NOTE: The clusterable object intersects the frustum so it
// must be visible and part of the global set
global_clusterable_objects
.entities
.insert(clusterable_object.entity);
visible_clusterable_objects.push(clusterable_object.entity);
// note: caching seems to be slower than calling twice for this aabb calculation
let (
clusterable_object_aabb_xy_ndc_z_view_min,
clusterable_object_aabb_xy_ndc_z_view_max,
) = cluster_space_clusterable_object_aabb(
view_from_world,
view_from_world_scale,
camera.clip_from_view(),
&clusterable_object_sphere,
);
let min_cluster = ndc_position_to_cluster(
clusters.dimensions,
cluster_factors,
is_orthographic,
clusterable_object_aabb_xy_ndc_z_view_min,
clusterable_object_aabb_xy_ndc_z_view_min.z,
);
let max_cluster = ndc_position_to_cluster(
clusters.dimensions,
cluster_factors,
is_orthographic,
clusterable_object_aabb_xy_ndc_z_view_max,
clusterable_object_aabb_xy_ndc_z_view_max.z,
);
let (min_cluster, max_cluster) =
(min_cluster.min(max_cluster), min_cluster.max(max_cluster));
// What follows is the Iterative Sphere Refinement algorithm from Just Cause 3
// Persson et al, Practical Clustered Shading
// http://newq.net/dl/pub/s2015_practical.pdf
// NOTE: A sphere under perspective projection is no longer a sphere. It gets
// stretched and warped, which prevents simpler algorithms from being correct
// as they often assume that the widest part of the sphere under projection is the
// center point on the axis of interest plus the radius, and that is not true!
let view_clusterable_object_sphere = Sphere {
center: Vec3A::from_vec4(
view_from_world * clusterable_object_sphere.center.extend(1.0),
),
radius: clusterable_object_sphere.radius * view_from_world_scale_max,
};
let spot_light_dir_sin_cos = match clusterable_object.object_type {
ClusterableObjectType::SpotLight { outer_angle, .. } => {
let (angle_sin, angle_cos) = sin_cos(outer_angle);
Some((
(view_from_world * clusterable_object.transform.back().extend(0.0))
.truncate()
.normalize(),
angle_sin,
angle_cos,
))
}
ClusterableObjectType::Decal => {
// TODO: cull via a frustum
None
}
ClusterableObjectType::PointLight { .. }
| ClusterableObjectType::ReflectionProbe
| ClusterableObjectType::IrradianceVolume => None,
};
let clusterable_object_center_clip =
camera.clip_from_view() * view_clusterable_object_sphere.center.extend(1.0);
let object_center_ndc =
clusterable_object_center_clip.xyz() / clusterable_object_center_clip.w;
let cluster_coordinates = ndc_position_to_cluster(
clusters.dimensions,
cluster_factors,
is_orthographic,
object_center_ndc,
view_clusterable_object_sphere.center.z,
);
let z_center = if object_center_ndc.z <= 1.0 {
Some(cluster_coordinates.z)
} else {
None
};
let y_center = if object_center_ndc.y > 1.0 {
None
} else if object_center_ndc.y < -1.0 {
Some(clusters.dimensions.y + 1)
} else {
Some(cluster_coordinates.y)
};
for z in min_cluster.z..=max_cluster.z {
let mut z_object = view_clusterable_object_sphere.clone();
if z_center.is_none() || z != z_center.unwrap() {
// The z plane closer to the clusterable object has the
// larger radius circle where the light sphere
// intersects the z plane.
let z_plane = if z_center.is_some() && z < z_center.unwrap() {
z_planes[(z + 1) as usize]
} else {
z_planes[z as usize]
};
// Project the sphere to this z plane and use its radius as the radius of a
// new, refined sphere.
if let Some(projected) = project_to_plane_z(z_object, z_plane) {
z_object = projected;
} else {
continue;
}
}
for y in min_cluster.y..=max_cluster.y {
let mut y_object = z_object.clone();
if y_center.is_none() || y != y_center.unwrap() {
// The y plane closer to the clusterable object has
// the larger radius circle where the light sphere
// intersects the y plane.
let y_plane = if y_center.is_some() && y < y_center.unwrap() {
y_planes[(y + 1) as usize]
} else {
y_planes[y as usize]
};
// Project the refined sphere to this y plane and use its radius as the
// radius of a new, even more refined sphere.
if let Some(projected) =
project_to_plane_y(y_object, y_plane, is_orthographic)
{
y_object = projected;
} else {
continue;
}
}
// Loop from the left to find the first affected cluster
let mut min_x = min_cluster.x;
loop {
if min_x >= max_cluster.x
|| -get_distance_x(
x_planes[(min_x + 1) as usize],
y_object.center,
is_orthographic,
) + y_object.radius
> 0.0
{
break;
}
min_x += 1;
}
// Loop from the right to find the last affected cluster
let mut max_x = max_cluster.x;
loop {
if max_x <= min_x
|| get_distance_x(
x_planes[max_x as usize],
y_object.center,
is_orthographic,
) + y_object.radius
> 0.0
{
break;
}
max_x -= 1;
}
let mut cluster_index = ((y * clusters.dimensions.x + min_x)
* clusters.dimensions.z
+ z) as usize;
match clusterable_object.object_type {
ClusterableObjectType::SpotLight { .. } => {
let (view_light_direction, angle_sin, angle_cos) =
spot_light_dir_sin_cos.unwrap();
for x in min_x..=max_x {
// further culling for spot lights
// get or initialize cluster bounding sphere
let cluster_aabb_sphere =
&mut cluster_aabb_spheres[cluster_index];
let cluster_aabb_sphere =
if let Some(sphere) = cluster_aabb_sphere {
&*sphere
} else {
let aabb = compute_aabb_for_cluster(
first_slice_depth,
far_z,
clusters.tile_size.as_vec2(),
screen_size.as_vec2(),
view_from_clip,
is_orthographic,
clusters.dimensions,
UVec3::new(x, y, z),
);
let sphere = Sphere {
center: aabb.center,
radius: aabb.half_extents.length(),
};
*cluster_aabb_sphere = Some(sphere);
cluster_aabb_sphere.as_ref().unwrap()
};
// test -- based on https://bartwronski.com/2017/04/13/cull-that-cone/
let spot_light_offset = Vec3::from(
view_clusterable_object_sphere.center
- cluster_aabb_sphere.center,
);
let spot_light_dist_sq = spot_light_offset.length_squared();
let v1_len = spot_light_offset.dot(view_light_direction);
let distance_closest_point = (angle_cos
* (spot_light_dist_sq - v1_len * v1_len).sqrt())
- v1_len * angle_sin;
let angle_cull =
distance_closest_point > cluster_aabb_sphere.radius;
let front_cull = v1_len
> cluster_aabb_sphere.radius
+ clusterable_object.range * view_from_world_scale_max;
let back_cull = v1_len < -cluster_aabb_sphere.radius;
if !angle_cull && !front_cull && !back_cull {
// this cluster is affected by the spot light
clusters.clusterable_objects[cluster_index]
.entities
.push(clusterable_object.entity);
clusters.clusterable_objects[cluster_index]
.counts
.spot_lights += 1;
}
cluster_index += clusters.dimensions.z as usize;
}
}
ClusterableObjectType::PointLight { .. } => {
for _ in min_x..=max_x {
// all clusters within range are affected by point lights
clusters.clusterable_objects[cluster_index]
.entities
.push(clusterable_object.entity);
clusters.clusterable_objects[cluster_index]
.counts
.point_lights += 1;
cluster_index += clusters.dimensions.z as usize;
}
}
ClusterableObjectType::ReflectionProbe => {
// Reflection probes currently affect all
// clusters in their bounding sphere.
//
// TODO: Cull more aggressively based on the
// probe's OBB.
for _ in min_x..=max_x {
clusters.clusterable_objects[cluster_index]
.entities
.push(clusterable_object.entity);
clusters.clusterable_objects[cluster_index]
.counts
.reflection_probes += 1;
cluster_index += clusters.dimensions.z as usize;
}
}
ClusterableObjectType::IrradianceVolume => {
// Irradiance volumes currently affect all
// clusters in their bounding sphere.
//
// TODO: Cull more aggressively based on the
// probe's OBB.
for _ in min_x..=max_x {
clusters.clusterable_objects[cluster_index]
.entities
.push(clusterable_object.entity);
clusters.clusterable_objects[cluster_index]
.counts
.irradiance_volumes += 1;
cluster_index += clusters.dimensions.z as usize;
}
}
ClusterableObjectType::Decal => {
// Decals currently affect all clusters in their
// bounding sphere.
//
// TODO: Cull more aggressively based on the
// decal's OBB.
for _ in min_x..=max_x {
clusters.clusterable_objects[cluster_index]
.entities
.push(clusterable_object.entity);
clusters.clusterable_objects[cluster_index].counts.decals += 1;
cluster_index += clusters.dimensions.z as usize;
}
}
}
}
}
}
};
// reuse existing visible clusterable objects Vec, if it exists
if let Some(visible_clusterable_objects) = visible_clusterable_objects.as_mut() {
visible_clusterable_objects.entities.clear();
update_from_object_intersections(&mut visible_clusterable_objects.entities);
} else {
let mut entities = Vec::new();
update_from_object_intersections(&mut entities);
commands
.entity(view_entity)
.insert(VisibleClusterableObjects {
entities,
..Default::default()
});
}
}
}
pub fn calculate_cluster_factors(
near: f32,
far: f32,
z_slices: f32,
is_orthographic: bool,
) -> Vec2 {
if is_orthographic {
Vec2::new(-near, z_slices / (-far - -near))
} else {
let z_slices_of_ln_zfar_over_znear = (z_slices - 1.0) / ops::ln(far / near);
Vec2::new(
z_slices_of_ln_zfar_over_znear,
ops::ln(near) * z_slices_of_ln_zfar_over_znear,
)
}
}
fn compute_aabb_for_cluster(
z_near: f32,
z_far: f32,
tile_size: Vec2,
screen_size: Vec2,
view_from_clip: Mat4,
is_orthographic: bool,
cluster_dimensions: UVec3,
ijk: UVec3,
) -> Aabb {
let ijk = ijk.as_vec3();
// Calculate the minimum and maximum points in screen space
let p_min = ijk.xy() * tile_size;
let p_max = p_min + tile_size;
let cluster_min;
let cluster_max;
if is_orthographic {
// Use linear depth slicing for orthographic
// Convert to view space at the cluster near and far planes
// NOTE: 1.0 is the near plane due to using reverse z projections
let mut p_min = screen_to_view(screen_size, view_from_clip, p_min, 0.0).xyz();
let mut p_max = screen_to_view(screen_size, view_from_clip, p_max, 0.0).xyz();
// calculate cluster depth using z_near and z_far
p_min.z = -z_near + (z_near - z_far) * ijk.z / cluster_dimensions.z as f32;
p_max.z = -z_near + (z_near - z_far) * (ijk.z + 1.0) / cluster_dimensions.z as f32;
cluster_min = p_min.min(p_max);
cluster_max = p_min.max(p_max);
} else {
// Convert to view space at the near plane
// NOTE: 1.0 is the near plane due to using reverse z projections
let p_min = screen_to_view(screen_size, view_from_clip, p_min, 1.0);
let p_max = screen_to_view(screen_size, view_from_clip, p_max, 1.0);
let z_far_over_z_near = -z_far / -z_near;
let cluster_near = if ijk.z == 0.0 {
0.0
} else {
-z_near
* ops::powf(
z_far_over_z_near,
(ijk.z - 1.0) / (cluster_dimensions.z - 1) as f32,
)
};
// NOTE: This could be simplified to:
// cluster_far = cluster_near * z_far_over_z_near;
let cluster_far = if cluster_dimensions.z == 1 {
-z_far
} else {
-z_near * ops::powf(z_far_over_z_near, ijk.z / (cluster_dimensions.z - 1) as f32)
};
// Calculate the four intersection points of the min and max points with the cluster near and far planes
let p_min_near = line_intersection_to_z_plane(Vec3::ZERO, p_min.xyz(), cluster_near);
let p_min_far = line_intersection_to_z_plane(Vec3::ZERO, p_min.xyz(), cluster_far);
let p_max_near = line_intersection_to_z_plane(Vec3::ZERO, p_max.xyz(), cluster_near);
let p_max_far = line_intersection_to_z_plane(Vec3::ZERO, p_max.xyz(), cluster_far);
cluster_min = p_min_near.min(p_min_far).min(p_max_near.min(p_max_far));
cluster_max = p_min_near.max(p_min_far).max(p_max_near.max(p_max_far));
}
Aabb::from_min_max(cluster_min, cluster_max)
}
// NOTE: Keep in sync as the inverse of view_z_to_z_slice above
fn z_slice_to_view_z(
near: f32,
far: f32,
z_slices: u32,
z_slice: u32,
is_orthographic: bool,
) -> f32 {
if is_orthographic {
return -near - (far - near) * z_slice as f32 / z_slices as f32;
}
// Perspective
if z_slice == 0 {
0.0
} else {
-near * ops::powf(far / near, (z_slice - 1) as f32 / (z_slices - 1) as f32)
}
}
fn ndc_position_to_cluster(
cluster_dimensions: UVec3,
cluster_factors: Vec2,
is_orthographic: bool,
ndc_p: Vec3,
view_z: f32,
) -> UVec3 {
let cluster_dimensions_f32 = cluster_dimensions.as_vec3();
let frag_coord = (ndc_p.xy() * VEC2_HALF_NEGATIVE_Y + VEC2_HALF).clamp(Vec2::ZERO, Vec2::ONE);
let xy = (frag_coord * cluster_dimensions_f32.xy()).floor();
let z_slice = view_z_to_z_slice(
cluster_factors,
cluster_dimensions.z,
view_z,
is_orthographic,
);
xy.as_uvec2()
.extend(z_slice)
.clamp(UVec3::ZERO, cluster_dimensions - UVec3::ONE)
}
/// Calculate bounds for the clusterable object using a view space aabb.
///
/// Returns a `(Vec3, Vec3)` containing minimum and maximum with
/// `X` and `Y` in normalized device coordinates with range `[-1, 1]`
/// `Z` in view space, with range `[-inf, -f32::MIN_POSITIVE]`
fn cluster_space_clusterable_object_aabb(
view_from_world: Mat4,
view_from_world_scale: Vec3,
clip_from_view: Mat4,
clusterable_object_sphere: &Sphere,
) -> (Vec3, Vec3) {
let clusterable_object_aabb_view = Aabb {
center: Vec3A::from_vec4(view_from_world * clusterable_object_sphere.center.extend(1.0)),
half_extents: Vec3A::from(clusterable_object_sphere.radius * view_from_world_scale.abs()),
};
let (mut clusterable_object_aabb_view_min, mut clusterable_object_aabb_view_max) = (
clusterable_object_aabb_view.min(),
clusterable_object_aabb_view.max(),
);
// Constrain view z to be negative - i.e. in front of the camera
// When view z is >= 0.0 and we're using a perspective projection, bad things happen.
// At view z == 0.0, ndc x,y are mathematically undefined. At view z > 0.0, i.e. behind the camera,
// the perspective projection flips the directions of the axes. This breaks assumptions about
// use of min/max operations as something that was to the left in view space is now returning a
// coordinate that for view z in front of the camera would be on the right, but at view z behind the
// camera is on the left. So, we just constrain view z to be < 0.0 and necessarily in front of the camera.
clusterable_object_aabb_view_min.z = clusterable_object_aabb_view_min.z.min(-f32::MIN_POSITIVE);
clusterable_object_aabb_view_max.z = clusterable_object_aabb_view_max.z.min(-f32::MIN_POSITIVE);
// Is there a cheaper way to do this? The problem is that because of perspective
// the point at max z but min xy may be less xy in screenspace, and similar. As
// such, projecting the min and max xy at both the closer and further z and taking
// the min and max of those projected points addresses this.
let (
clusterable_object_aabb_view_xymin_near,
clusterable_object_aabb_view_xymin_far,
clusterable_object_aabb_view_xymax_near,
clusterable_object_aabb_view_xymax_far,
) = (
clusterable_object_aabb_view_min,
clusterable_object_aabb_view_min
.xy()
.extend(clusterable_object_aabb_view_max.z),
clusterable_object_aabb_view_max
.xy()
.extend(clusterable_object_aabb_view_min.z),
clusterable_object_aabb_view_max,
);
let (
clusterable_object_aabb_clip_xymin_near,
clusterable_object_aabb_clip_xymin_far,
clusterable_object_aabb_clip_xymax_near,
clusterable_object_aabb_clip_xymax_far,
) = (
clip_from_view * clusterable_object_aabb_view_xymin_near.extend(1.0),
clip_from_view * clusterable_object_aabb_view_xymin_far.extend(1.0),
clip_from_view * clusterable_object_aabb_view_xymax_near.extend(1.0),
clip_from_view * clusterable_object_aabb_view_xymax_far.extend(1.0),
);
let (
clusterable_object_aabb_ndc_xymin_near,
clusterable_object_aabb_ndc_xymin_far,
clusterable_object_aabb_ndc_xymax_near,
clusterable_object_aabb_ndc_xymax_far,
) = (
clusterable_object_aabb_clip_xymin_near.xyz() / clusterable_object_aabb_clip_xymin_near.w,
clusterable_object_aabb_clip_xymin_far.xyz() / clusterable_object_aabb_clip_xymin_far.w,
clusterable_object_aabb_clip_xymax_near.xyz() / clusterable_object_aabb_clip_xymax_near.w,
clusterable_object_aabb_clip_xymax_far.xyz() / clusterable_object_aabb_clip_xymax_far.w,
);
let (clusterable_object_aabb_ndc_min, clusterable_object_aabb_ndc_max) = (
clusterable_object_aabb_ndc_xymin_near
.min(clusterable_object_aabb_ndc_xymin_far)
.min(clusterable_object_aabb_ndc_xymax_near)
.min(clusterable_object_aabb_ndc_xymax_far),
clusterable_object_aabb_ndc_xymin_near
.max(clusterable_object_aabb_ndc_xymin_far)
.max(clusterable_object_aabb_ndc_xymax_near)
.max(clusterable_object_aabb_ndc_xymax_far),
);
// clamp to ndc coords without depth
let (aabb_min_ndc, aabb_max_ndc) = (
clusterable_object_aabb_ndc_min.xy().clamp(NDC_MIN, NDC_MAX),
clusterable_object_aabb_ndc_max.xy().clamp(NDC_MIN, NDC_MAX),
);
// pack unadjusted z depth into the vecs
(
aabb_min_ndc.extend(clusterable_object_aabb_view_min.z),
aabb_max_ndc.extend(clusterable_object_aabb_view_max.z),
)
}
// Calculate the intersection of a ray from the eye through the view space position to a z plane
fn line_intersection_to_z_plane(origin: Vec3, p: Vec3, z: f32) -> Vec3 {
let v = p - origin;
let t = (z - Vec3::Z.dot(origin)) / Vec3::Z.dot(v);
origin + t * v
}
// NOTE: Keep in sync with bevy_pbr/src/render/pbr.wgsl
fn view_z_to_z_slice(
cluster_factors: Vec2,
z_slices: u32,
view_z: f32,
is_orthographic: bool,
) -> u32 {
let z_slice = if is_orthographic {
// NOTE: view_z is correct in the orthographic case
((view_z - cluster_factors.x) * cluster_factors.y).floor() as u32
} else {
// NOTE: had to use -view_z to make it positive else log(negative) is nan
(ops::ln(-view_z) * cluster_factors.x - cluster_factors.y + 1.0) as u32
};
// NOTE: We use min as we may limit the far z plane used for clustering to be closer than
// the furthest thing being drawn. This means that we need to limit to the maximum cluster.
z_slice.min(z_slices - 1)
}
fn clip_to_view(view_from_clip: Mat4, clip: Vec4) -> Vec4 {
let view = view_from_clip * clip;
view / view.w
}
fn screen_to_view(screen_size: Vec2, view_from_clip: Mat4, screen: Vec2, ndc_z: f32) -> Vec4 {
let tex_coord = screen / screen_size;
let clip = Vec4::new(
tex_coord.x * 2.0 - 1.0,
(1.0 - tex_coord.y) * 2.0 - 1.0,
ndc_z,
1.0,
);
clip_to_view(view_from_clip, clip)
}
// NOTE: This exploits the fact that a x-plane normal has only x and z components
fn get_distance_x(plane: HalfSpace, point: Vec3A, is_orthographic: bool) -> f32 {
if is_orthographic {
point.x - plane.d()
} else {
// Distance from a point to a plane:
// signed distance to plane = (nx * px + ny * py + nz * pz + d) / n.length()
// NOTE: For a x-plane, ny and d are 0 and we have a unit normal
// = nx * px + nz * pz
plane.normal_d().xz().dot(point.xz())
}
}
// NOTE: This exploits the fact that a z-plane normal has only a z component
fn project_to_plane_z(z_object: Sphere, z_plane: HalfSpace) -> Option<Sphere> {
// p = sphere center
// n = plane normal
// d = n.p if p is in the plane
// NOTE: For a z-plane, nx and ny are both 0
// d = px * nx + py * ny + pz * nz
// = pz * nz
// => pz = d / nz
let z = z_plane.d() / z_plane.normal_d().z;
let distance_to_plane = z - z_object.center.z;
if distance_to_plane.abs() > z_object.radius {
return None;
}
Some(Sphere {
center: Vec3A::from(z_object.center.xy().extend(z)),
// hypotenuse length = radius
// pythagoras = (distance to plane)^2 + b^2 = radius^2
radius: (z_object.radius * z_object.radius - distance_to_plane * distance_to_plane).sqrt(),
})
}
// NOTE: This exploits the fact that a y-plane normal has only y and z components
fn project_to_plane_y(
y_object: Sphere,
y_plane: HalfSpace,
is_orthographic: bool,
) -> Option<Sphere> {
let distance_to_plane = if is_orthographic {
y_plane.d() - y_object.center.y
} else {
-y_object.center.yz().dot(y_plane.normal_d().yz())
};
if distance_to_plane.abs() > y_object.radius {
return None;
}
Some(Sphere {
center: y_object.center + distance_to_plane * y_plane.normal(),
radius: (y_object.radius * y_object.radius - distance_to_plane * distance_to_plane).sqrt(),
})
}