Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 35 additions & 13 deletions crates/bevy_light/src/atmosphere.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,46 @@

use alloc::{borrow::Cow, sync::Arc};
use bevy_asset::{Asset, AssetEvent, AssetId, Handle};
use bevy_camera::Hdr;
use bevy_color::{ColorToComponents, Gray, LinearRgba};
use bevy_ecs::{
component::Component,
lifecycle::HookContext,
message::MessageReader,
system::{Res, ResMut},
world::DeferredWorld,
};
use bevy_image::Image;
use bevy_math::curve::{FunctionCurve, Interval, SampleAutoCurve};
use bevy_math::{ops, Curve, FloatPow, Vec3};
use bevy_platform::collections::HashSet;
use bevy_reflect::TypePath;
use bevy_transform::components::GlobalTransform;
use core::f32::{self, consts::PI};
use smallvec::SmallVec;
use wgpu_types::TextureFormat;

/// Enables atmospheric scattering for an HDR camera.
/// Atmosphere for one planet. The entity's [`GlobalTransform`] is the planet center in world space.
///
/// Add `AtmosphereSettings` to each 3D camera that should use it, the nearest atmosphere is used for rendering.
///
/// 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.
///
/// The scale on [`GlobalTransform`] rescales the planet in world space. Tune it with the radius offset
/// when your scene uses other units, like kilometer-sized scenes.
Comment on lines +29 to +30
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Atmosphere fields say units: m. But according to this comment this is only accurate for scale = Vec3::ONE.

Maybe there should also be a note about transforms that are not x==y==z.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point I never tested non-uniform scale, hopefully it doesn't break too badly 😬 However, regarding the units: m that should stay the same since these units are still expressed in atmosphere-space and not world-space. So scaling the atmosphere doesn't change the meaning of those units, those are still meters. For actually scaling the atmosphere in a physically based way you'd set a smaller inner_radius and outer_radius. Now I realize this might be confusing so maybe it deserves more docs.

#[derive(Clone, Component)]
#[require(Hdr)]
#[require(GlobalTransform::default())]
#[component(on_add = set_default_transform)]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if there was some way to opt out of setting the Transform for users of non-default transform systems. I don't know if there is a nice solution that doesn't add more complexity.

The only thing I can think of is making the Transform and GlobalTransform optional (not required components), and placing the atmosphere at -6000km in Y if they are not present. That way you have to opt in to manual atmosphere positioning and can use your own components?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to better understand the use case, maybe I should test this change with big_space. is that a good example of a non-default transform system? also, would just changing the lines in set_default_transform to GlobalTransform work for decoupling it?

Copy link
Copy Markdown
Member

@aevyrie aevyrie Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to better understand the use case, maybe I should test this change with big_space. is that a good example of a non-default transform system?

Not really, it integrates with Transform. It's more about only depending on GlobalTransform instead of Transform everywhere.

would just changing the lines in set_default_transform to GlobalTransform work for decoupling it?

No, because it would just get overwritten by whatever transform system is present, including bevy's. That's why I was suggesting making the atmosphere positioning entirely opt-in: you get good default behavior for bevy out of the box, and don't have any dependency on Transform for arbitrary downstream use cases - the code only reads GlobalTransform.

pub struct Atmosphere {
/// Radius of the planet
///
/// units: m
pub bottom_radius: f32,
pub inner_radius: f32,

/// Radius at which we consider the atmosphere to 'end' for our
/// calculations (from center of planet)
///
/// units: m
pub top_radius: f32,
pub outer_radius: f32,

/// An approximation of the average albedo (or color, roughly) of the
/// planet's surface. This is used when calculating multiscattering.
Expand All @@ -44,15 +54,27 @@ pub struct Atmosphere {
pub medium: Handle<ScatteringMedium>,
}

fn set_default_transform(mut world: DeferredWorld<'_>, HookContext { entity, .. }: HookContext) {
let Some(inner_radius) = world.get::<Atmosphere>(entity).map(|a| a.inner_radius) else {
unreachable!("on_add hooks guarantee the component is present");
};

if let Some(mut transform) = world.get_mut::<GlobalTransform>(entity)
&& *transform == GlobalTransform::default()
{
*transform = GlobalTransform::from_translation(-Vec3::Y * inner_radius);
}
}

impl Atmosphere {
/// An atmosphere like that of earth. Use this with a [`ScatteringMedium::earth`] handle.
pub fn earth(medium: Handle<ScatteringMedium>) -> Self {
const EARTH_BOTTOM_RADIUS: f32 = 6_360_000.0;
const EARTH_TOP_RADIUS: f32 = 6_460_000.0;
const EARTH_INNER_RADIUS: f32 = 6_360_000.0;
const EARTH_OUTER_RADIUS: f32 = 6_460_000.0;
const EARTH_ALBEDO: Vec3 = Vec3::splat(0.3);
Self {
bottom_radius: EARTH_BOTTOM_RADIUS,
top_radius: EARTH_TOP_RADIUS,
inner_radius: EARTH_INNER_RADIUS,
outer_radius: EARTH_OUTER_RADIUS,
ground_albedo: EARTH_ALBEDO,
medium,
}
Expand All @@ -64,12 +86,12 @@ impl Atmosphere {
///
/// [Seidelmann et al. 2007, Table 4]: https://doi.org/10.1007/s10569-007-9072-y
pub fn mars(medium: Handle<ScatteringMedium>) -> Self {
const MARS_BOTTOM_RADIUS: f32 = 3_389_500.0;
const MARS_TOP_RADIUS: f32 = 3_509_500.0;
const MARS_INNER_RADIUS: f32 = 3_389_500.0;
const MARS_OUTER_RADIUS: f32 = 3_509_500.0;
const MARS_ALBEDO: Vec3 = Vec3::splat(0.1);
Self {
bottom_radius: MARS_BOTTOM_RADIUS,
top_radius: MARS_TOP_RADIUS,
inner_radius: MARS_INNER_RADIUS,
outer_radius: MARS_OUTER_RADIUS,
ground_albedo: MARS_ALBEDO,
medium,
}
Expand Down
48 changes: 26 additions & 22 deletions crates/bevy_pbr/src/atmosphere/bruneton_functions.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,27 @@
bindings::atmosphere,
}

// Bruneton's paper calls the surface and exosphere "bottom radius" and "top radius".
// Our `Atmosphere` struct uses `inner_radius` and `outer_radius` for the same values so naming stays
// meaningful when the atmosphere entity has an arbitrary transform.

// Mapping from view height (r) and zenith cos angle (mu) to UV coordinates in the transmittance LUT
// Assuming r between ground and top atmosphere boundary, and mu= cos(zenith_angle)
// Chosen to increase precision near the ground and to work around a discontinuity at the horizon
// See Bruneton and Neyret 2008, "Precomputed Atmospheric Scattering" section 4
fn transmittance_lut_r_mu_to_uv(atm: Atmosphere, r: f32, mu: f32) -> vec2<f32> {
// Distance along a horizontal ray from the ground to the top atmosphere boundary
let H = sqrt(atm.top_radius * atm.top_radius - atm.bottom_radius * atm.bottom_radius);
// Distance along a horizontal ray from the ground to the top atmosphere boundary
let H = sqrt(atm.outer_radius * atm.outer_radius - atm.inner_radius * atm.inner_radius);

// Distance from a point at height r to the horizon
// ignore the case where r <= atmosphere.bottom_radius
let rho = sqrt(max(r * r - atm.bottom_radius * atm.bottom_radius, 0.0));
// Distance from a point at height r to the horizon
// ignore the case where r <= atmosphere.inner_radius
let rho = sqrt(max(r * r - atm.inner_radius * atm.inner_radius, 0.0));

// Distance from a point at height r to the top atmosphere boundary at zenith angle mu
// Distance from a point at height r to the top atmosphere boundary at zenith angle mu
let d = distance_to_top_atmosphere_boundary(atm, r, mu);

// Minimum and maximum distance to the top atmosphere boundary from a point at height r
let d_min = atm.top_radius - r; // length of the ray straight up to the top atmosphere boundary
// Minimum and maximum distance to the top atmosphere boundary from a point at height r
let d_min = atm.outer_radius - r; // length of the ray straight up to the top atmosphere boundary
let d_max = rho + H; // length of the ray to the top atmosphere boundary and grazing the horizon

let u = (d - d_min) / (d_max - d_min);
Expand All @@ -86,17 +90,17 @@ fn transmittance_lut_r_mu_to_uv(atm: Atmosphere, r: f32, mu: f32) -> vec2<f32> {

// Inverse of the mapping above, mapping from UV coordinates in the transmittance LUT to view height (r) and zenith cos angle (mu)
fn transmittance_lut_uv_to_r_mu(uv: vec2<f32>) -> vec2<f32> {
// Distance to top atmosphere boundary for a horizontal ray at ground level
let H = sqrt(atmosphere.top_radius * atmosphere.top_radius - atmosphere.bottom_radius * atmosphere.bottom_radius);
// Distance to top atmosphere boundary for a horizontal ray at ground level
let H = sqrt(atmosphere.outer_radius * atmosphere.outer_radius - atmosphere.inner_radius * atmosphere.inner_radius);

// Distance to the horizon, from which we can compute r:
// Distance to the horizon, from which we can compute r:
let rho = H * uv.y;
let r = sqrt(rho * rho + atmosphere.bottom_radius * atmosphere.bottom_radius);
let r = sqrt(rho * rho + atmosphere.inner_radius * atmosphere.inner_radius);

// Distance to the top atmosphere boundary for the ray (r,mu), and its minimum
// and maximum values over all mu- obtained for (r,1) and (r,mu_horizon) -
// from which we can recover mu:
let d_min = atmosphere.top_radius - r;
// Distance to the top atmosphere boundary for the ray (r,mu), and its minimum
// and maximum values over all mu- obtained for (r,1) and (r,mu_horizon) -
// from which we can recover mu:
let d_min = atmosphere.outer_radius - r;
let d_max = rho + H;
let d = d_min + uv.x * (d_max - d_min);

Expand All @@ -114,26 +118,26 @@ fn transmittance_lut_uv_to_r_mu(uv: vec2<f32>) -> vec2<f32> {

/// Simplified ray-sphere intersection
/// where:
/// Ray origin, o = [0,0,r] with r <= atmosphere.top_radius
/// Ray origin, o = [0,0,r] with r <= atmosphere.outer_radius
/// mu is the cosine of spherical coordinate theta (-1.0 <= mu <= 1.0)
/// so ray direction in spherical coordinates is [1,acos(mu),0] which needs to be converted to cartesian
/// Direction of ray, u = [0,sqrt(1-mu*mu),mu]
/// Center of sphere, c = [0,0,0]
/// Radius of sphere, r = atmosphere.top_radius
/// Radius of sphere, r = atmosphere.outer_radius
/// This function solves the quadratic equation for line-sphere intersection simplified under these assumptions
fn distance_to_top_atmosphere_boundary(atm: Atmosphere, r: f32, mu: f32) -> f32 {
// ignore the case where r > atm.top_radius
let positive_discriminant = max(r * r * (mu * mu - 1.0) + atm.top_radius * atm.top_radius, 0.0);
// ignore the case where r > atm.outer_radius
let positive_discriminant = max(r * r * (mu * mu - 1.0) + atm.outer_radius * atm.outer_radius, 0.0);
return max(-r * mu + sqrt(positive_discriminant), 0.0);
}

/// Simplified ray-sphere intersection
/// as above for intersections with the ground
fn distance_to_bottom_atmosphere_boundary(r: f32, mu: f32) -> f32 {
let positive_discriminant = max(r * r * (mu * mu - 1.0) + atmosphere.bottom_radius * atmosphere.bottom_radius, 0.0);
let positive_discriminant = max(r * r * (mu * mu - 1.0) + atmosphere.inner_radius * atmosphere.inner_radius, 0.0);
return max(-r * mu - sqrt(positive_discriminant), 0.0);
}

fn ray_intersects_ground(r: f32, mu: f32) -> bool {
return mu < 0.0 && r * r * (mu * mu - 1.0) + atmosphere.bottom_radius * atmosphere.bottom_radius >= 0.0;
return mu < 0.0 && r * r * (mu * mu - 1.0) + atmosphere.inner_radius * atmosphere.inner_radius >= 0.0;
}
37 changes: 20 additions & 17 deletions crates/bevy_pbr/src/atmosphere/functions.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,21 @@ fn sub_uvs_to_unit(val: vec2<f32>, resolution: vec2<f32>) -> vec2<f32> {

fn multiscattering_lut_r_mu_to_uv(r: f32, mu: f32) -> vec2<f32> {
let u = 0.5 + 0.5 * mu;
let v = saturate((r - atmosphere.bottom_radius) / (atmosphere.top_radius - atmosphere.bottom_radius)); //TODO
let v = saturate((r - atmosphere.inner_radius) / (atmosphere.outer_radius - atmosphere.inner_radius)); //TODO
return unit_to_sub_uvs(vec2(u, v), vec2<f32>(settings.multiscattering_lut_size));
}

fn multiscattering_lut_uv_to_r_mu(uv: vec2<f32>) -> vec2<f32> {
let adj_uv = sub_uvs_to_unit(uv, vec2<f32>(settings.multiscattering_lut_size));
let r = mix(atmosphere.bottom_radius, atmosphere.top_radius, adj_uv.y);
let r = mix(atmosphere.inner_radius, atmosphere.outer_radius, adj_uv.y);
let mu = adj_uv.x * 2 - 1;
return vec2(r, mu);
}

fn sky_view_lut_r_mu_azimuth_to_uv(r: f32, mu: f32, azimuth: f32) -> vec2<f32> {
let u = (azimuth * FRAC_2_PI) + 0.5;

let v_horizon = sqrt(r * r - atmosphere.bottom_radius * atmosphere.bottom_radius);
let v_horizon = sqrt(r * r - atmosphere.inner_radius * atmosphere.inner_radius);
let cos_beta = v_horizon / r;
// Using fast_acos_4 for better precision at small angles
// to avoid artifacts at the horizon
Expand All @@ -99,7 +99,7 @@ fn sky_view_lut_uv_to_zenith_azimuth(r: f32, uv: vec2<f32>) -> vec2<f32> {
let azimuth = (adj_uv.x - 0.5) * PI_2;

// Horizon parameters
let v_horizon = sqrt(r * r - atmosphere.bottom_radius * atmosphere.bottom_radius);
let v_horizon = sqrt(r * r - atmosphere.inner_radius * atmosphere.inner_radius);
let cos_beta = v_horizon / r;
let beta = fast_acos_4(cos_beta);
let horizon_zenith = PI - beta;
Expand Down Expand Up @@ -153,8 +153,11 @@ fn sample_sky_view_lut(r: f32, ray_dir_as: vec3<f32>) -> vec3<f32> {

fn ndc_to_camera_dist(ndc: vec3<f32>) -> f32 {
let view_pos = view.view_from_clip * vec4(ndc, 1.0);
let t = length(view_pos.xyz / view_pos.w) * settings.scene_units_to_m;
return t;
let p_view = view_pos.xyz / view_pos.w;
Comment thread
mate-h marked this conversation as resolved.
let p_world = (view.world_from_view * vec4(p_view, 1.0)).xyz;
let p_atmo = (atmosphere.world_to_atmosphere * vec4(p_world, 1.0)).xyz;
let cam_atmo = (atmosphere.world_to_atmosphere * vec4(view.world_position, 1.0)).xyz;
return length(p_atmo - cam_atmo);
}

// RGB channels: total inscattered light along the camera ray to the current sample.
Expand Down Expand Up @@ -189,7 +192,7 @@ const SCATTERING_DENSITY: f32 = 1.0;
// while calling with `component = 1.0` will return the atmosphere's scattering density.
fn sample_density_lut(r: f32, component: f32) -> vec3<f32> {
// sampler clamps to [0, 1] anyways, no need to clamp the altitude
let normalized_altitude = (r - atmosphere.bottom_radius) / (atmosphere.top_radius - atmosphere.bottom_radius);
let normalized_altitude = (r - atmosphere.inner_radius) / (atmosphere.outer_radius - atmosphere.inner_radius);
let uv = vec2(1.0 - normalized_altitude, component);
return textureSampleLevel(medium_density_lut, medium_sampler, uv, 0.0).xyz;
}
Expand All @@ -199,7 +202,7 @@ fn sample_density_lut(r: f32, component: f32) -> vec3<f32> {
// Nonlinear phase mapping to mitigate banding in low-resolution LUTs.
const PHASE_MAPPING_N: f32 = 0.5;
fn sample_scattering_lut(r: f32, neg_LdotV: f32) -> vec3<f32> {
let normalized_altitude = (r - atmosphere.bottom_radius) / (atmosphere.top_radius - atmosphere.bottom_radius);
let normalized_altitude = (r - atmosphere.inner_radius) / (atmosphere.outer_radius - atmosphere.inner_radius);
let phase_uv = 0.5 + 0.5 * sign(neg_LdotV) * (1.0 - pow(1.0 - abs(neg_LdotV), PHASE_MAPPING_N));
let uv = vec2(1.0 - normalized_altitude, phase_uv);
return textureSampleLevel(medium_scattering_lut, medium_sampler, uv, 0.0).xyz;
Expand Down Expand Up @@ -259,10 +262,10 @@ fn sample_sun_radiance(ray_dir_ws: vec3<f32>) -> vec3<f32> {
}

fn calculate_visible_sun_ratio(atmosphere: Atmosphere, r: f32, mu: f32, sun_angular_size: f32) -> f32 {
let bottom_radius = atmosphere.bottom_radius;
let inner_radius = atmosphere.inner_radius;
// Calculate the angle between horizon and sun center
// Invert the horizon angle calculation to fix shading direction
let horizon_cos = -sqrt(1.0 - (bottom_radius * bottom_radius) / (r * r));
let horizon_cos = -sqrt(1.0 - (inner_radius * inner_radius) / (r * r));
let horizon_angle = fast_acos_4(horizon_cos);
let sun_zenith_angle = fast_acos_4(mu);

Expand All @@ -286,7 +289,7 @@ fn calculate_visible_sun_ratio(atmosphere: Atmosphere, r: f32, mu: f32, sun_angu

/// Clamp a position to the planet surface (with a small epsilon) to avoid underground artifacts.
fn clamp_to_surface(atmosphere: Atmosphere, position: vec3<f32>) -> vec3<f32> {
let min_radius = atmosphere.bottom_radius + EPSILON;
let min_radius = atmosphere.inner_radius + EPSILON;
let r = length(position);
if r < min_radius {
let up = normalize(position);
Expand All @@ -304,8 +307,8 @@ fn max_atmosphere_distance(r: f32, mu: f32) -> f32 {

/// Returns the observer's position in the atmosphere
fn get_view_position() -> vec3<f32> {
var world_pos = view.world_position * settings.scene_units_to_m + vec3(0.0, atmosphere.bottom_radius, 0.0);
return clamp_to_surface(atmosphere, world_pos);
let atmo_pos = (atmosphere.world_to_atmosphere * vec4(view.world_position, 1.0)).xyz;
return clamp_to_surface(atmosphere, atmo_pos);
}

// We assume the `up` vector at the view position is the y axis, since the world is locally flat/level.
Expand Down Expand Up @@ -385,16 +388,16 @@ struct RaymarchSegment {

fn get_raymarch_segment(r: f32, mu: f32) -> RaymarchSegment {
// Get both intersection points with atmosphere
let atmosphere_intersections = ray_sphere_intersect(r, mu, atmosphere.top_radius);
let ground_intersections = ray_sphere_intersect(r, mu, atmosphere.bottom_radius);
let atmosphere_intersections = ray_sphere_intersect(r, mu, atmosphere.outer_radius);
let ground_intersections = ray_sphere_intersect(r, mu, atmosphere.inner_radius);

var segment: RaymarchSegment;

if r < atmosphere.bottom_radius {
if r < atmosphere.inner_radius {
// Inside planet - start from bottom of atmosphere
segment.start = ground_intersections.y; // Use second intersection point with ground
segment.end = atmosphere_intersections.y;
} else if r < atmosphere.top_radius {
} else if r < atmosphere.outer_radius {
// Inside atmosphere
segment.start = 0.0;
segment.end = select(atmosphere_intersections.y, ground_intersections.x, ray_intersects_ground(r, mu));
Expand Down
Loading