diff --git a/crates/bevy_pbr/src/atmosphere/aerial_view_lut.wgsl b/crates/bevy_pbr/src/atmosphere/aerial_view_lut.wgsl index efacc3de04a43..1f34794101759 100644 --- a/crates/bevy_pbr/src/atmosphere/aerial_view_lut.wgsl +++ b/crates/bevy_pbr/src/atmosphere/aerial_view_lut.wgsl @@ -16,7 +16,9 @@ fn main(@builtin(global_invocation_id) idx: vec3) { if any(idx.xy > settings.aerial_view_lut_size.xy) { return; } - let uv = (vec2(idx.xy) + 0.5) / vec2(settings.aerial_view_lut_size.xy); + // Use global invocation ID as pixel coordinates for jittering + let pixel_coords = vec2(idx.xy); + let uv = (pixel_coords + 0.5) / vec2(settings.aerial_view_lut_size.xy); let ray_dir = uv_to_ray_direction(uv); let world_pos = get_view_position(); @@ -45,7 +47,7 @@ fn main(@builtin(global_invocation_id) idx: vec3) { let sample_transmittance = exp(-sample_optical_depth); // evaluate one segment of the integral - var inscattering = sample_local_inscattering(scattering, ray_dir, sample_pos); + var inscattering = sample_local_inscattering(scattering, ray_dir, sample_pos, pixel_coords); // Analytical integration of the single scattering term in the radiance transfer equation let s_int = (inscattering - inscattering * sample_transmittance) / max(extinction, MIN_EXTINCTION); diff --git a/crates/bevy_pbr/src/atmosphere/bindings.wgsl b/crates/bevy_pbr/src/atmosphere/bindings.wgsl index 5c8fc81132674..dc1381a282ace 100644 --- a/crates/bevy_pbr/src/atmosphere/bindings.wgsl +++ b/crates/bevy_pbr/src/atmosphere/bindings.wgsl @@ -22,3 +22,15 @@ @group(0) @binding(10) var sky_view_lut: texture_2d; @group(0) @binding(11) var aerial_view_lut: texture_3d; @group(0) @binding(12) var atmosphere_lut_sampler: sampler; + +// Cloud shadow map (front depth + extinction stats) generated by a compute pass. +// Encodes: +// - R: front depth (meters) from the light-volume near plane +// - G: mean extinction (1/m) +// - B: max optical depth (unitless) +@group(0) @binding(17) var cloud_shadow_map: texture_2d; + +#ifdef CLOUDS_ENABLED +// Spatio-temporal blue noise for per-frame stratification +@group(0) @binding(19) var stbn_texture: texture_2d_array; +#endif diff --git a/crates/bevy_pbr/src/atmosphere/cloud_shadow_filter.wgsl b/crates/bevy_pbr/src/atmosphere/cloud_shadow_filter.wgsl new file mode 100644 index 0000000000000..b09fdb367a4b9 --- /dev/null +++ b/crates/bevy_pbr/src/atmosphere/cloud_shadow_filter.wgsl @@ -0,0 +1,136 @@ +#define_import_path bevy_pbr::atmosphere::cloud_shadow_filter + +#import bevy_pbr::atmosphere::bindings::{settings, atmosphere_lut_sampler} + +// Source cloud shadow map (rgba16f sampled as float). +// Layout matches `bindings.wgsl`: +// - R: front depth (km) +// - G: mean extinction (1/m) +// - B: max optical depth (unitless) +@group(0) @binding(17) var cloud_shadow_src: texture_2d; + +// Destination ping-pong texture. +@group(0) @binding(13) var cloud_shadow_dst: texture_storage_2d; + +fn load_shadow(coord: vec2) -> vec3 { + // Sample at texel centers using linear filtering (sampler clamps to edge). + let size = vec2(settings.cloud_shadow_map_size); + let uv = (vec2(coord) + vec2(0.5)) / size; + return textureSampleLevel(cloud_shadow_src, atmosphere_lut_sampler, uv, 0.0).rgb; +} + +fn sort2(a: ptr, b: ptr) { + if ((*a) > (*b)) { + let t = (*a); + (*a) = (*b); + (*b) = t; + } +} + +// Median of 9 values (3x3). Used to denoise front-depth without the “expanding rings” +// you get from iterative min/max dilate/erode passes. +fn median9( + v0: f32, v1: f32, v2: f32, + v3: f32, v4: f32, v5: f32, + v6: f32, v7: f32, v8: f32 +) -> f32 { + var a = v0; var b = v1; var c = v2; + var d = v3; var e = v4; var f = v5; + var g = v6; var h = v7; var i = v8; + + sort2(&a, &b); sort2(&d, &e); sort2(&g, &h); + sort2(&b, &c); sort2(&e, &f); sort2(&h, &i); + sort2(&a, &b); sort2(&d, &e); sort2(&g, &h); + + sort2(&a, &d); sort2(&d, &g); + sort2(&b, &e); sort2(&e, &h); + sort2(&c, &f); sort2(&f, &i); + + sort2(&c, &e); sort2(&e, &g); + sort2(&c, &e); sort2(&e, &g); + + return e; +} + +// Spatial filter in transmittance space for max optical depth (Unreal-style idea): +// - Filter visibility V = exp(-OD) with a small kernel +// - Convert back: OD = -log(V) +// Keep depth mostly unfiltered to avoid precision / convergence issues. +@compute +// Keep in sync with `dispatch_2d()` in `atmosphere/node.rs` (currently assumes 16x16 workgroups). +@workgroup_size(16, 16, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + let size = settings.cloud_shadow_map_size; + if (gid.x >= size.x || gid.y >= size.y) { + return; + } + + let p = vec2(gid.xy); + let center = load_shadow(p); + + // 3x3 tent-ish kernel weights: + // 1 2 1 + // 2 4 2 + // 1 2 1 + // sum = 16 + var sum_mean_ext = 0.0; + var sum_vis = 0.0; + var sum_w = 0.0; + + for (var dy: i32 = -1; dy <= 1; dy += 1) { + for (var dx: i32 = -1; dx <= 1; dx += 1) { + let w = select(1.0, 2.0, (dx == 0) || (dy == 0)); + let tap = load_shadow(p + vec2(dx, dy)); + let mean_ext = tap.y; + let od = max(0.0, tap.z); + let vis = exp(-od); + sum_mean_ext += w * mean_ext; + sum_vis += w * vis; + sum_w += w; + } + } + + let mean_ext_out = sum_mean_ext / max(1.0, sum_w); + let vis_out = sum_vis / max(1.0, sum_w); + // Avoid -inf / NaN from log(0). + let od_out = select(100.0, -log(max(vis_out, 1e-6)), vis_out > 0.0); + + // Depth denoise: + // Use a conservative median filter on valid depth samples. This reduces noisy “salt-and-pepper” + // in the front-depth channel without the runaway growth you can get from repeated dilation. + // + // NOTE: This pass can run multiple iterations (ping-pong). To keep it stable over multiple + // passes, we only blend *partially* toward the median. + const DEPTH_INVALID: f32 = 1.0e9; + const DEPTH_DENOISE_ALPHA: f32 = 1.0; + + let d00 = load_shadow(p + vec2(-1, -1)).x; + let d10 = load_shadow(p + vec2( 0, -1)).x; + let d20 = load_shadow(p + vec2( 1, -1)).x; + let d01 = load_shadow(p + vec2(-1, 0)).x; + let d11 = center.x; + let d21 = load_shadow(p + vec2( 1, 0)).x; + let d02 = load_shadow(p + vec2(-1, 1)).x; + let d12 = load_shadow(p + vec2( 0, 1)).x; + let d22 = load_shadow(p + vec2( 1, 1)).x; + + // Treat non-positive depths as "invalid / no cloud" so they don't dominate the median. + let v00 = select(DEPTH_INVALID, d00, d00 > 0.0); + let v10 = select(DEPTH_INVALID, d10, d10 > 0.0); + let v20 = select(DEPTH_INVALID, d20, d20 > 0.0); + let v01 = select(DEPTH_INVALID, d01, d01 > 0.0); + let v11 = select(DEPTH_INVALID, d11, d11 > 0.0); + let v21 = select(DEPTH_INVALID, d21, d21 > 0.0); + let v02 = select(DEPTH_INVALID, d02, d02 > 0.0); + let v12 = select(DEPTH_INVALID, d12, d12 > 0.0); + let v22 = select(DEPTH_INVALID, d22, d22 > 0.0); + + let med = median9(v00, v10, v20, v01, v11, v21, v02, v12, v22); + let depth_med = select(center.x, med, med < 1.0e8); + let depth_out = mix(center.x, depth_med, DEPTH_DENOISE_ALPHA); + + let out_rgb = vec3(depth_out, max(0.0, mean_ext_out), max(0.0, od_out)); + textureStore(cloud_shadow_dst, p, vec4(out_rgb, 0.0)); +} + + diff --git a/crates/bevy_pbr/src/atmosphere/cloud_shadow_map.wgsl b/crates/bevy_pbr/src/atmosphere/cloud_shadow_map.wgsl new file mode 100644 index 0000000000000..536f5b47dba34 --- /dev/null +++ b/crates/bevy_pbr/src/atmosphere/cloud_shadow_map.wgsl @@ -0,0 +1,223 @@ +#define_import_path bevy_pbr::atmosphere::cloud_shadow_map + +// Computes an Unreal-style cloud shadow map storing: +// R: front depth (kilometers) from the light-volume near plane (stored in km to fit fp16 range) +// G: mean extinction (1/m) +// B: max optical depth (unitless) +// +// This is later sampled at arbitrary world points to cheaply compute +// transmittance: T = exp(-tau) where tau = mean_ext * (d_sample - d_front), +// clamped to max optical depth. + +#import bevy_pbr::atmosphere::bindings::{atmosphere, settings, view, lights} +#import bevy_pbr::atmosphere::clouds::{ + cloud_layer_segment, + get_cloud_medium_density, + get_cloud_scattering_coeff, + get_cloud_absorption_coeff, +} + +@group(0) @binding(19) var stbn_texture: texture_2d_array; +@group(0) @binding(13) var out_cloud_shadow_map: texture_storage_2d; + +const EPSILON_M: f32 = 1.0; + +// Simple deterministic hash utilities (no frame index required). +// Used to jitter integration per shadow-map texel to mitigate banding. +fn hash_u32(x_in: u32) -> u32 { + var x = x_in; + x ^= x >> 16u; + x *= 0x7feb352du; + x ^= x >> 15u; + x *= 0x846ca68bu; + x ^= x >> 16u; + return x; +} + +fn hash_2d_to_01(p: vec2, salt: u32) -> f32 { + let h = hash_u32(p.x ^ (hash_u32(p.y) + 0x9e3779b9u) ^ salt); + // Convert to [0,1). 2^32 as f32 is representable. + return f32(h) * (1.0 / 4294967296.0); +} + +fn safe_normalize(v: vec3) -> vec3 { + let l2 = dot(v, v); + if (l2 <= 1e-12) { + return vec3(0.0, 1.0, 0.0); + } + return v * inverseSqrt(l2); +} + +fn clamp_to_surface(position: vec3) -> vec3 { + let min_radius = atmosphere.inner_radius + EPSILON_M; + let r = length(position); + if (r < min_radius) { + let up = safe_normalize(position); + return up * min_radius; + } + return position; +} + +fn get_view_position() -> vec3 { + // Matches `functions.wgsl::get_view_position()` to keep anchor consistent. + let atmo_pos = (atmosphere.world_to_atmosphere * vec4(view.world_position, 1.0)).xyz; + return clamp_to_surface(atmo_pos); +} + +struct LightBasis { + x: vec3, + y: vec3, +}; + +fn build_light_basis(light_dir: vec3) -> LightBasis { + // Pick a vector not parallel to `light_dir`, then build an orthonormal basis. + // IMPORTANT: keep this selection stable; switching too early causes the basis to “flip” + // when the light gets moderately close to zenith, which looks like the shadow map drifts/rotates. + let world_up = vec3(0.0, 1.0, 0.0); + let a = select(world_up, vec3(1.0, 0.0, 0.0), abs(dot(light_dir, world_up)) > 0.999); + let x = safe_normalize(cross(a, light_dir)); + let y = cross(light_dir, x); + return LightBasis(x, y); +} + +fn snap_anchor_to_texel_grid(anchor: vec3, basis: LightBasis, extent: f32, size: vec2) -> vec3 { + // Snap the anchor in the shadow-map XY plane to stabilize the map under camera motion. + // This mirrors Unreal's stabilized shadow-map anchor strategy. + let res = vec2(size); + let texel_size = (2.0 * extent) / max(res, vec2(1.0)); + + let ax = dot(anchor, basis.x); + let ay = dot(anchor, basis.y); + + let snapped_ax = round(ax / texel_size.x) * texel_size.x; + let snapped_ay = round(ay / texel_size.y) * texel_size.y; + + let dx = snapped_ax - ax; + let dy = snapped_ay - ay; + return anchor + basis.x * dx + basis.y * dy; +} + +@compute +@workgroup_size(16, 16, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + let size = settings.cloud_shadow_map_size; + if (gid.x >= size.x || gid.y >= size.y) { + return; + } + + // If there is no directional light, write "no clouds". + if (lights.n_directional_lights == 0u) { + let half_depth = settings.cloud_shadow_map_extent * 2.0; + let far_depth_km = (2.0 * half_depth) * 0.001; + textureStore(out_cloud_shadow_map, vec2(gid.xy), vec4(far_depth_km, 0.0, 0.0, 0.0)); + return; + } + + // IMPORTANT: trace direction must point from the light toward the scene (light -> surface), + // matching Unreal's convention. Bevy's `direction_to_light` points from the point toward the + // light (surface -> light), so we negate it here. + let trace_dir = safe_normalize(-lights.directional_lights[0].direction_to_light); + let basis = build_light_basis(trace_dir); + + let extent = settings.cloud_shadow_map_extent; + // Unreal-style: derive depth range from extent (prevents pathological far near-plane positions at low sun). + let half_depth = extent * 2.0; + let strength = settings.cloud_shadow_map_strength; + + // Map texel -> light-space XY in [-extent, extent]. + let uv = (vec2(gid.xy) + vec2(0.5)) / vec2(size); + let xy = (uv - vec2(0.5)) * (2.0 * extent); + + var anchor = get_view_position(); + anchor = snap_anchor_to_texel_grid(anchor, basis, extent, size); + let ray_origin = anchor + basis.x * xy.x + basis.y * xy.y - trace_dir * half_depth; + let max_t = 2.0 * half_depth; + let max_t_km = max_t * 0.001; + + // Intersect the ray with the cloud layer shell and clamp to our light volume depth. + let seg = cloud_layer_segment(ray_origin, trace_dir); + if (seg.z < 0.5) { + textureStore(out_cloud_shadow_map, vec2(gid.xy), vec4(max_t_km, 0.0, 0.0, 0.0)); + return; + } + + let t_start = max(0.0, seg.x); + let t_end = min(seg.y, max_t); + if (t_end <= t_start) { + textureStore(out_cloud_shadow_map, vec2(gid.xy), vec4(max_t_km, 0.0, 0.0, 0.0)); + return; + } + + // More samples when the sun is near the horizon (Unreal-style): + // the ray travels a lot longer through the cloud shell, so a fixed sample count can miss + // density entirely, producing sweeping “no shadow” bands as the sun moves. + // + // Unreal uses: + // HorizonFactor = clamp(0.2 / abs(dot(PlanetUp, -LightDir)), 0, 1) + // SampleCount *= lerp(1, HorizonMultiplier, HorizonFactor) + // + // Here we approximate PlanetUp using the anchor up vector (good enough for ground/near-ground views). + let base_n = max(1u, settings.cloud_shadow_map_samples); + let anchor_up = safe_normalize(anchor); + let mu = abs(dot(anchor_up, trace_dir)); + let horizon_factor = clamp(0.2 / max(mu, 1e-3), 0.0, 1.0); + const HORIZON_MULT: f32 = 2.0; + let n = min(512u, max(1u, u32(ceil(f32(base_n) * mix(1.0, HORIZON_MULT, horizon_factor))))); + // let n = 24u; + let dt = (t_end - t_start) / f32(n); + + let sigma_t_per_density = get_cloud_scattering_coeff() + get_cloud_absorption_coeff(); + + var front_depth = max_t; + var sum_ext = 0.0; + var count_ext = 0.0; + var max_optical_depth = 0.0; + + // Avoid “phantom” front depths from tiny numerical densities, which can cast shadows + // in empty space on the sun-facing side of clouds. + // Units: optical depth is unitless. + const HIT_OPTICAL_DEPTH_THRESHOLD: f32 = 1e-3; + + for (var i = 0u; i < n; i += 1u) { + // Jittered + stratified: one sample per stratum, with *per-step* jitter. + // Per-step jitter reduces “streaky” structure compared to using a single phase shift + // across all steps in a texel. + var j: f32; + let stbn_dims = textureDimensions(stbn_texture); + if (all(stbn_dims > vec2(1u))) { + let stbn_layers = textureNumLayers(stbn_texture); + let stbn_layer = i32(view.frame_count % u32(stbn_layers)); + let stbn_noise = textureLoad(stbn_texture, vec2(gid.xy) % vec2(stbn_dims), stbn_layer, 0); + // j = fract(stbn_noise.r + f32(i) * 0.618033988749895); + j = fract(0.0 + f32(i) * 0.618033988749895); + } else { + j = hash_2d_to_01(gid.xy, 0x1000u + i); + } + let t = t_start + (f32(i) + j) * dt; + let p = ray_origin + trace_dir * t; + let r = length(p); + let density = get_cloud_medium_density(r, p); + if (density > 0.0) { + let extinction = density * sigma_t_per_density; // 1/m + let od = extinction * dt; + if (od > HIT_OPTICAL_DEPTH_THRESHOLD) { + if (front_depth == max_t) { + front_depth = t; + } + sum_ext += extinction; + count_ext += 1.0; + max_optical_depth += od; + } + } + } + + let mean_ext = select(0.0, sum_ext / count_ext, count_ext > 0.0); + + // Apply user strength scaling (matches Unreal's "strength" behavior). + let out_mean_ext = mean_ext * strength; + let out_max_od = max_optical_depth * strength; + + textureStore(out_cloud_shadow_map, vec2(gid.xy), vec4(front_depth * 0.001, out_mean_ext, out_max_od, 0.0)); +} + + diff --git a/crates/bevy_pbr/src/atmosphere/cloud_shadow_temporal.wgsl b/crates/bevy_pbr/src/atmosphere/cloud_shadow_temporal.wgsl new file mode 100644 index 0000000000000..5160640d0ee2c --- /dev/null +++ b/crates/bevy_pbr/src/atmosphere/cloud_shadow_temporal.wgsl @@ -0,0 +1,123 @@ +#define_import_path bevy_pbr::atmosphere::cloud_shadow_temporal + +// Temporal filter for cloud shadow map. Reduces edge flickering by blending +// with history. Based on Unreal's MainShadowTemporalProcessCS and +// three-geospatial's ShadowResolveMaterial. +// +// Layout matches cloud_shadow_map: +// - R: front depth (km) from light-volume near plane +// - G: mean extinction (1/m) +// - B: max optical depth (unitless) +// +// Note: We do NOT import bindings::settings here because it would conflict +// with our binding 1 (curr_cloud_shadow). Size is passed via temporal_params. + +@group(0) @binding(3) var prev_cloud_sampler: sampler; + +@group(0) @binding(0) var temporal_params: CloudShadowTemporalParams; +// Traced output (storage, use textureLoad) +@group(0) @binding(1) var curr_cloud_shadow: texture_storage_2d; +// History (sampled for bilinear) +@group(0) @binding(2) var prev_cloud_shadow: texture_2d; +@group(0) @binding(4) var out_cloud_shadow: texture_storage_2d; + +struct CloudShadowTemporalParams { + curr_anchor: vec3, + curr_light_dir: vec3, + curr_basis_x: vec3, + curr_basis_y: vec3, + prev_anchor: vec3, + prev_light_dir: vec3, + prev_basis_x: vec3, + prev_basis_y: vec3, + extent: f32, + temporal_alpha: f32, + history_valid: u32, + anchor_moved: u32, + size: vec2, +} + +@compute +@workgroup_size(8, 8, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + let size = temporal_params.size; + if (gid.x >= size.x || gid.y >= size.y) { + return; + } + + let coord = vec2(gid.xy); + let size_f = vec2(size); + let uv = (vec2(gid.xy) + vec2(0.5)) / size_f; + let extent = temporal_params.extent; + let half_depth = extent * 2.0; + + // Current frame data (traced output) + let curr_data = textureLoad(curr_cloud_shadow, coord).rgb; + + var filtered_data = curr_data; + + if (temporal_params.history_valid != 0u) { + // Clamp-to-neighborhood (AABB): compute min/max of G and B in 3x3 neighborhood. + // Prevents history from contributing outlier values at edges, reducing jitter. + // Based on Unreal VolumetricRenderTarget and TSR/TAA. + var g_min = curr_data.g; + var g_max = curr_data.g; + var b_min = curr_data.b; + var b_max = curr_data.b; + let size_i = vec2(size); + for (var dy = -1; dy <= 1; dy += 1) { + for (var dx = -1; dx <= 1; dx += 1) { + let nc = clamp(coord + vec2(dx, dy), vec2(0, 0), size_i - vec2(1, 1)); + let n = textureLoad(curr_cloud_shadow, nc).rgb; + g_min = min(g_min, n.g); + g_max = max(g_max, n.g); + b_min = min(b_min, n.b); + b_max = max(b_max, n.b); + } + } + + // Reproject: current texel UV -> world position -> previous UV + // World position at near plane for this texel (Unreal-style) + let xy = (uv - vec2(0.5)) * (2.0 * extent); + let world_pos = temporal_params.curr_anchor + + temporal_params.curr_basis_x * xy.x + + temporal_params.curr_basis_y * xy.y + - temporal_params.curr_light_dir * half_depth; + + // Map world position to previous frame's UV + let prev_rel = world_pos - temporal_params.prev_anchor; + let prev_x = dot(prev_rel, temporal_params.prev_basis_x); + let prev_y = dot(prev_rel, temporal_params.prev_basis_y); + let prev_uv = vec2(prev_x, prev_y) / (2.0 * extent) + vec2(0.5); + + // Reject history when reprojection is out of bounds (three-geospatial style) + let in_bounds = prev_uv.x >= 0.0 && prev_uv.x <= 1.0 && prev_uv.y >= 0.0 && prev_uv.y <= 1.0; + if (in_bounds) { + var prev = textureSampleLevel(prev_cloud_shadow, prev_cloud_sampler, prev_uv, 0.0).rgb; + + // Depth reprojection when anchor moved (Unreal-style) + // prev.r is distance from near plane along the ray; near plane is anchor - light_dir * half_depth. + if (temporal_params.anchor_moved != 0u) { + let prev_depth_m = prev.r * 1000.0; + let prev_ray_origin = temporal_params.prev_anchor - temporal_params.prev_light_dir * half_depth; + let prev_pos = prev_ray_origin + temporal_params.prev_light_dir * prev_depth_m; + let curr_ray_origin = temporal_params.curr_anchor - temporal_params.curr_light_dir * half_depth; + let curr_depth_from_near = dot(prev_pos - curr_ray_origin, temporal_params.curr_light_dir); + prev.r = curr_depth_from_near * 0.001; + } + + // Clamp history to neighborhood AABB before blending (reduces edge jitter) + prev.g = clamp(prev.g, g_min, g_max); + prev.b = clamp(prev.b, b_min, b_max); + + // Temporal blend: prev + alpha * (curr - prev) + // Do NOT filter depth (Unreal: precision/convergence issues) + let alpha = temporal_params.temporal_alpha; + filtered_data.g = prev.g + alpha * (curr_data.g - prev.g); + filtered_data.b = prev.b + alpha * (curr_data.b - prev.b); + filtered_data.r = curr_data.r; + } + } + + textureStore(out_cloud_shadow, coord, vec4(max(filtered_data, vec3(0.0)), 0.0)); +} diff --git a/crates/bevy_pbr/src/atmosphere/clouds.wgsl b/crates/bevy_pbr/src/atmosphere/clouds.wgsl new file mode 100644 index 0000000000000..fa3999324a28e --- /dev/null +++ b/crates/bevy_pbr/src/atmosphere/clouds.wgsl @@ -0,0 +1,457 @@ +// Cloud rendering functions using 3D FBM noise +#define_import_path bevy_pbr::atmosphere::clouds + +#import bevy_render::maths::{PI, ray_sphere_intersect} +#import bevy_pbr::utils::interleaved_gradient_noise +#import bevy_pbr::atmosphere::{ + types::{Atmosphere, AtmosphereSettings}, + bindings::{settings, atmosphere}, + functions::{ + get_local_r, + }, +} + +struct CloudLayer { + cloud_layer_start: f32, + cloud_layer_end: f32, + cloud_density: f32, + cloud_absorption: f32, + cloud_scattering: f32, + noise_scale: f32, + noise_offset: vec3, + detail_noise_scale: f32, + detail_strength: f32, +} + +@group(0) @binding(14) var cloud_layer: CloudLayer; +@group(0) @binding(15) var noise_texture_2d: texture_2d; +@group(0) @binding(16) var noise_sampler_3d: sampler; +@group(0) @binding(18) var perlin_worley_noise_3d: texture_3d; + +fn safe_normalize(v: vec3) -> vec3 { + let l2 = dot(v, v); + if (l2 <= 1e-12) { + return vec3(0.0, 1.0, 0.0); + } + return v * inverseSqrt(l2); +} + +// Sample packed cloud noise (RGBA) at a given scale. +// - R: coverage (macro placement) +// - G: bottom type (vertical profile shaping) +// - B: top type (vertical profile shaping) +// - A: detail (erosion / "up-rez") +fn sample_cloud_noise_rgba_at_scale(world_pos: vec3, noise_scale: f32) -> vec4 { + // Convert world position to noise texture coordinates in XZ. + // Apply a small rotation to break up axis-aligned stretching artifacts. + let rot = mat2x2( + 0.8660254, -0.5, + 0.5, 0.8660254 + ); // 30 degrees + let xz = rot * world_pos.xz; + let uv = (xz + cloud_layer.noise_offset.xz) / noise_scale; + return textureSampleLevel(noise_texture_2d, noise_sampler_3d, uv, 0.0); +} + +fn sample_perlin_worley_3d(world_pos: vec3, h: f32) -> vec4 { + // Map world XZ into texture space, and use normalized layer height as the 3rd dimension. + // A small rotation helps break up axis-aligned repetition. + let rot = mat2x2( + 0.8660254, -0.5, + 0.5, 0.8660254 + ); + let xz = rot * world_pos.xz; + // Scale is tied to the existing noise scale for now to keep tuning surface area small. + let uv = (xz + cloud_layer.noise_offset.xz) / 1000.0; + let w = h + cloud_layer.noise_offset.y * 1e-5; + let uvw = vec3(uv, w); + return textureSampleLevel(perlin_worley_noise_3d, noise_sampler_3d, uvw, 0.0); +} + +/// Get cloud scattering coefficient per unit density +fn get_cloud_scattering_coeff() -> f32 { + return cloud_layer.cloud_scattering; +} + +/// Get cloud absorption coefficient per unit density +fn get_cloud_absorption_coeff() -> f32 { + return cloud_layer.cloud_absorption; +} + +// PERF/DEBUG: Hardcoded repeating sphere SDF volume (no texture fetches). +// +// This is intended for debugging raymarching performance and lighting/shadowing: +// - Deterministic shape +// - Cheap evaluation +// - Soft boundary (stable under undersampling) +// +// All parameters are intentionally hardcoded in shader code. + +const CELL_SIZE_M: f32 = 4000.0; // spacing between sphere centers (meters) +const RADIUS_M: f32 = 750.0; // sphere radius (meters) +const SOFTNESS_M: f32 = 10.0; // boundary softness (meters) + +// Debug toggle: when enabled, use a single sphere centered in the middle of the cloud layer +// instead of a repeating cell volume. This is useful to validate coordinate systems and +// shadow-map alignment. +const CLOUD_DEBUG_SINGLE_SPHERE: bool = false; + +// Primary cloud shape toggle: +// - false: use the debug sphere SDF volume (single/repeating) for lighting/debugging +// - true : use noise-based cumulus shaping (envelope + FBM erosion) +const CLOUD_USE_NOISE_SHAPE: bool = true; + +// --- Cumulus tuning knobs (shader constants for now) --- +// Increase density and sharpen cloud borders by tightening the coverage threshold band +// and applying a contrast curve. +const CUMULUS_EDGE_THRESHOLD: f32 = 0.75; // higher => fewer, more isolated clouds +const CUMULUS_EDGE_WIDTH: f32 = 0.01; // smaller => sharper border +const CUMULUS_EDGE_SHARPNESS: f32 = 1.0; // >1 => steeper transition to "fully inside cloud" + +fn debug_sphere_center() -> vec3 { + // Place the sphere at scene "origin" in XZ, and centered vertically in the cloud layer. + // Note: the atmosphere coordinate system is planet-centered; y points "up" locally. + let center_r = 0.5 * (cloud_layer.cloud_layer_start + cloud_layer.cloud_layer_end); + return vec3(0.0, center_r, 0.0); +} + +// --- Noise-based cumulus shaping (NUBIS-like envelope method) --- +// +// We use: +// - a 2D "coverage" field (noise texture) mapped over XZ +// - an envelope profile over height (bottom/top gradients) +// - FBM-like erosion/detail sampled from the same 2D noise with different scales/warps +// +// This keeps the binding footprint unchanged while producing much more cloud-like shapes +// than the sphere debug volume. + +fn remap(x: f32, a: f32, b: f32, c: f32, d: f32) -> f32 { + let t = clamp((x - a) / max(1e-6, b - a), 0.0, 1.0); + return mix(c, d, t); +} + +// --- Vertical profile method (NUBIS-style) --- +// +// In Nubis/UE the "top type" and "bottom type" are used to sample 2D profile lookup textures: +// - x axis: type +// - y axis: height in layer +// +// We don't have those LUT textures bound yet, so we approximate them with analytic curves that are: +// - distinct for top vs bottom +// - smoothly varying with type +// +// If/when we add real LUT textures, these become simple texture samples. +fn cloud_bottom_profile(h: f32, bottom_type: f32) -> f32 { + // Bottom profile: controls how quickly density builds from the base. + // Type 0: thin base / slow build. + // Type 1: thick base / fast build. + let knee = mix(0.35, 0.08, bottom_type); + let x = smoothstep(0.0, knee, h); + let exp = mix(2.4, 0.75, bottom_type); + return pow(x, exp); +} + +fn cloud_top_profile(h: f32, top_type: f32) -> f32 { + // Top profile: controls how quickly density fades near the cap. + // Type 0: hard cap (more anvil-ish) + // Type 1: soft, billowy fade + let t = 1.0 - h; + let knee = mix(0.10, 0.45, top_type); + let x = smoothstep(0.0, knee, t); + let exp = mix(0.9, 3.2, top_type); + return pow(x, exp); +} + +fn sample_cumulus_shape(r: f32, world_pos: vec3) -> f32 { + // Cloud layer normalized height. + let layer_thickness = cloud_layer.cloud_layer_end - cloud_layer.cloud_layer_start; + let height_in_layer = r - cloud_layer.cloud_layer_start; + let h = clamp(height_in_layer / max(1.0, layer_thickness), 0.0, 1.0); + + // NUBIS-like Vertical Profile Method + // We start with 2D NDF-style fields: + // - coverage: controls where clouds form + // - bottom_type/top_type: controls the vertical profile shape + // + // Then: + // dimensional_profile = vertical_profile * coverage + let macro_noise = sample_cloud_noise_rgba_at_scale(world_pos, cloud_layer.noise_scale); + var cov = macro_noise.r; + let bottom_type = macro_noise.g; + let top_type = macro_noise.b; + + // Vertical profile (height-dependent density envelope). + let bottom_profile = cloud_bottom_profile(h, bottom_type); + let top_profile = cloud_top_profile(h, top_type); + let vertical_profile = clamp(bottom_profile * top_profile, 0.0, 1.0); + cov -= pow(h, 6.0 * top_type); + + // // Keep the existing 2D detail channel for edge modulation / small-scale erosion. + let micro = sample_cloud_noise_rgba_at_scale(world_pos, cloud_layer.detail_noise_scale); + let detail_n = micro.a; + let edge_mod = (detail_n - 0.5) * smoothstep(0.0, 1.0, h); + cov += edge_mod * 1.0; + + // 3D Perlin–Worley shaping: gives true volumetric breakup and avoids the "vertical walls" + // you get from purely 2D coverage fields. + let pw = sample_perlin_worley_3d(world_pos, h); + let worley = pw.yzw; + // Combine Worley FBM bands (matches the reference weights). + let wfbm = worley.x * 0.625 + worley.y * 0.125 + worley.z * 0.25; + var shape3 = remap(pw.x, wfbm - 1.0, 1.0, 0.0, 1.0); + shape3 = remap(shape3, 0.85, 1.0, 0.0, 1.0); + shape3 = clamp(shape3, 0.0, 1.0); + cov += shape3 * 0.2; + + // fake cov value for testing (xy sine wave) + // let cov_fake = sin(world_pos.x * 0.0003) * sin(world_pos.z * 0.0003); + + // Coverage mask with height-varying threshold modulation + let edge0 = CUMULUS_EDGE_THRESHOLD - CUMULUS_EDGE_WIDTH; + let edge1 = CUMULUS_EDGE_THRESHOLD + CUMULUS_EDGE_WIDTH; + let edge = pow(smoothstep(edge0, edge1, cov), CUMULUS_EDGE_SHARPNESS); + return edge; +} + +/// Cloud shape / coverage term in [0, 1]. +/// This is *not* a physical density by itself: it is the normalized field used to +/// shape the cloud volume (noise + height falloff). +fn get_cloud_coverage(r: f32, world_pos: vec3) -> f32 { + // Check if we're within the cloud layer + if (r < cloud_layer.cloud_layer_start || r > cloud_layer.cloud_layer_end) { + return 0.0; + } + + if (CLOUD_USE_NOISE_SHAPE) { + return sample_cumulus_shape(r, world_pos); + } + + // Debug sphere volume path + if (CLOUD_DEBUG_SINGLE_SPHERE) { + let c = debug_sphere_center(); + let sdf = length(world_pos - c) - RADIUS_M; + let density = 1.0 - smoothstep(-SOFTNESS_M, SOFTNESS_M, sdf); + return clamp(density, 0.0, 1.0); + } + + // Repeating spheres in XZ only. + let layer_thickness = cloud_layer.cloud_layer_end - cloud_layer.cloud_layer_start; + let height_in_layer = r - cloud_layer.cloud_layer_start; + let center_y = 0.5 * layer_thickness; + let y_rel = height_in_layer - center_y; + let cell_xz = fract(world_pos.xz / CELL_SIZE_M) - vec2(0.5); + let q = vec3(cell_xz * CELL_SIZE_M, y_rel); + let sdf = length(q) - RADIUS_M; + let sphere_density = 1.0 - smoothstep(-SOFTNESS_M, SOFTNESS_M, sdf); + return clamp(sphere_density, 0.0, 1.0); +} + +/// Returns (density, grad_mag) for the current debug cloud field. +/// - density is the normalized coverage term in [0,1] +/// - grad_mag is an estimate of |∇density| in 1/m, useful for adaptive stepping. +fn sample_cloud_field_density_and_grad(r: f32, world_pos: vec3) -> vec2 { + // Outside cloud layer => empty and flat field. + if (r < cloud_layer.cloud_layer_start || r > cloud_layer.cloud_layer_end) { + return vec2(0.0, 0.0); + } + + // If we are using the noise shape, approximate gradient magnitude with finite differences + // in XZ (cheap-ish and good enough for adaptive substepping decisions). + if (CLOUD_USE_NOISE_SHAPE) { + let d0 = sample_cumulus_shape(r, world_pos); + const EPS_M: f32 = 500.0; + // Sample along local tangent directions to avoid directional bias / skew. + let up = safe_normalize(world_pos); + let east = safe_normalize(vec3(-up.z, 0.0, up.x)); + let north = safe_normalize(cross(up, east)); + let dx = sample_cumulus_shape(r, world_pos + east * EPS_M); + let dz = sample_cumulus_shape(r, world_pos + north * EPS_M); + // |∇density| ≈ sqrt((dd/dx)^2 + (dd/dz)^2) + let ddx = abs(dx - d0) / EPS_M; + let ddz = abs(dz - d0) / EPS_M; + let grad_mag = sqrt(ddx * ddx + ddz * ddz); + return vec2(d0, grad_mag); + } + + // Debug sphere field: analytic gradient proxy from SDF smoothstep. + var sdf: f32; + if (CLOUD_DEBUG_SINGLE_SPHERE) { + let c = debug_sphere_center(); + sdf = length(world_pos - c) - RADIUS_M; + } else { + let layer_thickness = cloud_layer.cloud_layer_end - cloud_layer.cloud_layer_start; + let height_in_layer = r - cloud_layer.cloud_layer_start; + let center_y = 0.5 * layer_thickness; + let y_rel = height_in_layer - center_y; + let cell_xz = fract(world_pos.xz / CELL_SIZE_M) - vec2(0.5); + let q = vec3(cell_xz * CELL_SIZE_M, y_rel); + sdf = length(q) - RADIUS_M; + } + + let edge0 = -SOFTNESS_M; + let edge1 = SOFTNESS_M; + let denom = (edge1 - edge0); + let tt = clamp((sdf - edge0) / denom, 0.0, 1.0); + let smoothen = tt * tt * (3.0 - 2.0 * tt); + let sphere_density = 1.0 - smoothen; + let d_sphere_density_dsdf = 6.0 * tt * (1.0 - tt) / denom; + let density = clamp(sphere_density, 0.0, 1.0); + let grad_mag = d_sphere_density_dsdf; + return vec2(density, grad_mag); +} + +/// Returns (start_t, end_t, valid) for the segment of the ray inside the cloud layer shell. +/// - start_t/end_t are distances along the ray direction (meters), relative to ray_origin. +/// - valid is 1.0 if the segment exists, 0.0 otherwise. +fn cloud_layer_segment(ray_origin: vec3, ray_dir: vec3) -> vec3 { + // IMPORTANT: do the ray/sphere intersection math in kilometers to improve numerical stability. + // In meters, `r` is ~6.3e6 and for grazing angles the discriminant can become ill-conditioned, + // producing "banded" invalid intersections as the sun elevation changes. + // Doing the math in km keeps magnitudes ~6.3e3 and greatly reduces cancellation. + const M_TO_KM: f32 = 0.001; + const KM_TO_M: f32 = 1000.0; + + let r_km = length(ray_origin) * M_TO_KM; + let up = normalize(ray_origin); + let mu = dot(ray_dir, up); + + let bottom_radius_km = cloud_layer.cloud_layer_start * M_TO_KM; + let top_radius_km = cloud_layer.cloud_layer_end * M_TO_KM; + + // Unreal-style intersection selection: + // Compute intersections with the top and bottom spheres, then derive a single [TMin, TMax] + // interval from carefully selected roots. This avoids sign/branch issues at grazing angles. + let t_top = ray_sphere_intersect(r_km, mu, top_radius_km); + if (t_top.x < 0.0 && t_top.y < 0.0) { + return vec3(0.0, 0.0, 0.0); + } + let t_bottom = ray_sphere_intersect(r_km, mu, bottom_radius_km); + + var t_min = t_top.x; + var t_max = t_top.y; + + // If we also intersect the bottom sphere, combine both. + if (!(t_bottom.x < 0.0 && t_bottom.y < 0.0)) { + // If we see both intersections in front of us, keep the min/closest, otherwise the max/furthest. + var temp_top = select(max(t_top.x, t_top.y), min(t_top.x, t_top.y), (t_top.x > 0.0) && (t_top.y > 0.0)); + var temp_bottom = select(max(t_bottom.x, t_bottom.y), min(t_bottom.x, t_bottom.y), (t_bottom.x > 0.0) && (t_bottom.y > 0.0)); + + if ((t_bottom.x > 0.0) && (t_bottom.y > 0.0)) { + // If we can see the bottom of the layer, make sure we use the camera (0) or the closest top intersection. + temp_top = max(0.0, min(t_top.x, t_top.y)); + } + + t_min = min(temp_bottom, temp_top); + t_max = max(temp_bottom, temp_top); + } + + t_min = max(0.0, t_min); + t_max = max(0.0, t_max); + let valid = (t_max > t_min); + + // Convert km back to meters for callers. + return vec3(t_min * KM_TO_M, t_max * KM_TO_M, select(0.0, 1.0, valid)); +} + +/// Cloud *medium density* used for extinction / scattering integration. +/// This is the normalized coverage term scaled by `cloud_layer.cloud_density`. +fn get_cloud_medium_density(r: f32, world_pos: vec3) -> f32 { + return get_cloud_coverage(r, world_pos) * cloud_layer.cloud_density; +} + +/// Raymarch through clouds towards the sun to compute volumetric shadow +/// Returns the light transmittance factor [0,1] where 0 = fully shadowed, 1 = no shadow +/// Properly handles viewer inside clouds and grazing angles +fn compute_cloud_shadow( + world_pos: vec3, + sun_dir: vec3, + steps: u32, + pixel_coords: vec2, +) -> f32 { + // Early exit if clouds are disabled + if (cloud_layer.cloud_density <= 0.0) { + return 1.0; + } + + // March bounds: only integrate within the cloud layer shell along the sun ray. + let seg = cloud_layer_segment(world_pos, sun_dir); + if (seg.z < 0.5) { + return 1.0; + } + + var march_start = max(seg.x, 0.0); + var march_end = seg.y; + if (march_start >= march_end || march_end <= 0.0) { + return 1.0; + } + + // Earth (planet) shadow term: + // If the sun ray hits the planet surface before it exits the cloud layer, + // the sun is fully occluded and there is no direct lighting. + let r0 = length(world_pos); + let up0 = normalize(world_pos); + let mu0 = dot(sun_dir, up0); + let ground_i = ray_sphere_intersect(r0, mu0, atmosphere.inner_radius); + // `ground_i.x` is the nearest positive intersection along the ray, if any. + if (ground_i.x > 0.0 && ground_i.x < march_end) { + return 0.0; + } + + let march_distance = march_end - march_start; + + // Adaptive step count: long segments need more samples, otherwise we can miss small dense regions + // (especially with the repeating-sphere debug density field). + const TARGET_STEP_M: f32 = 1500.0; + const MAX_SHADOW_STEPS: f32 = 16.0; + let desired = clamp(ceil(march_distance / TARGET_STEP_M), f32(steps), MAX_SHADOW_STEPS); + let shadow_steps = max(1u, u32(desired)); + + let step_size = march_distance / f32(shadow_steps); + var optical_depth = 0.0; + + // Raymarch through clouds towards sun with per-step jitter. + for (var i = 0u; i < shadow_steps; i++) { + let j = interleaved_gradient_noise(pixel_coords, 100u + i); // [0,1] + let t = march_start + (f32(i) + j) * step_size; + let sample_pos = world_pos + sun_dir * t; + let sample_r = length(sample_pos); + + let density = get_cloud_medium_density(sample_r, sample_pos); + if (density > 0.0) { + let extinction = density * (cloud_layer.cloud_scattering + cloud_layer.cloud_absorption); + optical_depth += extinction * step_size; + // Early out when essentially fully shadowed + if (optical_depth > 8.0) { + return 0.0; + } + } + } + + return exp(-optical_depth); +} + +/// Simplified cloud contribution for a single sample point +/// Returns (luminance_added, transmittance_multiplier) +fn sample_cloud_contribution( + world_pos: vec3, + step_size: f32, +) -> vec2 { + let r = length(world_pos); + let density = get_cloud_medium_density(r, world_pos); + + if (density < 0.01) { + return vec2(0.0, 1.0); + } + + // Physically correct coefficients (units: m^-1 per unit density) + let extinction = density * (cloud_layer.cloud_scattering + cloud_layer.cloud_absorption); + let scattering = density * cloud_layer.cloud_scattering; + + // Beer's law + let transmittance = exp(-extinction * step_size); + + // Simple uniform scattering (could be enhanced with actual sun direction) + let in_scatter = scattering * (1.0 - transmittance); + + return vec2(in_scatter, transmittance); +} diff --git a/crates/bevy_pbr/src/atmosphere/environment.rs b/crates/bevy_pbr/src/atmosphere/environment.rs index e19f39e15323b..f3840ba45d3e0 100644 --- a/crates/bevy_pbr/src/atmosphere/environment.rs +++ b/crates/bevy_pbr/src/atmosphere/environment.rs @@ -3,7 +3,8 @@ use crate::{ AtmosphereSampler, AtmosphereTextures, AtmosphereTransform, AtmosphereTransforms, AtmosphereTransformsOffset, GpuAtmosphere, }, - ExtractedAtmosphere, GpuAtmosphereSettings, GpuLights, LightMeta, ViewLightsUniformOffset, + CloudLayer, ExtractedAtmosphere, GpuAtmosphereSettings, GpuLights, LightMeta, + ScatteringMediumSampler, ViewLightsUniformOffset, }; use bevy_asset::{load_embedded_asset, AssetServer, Assets, Handle, RenderAssetUsages}; use bevy_ecs::{ @@ -22,12 +23,16 @@ use bevy_render::{ render_asset::RenderAssets, render_resource::{binding_types::*, *}, renderer::{RenderContext, RenderDevice, ViewQuery}, - texture::{CachedTexture, GpuImage}, + texture::{CachedTexture, FallbackImage, GpuImage}, view::{ViewUniform, ViewUniformOffset, ViewUniforms}, }; use bevy_utils::default; use tracing::warn; +use crate::fbm_noise::FbmNoiseTexture; +use crate::perlin_worley_noise::PerlinWorleyNoiseTexture; +use crate::GpuScatteringMedium; + // Render world representation of an environment map light for the atmosphere #[derive(Component, ExtractComponent, Clone, FromTemplate)] pub struct AtmosphereEnvironmentMap { @@ -42,21 +47,25 @@ pub struct AtmosphereProbeTextures { pub multiscattering_lut: CachedTexture, pub sky_view_lut: CachedTexture, pub aerial_view_lut: CachedTexture, + pub cloud_shadow_map: CachedTexture, } #[derive(Component)] pub(crate) struct AtmosphereProbeBindGroups { pub environment: BindGroup, + pub environment_clouds: Option, } #[derive(Resource)] pub struct AtmosphereProbeLayouts { pub environment: BindGroupLayoutDescriptor, + pub environment_clouds: BindGroupLayoutDescriptor, } #[derive(Resource)] pub struct AtmosphereProbePipeline { pub environment: CachedComputePipelineId, + pub environment_clouds: CachedComputePipelineId, } pub fn init_atmosphere_probe_layout(mut commands: Commands) { @@ -71,12 +80,69 @@ pub fn init_atmosphere_probe_layout(mut commands: Commands) { (2, uniform_buffer::(true)), (3, uniform_buffer::(true)), (4, uniform_buffer::(true)), + // scattering medium luts and sampler (required by `raymarch_atmosphere`) + (5, texture_2d(TextureSampleType::default())), + (6, texture_2d(TextureSampleType::default())), + (7, sampler(SamplerBindingType::Filtering)), + // atmosphere luts and sampler + (8, texture_2d(TextureSampleType::default())), // transmittance + (9, texture_2d(TextureSampleType::default())), // multiscattering + (10, texture_2d(TextureSampleType::default())), // sky view + (11, texture_3d(TextureSampleType::default())), // aerial view + (12, sampler(SamplerBindingType::Filtering)), + // output 2D array texture + ( + 13, + texture_storage_2d_array( + TextureFormat::Rgba16Float, + StorageTextureAccess::WriteOnly, + ), + ), + ), + ), + ); + + // Clouds-enabled variant: adds scattering medium LUTs + cloud bindings used by `raymarch_atmosphere()`. + let environment_clouds = BindGroupLayoutDescriptor::new( + "environment_bind_group_layout_clouds", + &BindGroupLayoutEntries::with_indices( + ShaderStages::COMPUTE, + ( + // uniforms + (0, uniform_buffer::(true)), + (1, uniform_buffer::(true)), + (2, uniform_buffer::(true)), + (3, uniform_buffer::(true)), + (4, uniform_buffer::(true)), + // scattering medium luts and sampler (required by raymarch_atmosphere) + (5, texture_2d(TextureSampleType::default())), + (6, texture_2d(TextureSampleType::default())), + (7, sampler(SamplerBindingType::Filtering)), // atmosphere luts and sampler (8, texture_2d(TextureSampleType::default())), // transmittance (9, texture_2d(TextureSampleType::default())), // multiscattering (10, texture_2d(TextureSampleType::default())), // sky view (11, texture_3d(TextureSampleType::default())), // aerial view (12, sampler(SamplerBindingType::Filtering)), + // cloud bindings (match `clouds.wgsl` / `bindings.wgsl`) + (14, uniform_buffer::(true)), + ( + 15, + texture_2d(TextureSampleType::Float { filterable: true }), + ), + (16, sampler(SamplerBindingType::Filtering)), + ( + 17, + texture_2d(TextureSampleType::Float { filterable: true }), + ), // cloud shadow map + ( + 18, + texture_3d(TextureSampleType::Float { filterable: true }), + ), // perlin-worley + ( + 19, + texture_2d_array(TextureSampleType::Float { filterable: true }), + ), // STBN // output 2D array texture ( 13, @@ -89,7 +155,10 @@ pub fn init_atmosphere_probe_layout(mut commands: Commands) { ), ); - commands.insert_resource(AtmosphereProbeLayouts { environment }); + commands.insert_resource(AtmosphereProbeLayouts { + environment, + environment_clouds, + }); } pub(super) fn prepare_atmosphere_probe_bind_groups( @@ -97,15 +166,62 @@ pub(super) fn prepare_atmosphere_probe_bind_groups( render_device: Res, layouts: Res, atmosphere_sampler: Res, - view_uniforms: Res, - lights_uniforms: Res, - atmosphere_transforms: Res, - atmosphere_uniforms: Res>, - settings_uniforms: Res>, - pipeline_cache: Res, + ( + medium_sampler, + view_uniforms, + lights_uniforms, + atmosphere_transforms, + atmosphere_uniforms, + settings_uniforms, + cloud_layer_uniforms, + fbm_noise_texture, + perlin_worley_noise_texture, + cloud_noise_sampler, + (bluenoise, render_images, fallback_image), + gpu_media, + views, + pipeline_cache, + ): ( + Res, + Res, + Res, + Res, + Res>, + Res>, + Res>, + Res, + Res, + Res, + ( + Res, + Res>, + Res, + ), + Res>, + Query<&ExtractedAtmosphere, With>, + Res, + ), mut commands: Commands, ) { + let atmosphere = views.iter().next(); + + // STBN texture for environment_clouds (raymarch_atmosphere uses it when CLOUDS_ENABLED) + let stbn_view: std::borrow::Cow<'_, TextureView> = match render_images.get(&bluenoise.texture) { + Some(gpu) => std::borrow::Cow::Owned(gpu.texture.create_view(&TextureViewDescriptor { + dimension: Some(TextureViewDimension::D2Array), + ..Default::default() + })), + None => std::borrow::Cow::Borrowed(&fallback_image.d2_array.texture_view), + }; + for (entity, textures) in &probes { + let Some(atmosphere) = atmosphere else { + continue; + }; + let Some(gpu_medium) = gpu_media.get(atmosphere.medium) else { + continue; + }; + let environment = render_device.create_bind_group( "environment_bind_group", &pipeline_cache.get_bind_group_layout(&layouts.environment), @@ -116,6 +232,10 @@ pub(super) fn prepare_atmosphere_probe_bind_groups( (2, atmosphere_transforms.uniforms().binding().unwrap()), (3, view_uniforms.uniforms.binding().unwrap()), (4, lights_uniforms.view_gpu_lights.binding().unwrap()), + // scattering medium luts and sampler + (5, &gpu_medium.density_lut_view), + (6, &gpu_medium.scattering_lut_view), + (7, medium_sampler.sampler()), // atmosphere luts and sampler (8, &textures.transmittance_lut.default_view), (9, &textures.multiscattering_lut.default_view), @@ -127,9 +247,48 @@ pub(super) fn prepare_atmosphere_probe_bind_groups( )), ); - commands - .entity(entity) - .insert(AtmosphereProbeBindGroups { environment }); + // Optional clouds-enabled bind group (requires CloudLayer + scattering medium + noise textures). + let environment_clouds = (|| -> Option { + let cloud_layer_binding = cloud_layer_uniforms.binding()?; + // `gpu_medium` comes from the selected view's atmosphere above. + + Some(render_device.create_bind_group( + "environment_bind_group_clouds", + &pipeline_cache.get_bind_group_layout(&layouts.environment_clouds), + &BindGroupEntries::with_indices(( + // uniforms + (0, atmosphere_uniforms.binding().unwrap()), + (1, settings_uniforms.binding().unwrap()), + (2, atmosphere_transforms.uniforms().binding().unwrap()), + (3, view_uniforms.uniforms.binding().unwrap()), + (4, lights_uniforms.view_gpu_lights.binding().unwrap()), + // scattering medium luts and sampler + (5, &gpu_medium.density_lut_view), + (6, &gpu_medium.scattering_lut_view), + (7, medium_sampler.sampler()), + // atmosphere luts and sampler + (8, &textures.transmittance_lut.default_view), + (9, &textures.multiscattering_lut.default_view), + (10, &textures.sky_view_lut.default_view), + (11, &textures.aerial_view_lut.default_view), + (12, &**atmosphere_sampler), + // cloud bindings + (14, cloud_layer_binding.clone()), + (15, &fbm_noise_texture.texture.default_view), + (16, &**cloud_noise_sampler), + (17, &textures.cloud_shadow_map.default_view), + (18, &perlin_worley_noise_texture.texture.default_view), + (19, stbn_view.as_ref()), + // output + (13, &textures.environment), + )), + )) + })(); + + commands.entity(entity).insert(AtmosphereProbeBindGroups { + environment, + environment_clouds, + }); } } @@ -160,6 +319,7 @@ pub(super) fn prepare_probe_textures( multiscattering_lut: view_textures.multiscattering_lut.clone(), sky_view_lut: view_textures.sky_view_lut.clone(), aerial_view_lut: view_textures.aerial_view_lut.clone(), + cloud_shadow_map: view_textures.cloud_shadow_map.clone(), }); } } @@ -177,7 +337,19 @@ pub fn init_atmosphere_probe_pipeline( shader: load_embedded_asset!(asset_server.as_ref(), "environment.wgsl"), ..default() }); - commands.insert_resource(AtmosphereProbePipeline { environment }); + + let environment_clouds = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some("environment_pipeline_clouds".into()), + layout: vec![layouts.environment_clouds.clone()], + shader: load_embedded_asset!(asset_server.as_ref(), "environment.wgsl"), + shader_defs: vec!["CLOUDS_ENABLED".into()], + ..default() + }); + + commands.insert_resource(AtmosphereProbePipeline { + environment, + environment_clouds, + }); } // Ensure power-of-two dimensions to avoid edge update issues on cubemap faces @@ -249,6 +421,7 @@ pub fn atmosphere_environment( &AtmosphereTransformsOffset, &ViewUniformOffset, &ViewLightsUniformOffset, + Option<&DynamicUniformIndex>, )>, probe_query: Query<(&AtmosphereProbeBindGroups, &AtmosphereEnvironmentMap)>, pipeline_cache: Res, @@ -266,27 +439,56 @@ pub fn atmosphere_environment( atmosphere_transforms_offset, view_uniforms_offset, lights_uniforms_offset, + cloud_layer_offset, ) = view.into_inner(); for (bind_groups, env_map_light) in probe_query.iter() { + let use_clouds = cloud_layer_offset.is_some() && bind_groups.environment_clouds.is_some(); + + let pipeline = if use_clouds { + pipeline_cache.get_compute_pipeline(pipelines.environment_clouds) + } else { + Some(environment_pipeline) + }; + + let Some(pipeline) = pipeline else { + continue; + }; + let command_encoder = ctx.command_encoder(); let mut pass = command_encoder.begin_compute_pass(&ComputePassDescriptor { label: Some("environment_pass"), timestamp_writes: None, }); - pass.set_pipeline(environment_pipeline); - pass.set_bind_group( - 0, - &bind_groups.environment, - &[ - atmosphere_uniforms_offset.index(), - settings_uniforms_offset.index(), - atmosphere_transforms_offset.index(), - view_uniforms_offset.offset, - lights_uniforms_offset.offset, - ], - ); + pass.set_pipeline(pipeline); + + if use_clouds { + pass.set_bind_group( + 0, + bind_groups.environment_clouds.as_ref().unwrap(), + &[ + atmosphere_uniforms_offset.index(), + settings_uniforms_offset.index(), + atmosphere_transforms_offset.index(), + view_uniforms_offset.offset, + lights_uniforms_offset.offset, + cloud_layer_offset.as_ref().unwrap().index(), + ], + ); + } else { + pass.set_bind_group( + 0, + &bind_groups.environment, + &[ + atmosphere_uniforms_offset.index(), + settings_uniforms_offset.index(), + atmosphere_transforms_offset.index(), + view_uniforms_offset.offset, + lights_uniforms_offset.offset, + ], + ); + } pass.dispatch_workgroups( env_map_light.size.x / 8, diff --git a/crates/bevy_pbr/src/atmosphere/environment.wgsl b/crates/bevy_pbr/src/atmosphere/environment.wgsl index befcf7c17a121..0d6cb207d0e4a 100644 --- a/crates/bevy_pbr/src/atmosphere/environment.wgsl +++ b/crates/bevy_pbr/src/atmosphere/environment.wgsl @@ -1,6 +1,17 @@ #import bevy_pbr::{ atmosphere::{ - functions::{direction_world_to_atmosphere, sample_sky_view_lut, get_view_position}, + bindings::{ + atmosphere, settings, view, lights, + transmittance_lut, sky_view_lut, + }, + functions::{ + direction_world_to_atmosphere, + sample_sky_view_lut, + sample_transmittance_lut, + get_view_position, + max_atmosphere_distance, + raymarch_atmosphere, + }, }, utils::sample_cube_dir } @@ -29,9 +40,26 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { let world_pos = get_view_position(); let r = length(world_pos); + let up = normalize(world_pos); + let mu = dot(ray_dir_ws, up); let ray_dir_as = direction_world_to_atmosphere(ray_dir_ws.xyz); - let inscattering = sample_sky_view_lut(r, ray_dir_as); + var transmittance = sample_transmittance_lut(r, mu); + var inscattering = sample_sky_view_lut(r, ray_dir_as); + + // Match `render_sky.wgsl` behavior: if raymarch mode is enabled, integrate numerically. + // With CLOUDS_ENABLED this also includes volumetric clouds on the view ray. + if (settings.rendering_method == 1u) { + let t_max = max_atmosphere_distance(r, mu); + let max_samples = settings.sky_max_samples; + let result = raymarch_atmosphere(world_pos, ray_dir_ws, t_max, max_samples, uv, true); + inscattering = result.inscattering; + transmittance = result.transmittance; + } + + // NOTE: We intentionally do NOT add the analytic sun-disk term here. + // `sample_sun_radiance()` uses `fwidth()` for anti-aliasing, which is forbidden in compute stages. + let color = vec4(inscattering, 1.0); textureStore(output, vec2(global_id.xy), i32(slice_index), color); diff --git a/crates/bevy_pbr/src/atmosphere/fbm_noise.rs b/crates/bevy_pbr/src/atmosphere/fbm_noise.rs new file mode 100644 index 0000000000000..83049865c3e4b --- /dev/null +++ b/crates/bevy_pbr/src/atmosphere/fbm_noise.rs @@ -0,0 +1,275 @@ +//! 3D FBM Noise Generation for Atmospheric Effects +//! +//! This module generates a 3D texture filled with Fractional Brownian Motion (FBM) noise, +//! which can be used for atmospheric effects like clouds, fog, and volumetric effects. + +use bevy_asset::load_embedded_asset; +use bevy_ecs::{ + resource::Resource, + system::{Res, ResMut}, + world::{FromWorld, World}, +}; +use bevy_math::UVec2; +use bevy_render::{ + render_resource::{binding_types::*, *}, + renderer::{RenderDevice, RenderQueue}, + texture::CachedTexture, +}; +use bevy_utils::default; + +/// Parameters for controlling FBM noise generation +#[derive(Clone, Copy, ShaderType, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct FbmNoiseParams { + /// Number of octaves for the FBM noise (more octaves = more detail) + pub octaves: u32, + /// Base frequency of the noise + pub frequency: f32, + /// Base amplitude of the noise + pub amplitude: f32, + /// Frequency multiplier for each octave (typically 2.0) + pub lacunarity: f32, + /// Amplitude multiplier for each octave (typically 0.5) + pub persistence: f32, +} + +impl Default for FbmNoiseParams { + fn default() -> Self { + Self { + octaves: 4, + frequency: 1.0, + amplitude: 1.0, + lacunarity: 2.0, + persistence: 0.5, + } + } +} + +/// Size of the 2D noise texture +#[derive(Clone, Copy)] +pub struct NoiseTextureSize { + pub size: UVec2, +} + +impl Default for NoiseTextureSize { + fn default() -> Self { + Self { + // Higher resolution reduces visible repetition in cloud coverage. + // This is still cheap to generate once at startup. + size: UVec2::new(32, 32), + } + } +} + +/// Resource containing the bind group layout for the FBM noise pass +#[derive(Resource)] +pub struct FbmNoiseBindGroupLayout { + pub layout: BindGroupLayout, + pub descriptor: BindGroupLayoutDescriptor, +} + +impl FromWorld for FbmNoiseBindGroupLayout { + fn from_world(world: &mut World) -> Self { + let render_device = world.resource::(); + let descriptor = BindGroupLayoutDescriptor::new( + "fbm_noise_bind_group_layout", + &BindGroupLayoutEntries::with_indices( + ShaderStages::COMPUTE, + ( + ( + // 2D noise texture storage + 13, + texture_storage_2d( + // Packed cloud noise (coverage + type controls + detail). + TextureFormat::Rgba16Float, + StorageTextureAccess::WriteOnly, + ), + ), + ( + // FBM parameters uniform buffer + 14, + uniform_buffer::(false), + ), + ), + ), + ); + + let layout = + render_device.create_bind_group_layout(descriptor.label.as_ref(), &descriptor.entries); + + Self { layout, descriptor } + } +} + +/// Resource containing the compute pipeline for FBM noise generation +#[derive(Resource)] +pub struct FbmNoisePipeline { + pub pipeline: CachedComputePipelineId, +} + +impl FromWorld for FbmNoisePipeline { + fn from_world(world: &mut World) -> Self { + let pipeline_cache = world.resource::(); + let layout = world.resource::(); + + let pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some("fbm_noise_2d_pipeline".into()), + layout: vec![layout.descriptor.clone()], + shader: load_embedded_asset!(world, "fbm_noise_3d.wgsl"), + ..default() + }); + + Self { pipeline } + } +} + +/// Resource containing the generated 2D noise texture +#[derive(Resource)] +pub struct FbmNoiseTexture { + pub texture: CachedTexture, + pub size: UVec2, +} + +/// Resource containing the bind group for the noise generation pass +#[derive(Resource)] +pub struct FbmNoiseBindGroup { + pub bind_group: BindGroup, +} + +/// Resource containing the uniform buffer for FBM parameters +#[derive(Resource)] +pub struct FbmNoiseParamsBuffer { + pub buffer: Buffer, +} + +/// System to initialize the FBM noise texture. +/// +/// Allocates GPU memory directly instead of the render [`TextureCache`](bevy_render::texture::TextureCache) +/// so this `RenderStartup` system does not need to run after `init_gpu_resource::`. +pub fn init_fbm_noise_texture( + render_device: Res, + mut commands: bevy_ecs::system::Commands, +) { + let size = NoiseTextureSize::default(); + + let texture_descriptor = TextureDescriptor { + label: Some("fbm_noise_2d_texture"), + size: Extent3d { + width: size.size.x, + height: size.size.y, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + // Packed cloud noise (coverage + type controls + detail). + format: TextureFormat::Rgba16Float, + usage: TextureUsages::STORAGE_BINDING | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }; + + let texture = render_device.create_texture(&texture_descriptor); + let default_view = texture.create_view(&TextureViewDescriptor::default()); + + commands.insert_resource(FbmNoiseTexture { + texture: CachedTexture { + texture, + default_view, + }, + size: size.size, + }); +} + +/// System to prepare the FBM noise bind group +pub fn prepare_fbm_noise_bind_group( + mut commands: bevy_ecs::system::Commands, + render_device: Res, + layout: Res, + texture: Res, + params_buffer: Res, +) { + let bind_group = render_device.create_bind_group( + "fbm_noise_bind_group", + &layout.layout, + &BindGroupEntries::with_indices(( + (13, &texture.texture.default_view), + (14, params_buffer.buffer.as_entire_binding()), + )), + ); + + commands.insert_resource(FbmNoiseBindGroup { bind_group }); +} + +/// System to initialize the FBM noise parameters buffer +pub fn init_fbm_noise_params_buffer( + mut commands: bevy_ecs::system::Commands, + render_device: Res, +) { + let params = FbmNoiseParams::default(); + let buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { + label: Some("fbm_noise_params_buffer"), + contents: bytemuck::cast_slice(&[params]), + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + }); + + commands.insert_resource(FbmNoiseParamsBuffer { buffer }); +} + +/// Returns the dispatch workgroup counts for the 2D noise texture based on its size +pub fn get_noise_dispatch_size(texture_size: UVec2) -> UVec2 { + const WORKGROUP_SIZE: u32 = 8; + UVec2::new( + texture_size.x.div_ceil(WORKGROUP_SIZE), + texture_size.y.div_ceil(WORKGROUP_SIZE), + ) +} + +/// Resource to track if noise has been generated +#[derive(Resource, Default)] +pub struct NoiseGenerated(pub bool); + +/// System to generate the FBM noise texture (runs once) +pub fn generate_fbm_noise_once( + render_device: Res, + render_queue: Res, + pipeline_cache: Res, + pipeline: Res, + bind_group: Option>, + texture: Res, + mut noise_generated: ResMut, +) { + // Only generate once + if noise_generated.0 { + return; + } + + let Some(bind_group) = bind_group else { + return; + }; + + let Some(compute_pipeline) = pipeline_cache.get_compute_pipeline(pipeline.pipeline) else { + return; + }; + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor { + label: Some("fbm_noise_generation"), + }); + + { + let mut compute_pass = encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("fbm_noise_2d_pass"), + timestamp_writes: None, + }); + + compute_pass.set_pipeline(compute_pipeline); + compute_pass.set_bind_group(0, &bind_group.bind_group, &[]); + + let dispatch_size = get_noise_dispatch_size(texture.size); + compute_pass.dispatch_workgroups(dispatch_size.x, dispatch_size.y, 1); + } + + let command_buffer = encoder.finish(); + render_queue.submit([command_buffer]); + + noise_generated.0 = true; +} diff --git a/crates/bevy_pbr/src/atmosphere/fbm_noise_3d.wgsl b/crates/bevy_pbr/src/atmosphere/fbm_noise_3d.wgsl new file mode 100644 index 0000000000000..a951a41e7b286 --- /dev/null +++ b/crates/bevy_pbr/src/atmosphere/fbm_noise_3d.wgsl @@ -0,0 +1,102 @@ +// 2D FBM (Fractional Brownian Motion) Noise Generator +// Generates a 2D noise texture for cloud coverage + +@group(0) @binding(13) var noise_texture_out: texture_storage_2d; + +// Parameters for FBM noise generation +struct FbmParams { + octaves: u32, + frequency: f32, + amplitude: f32, + lacunarity: f32, + persistence: f32, +} + +@group(0) @binding(14) var fbm_params: FbmParams; + +@compute +@workgroup_size(8, 8, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + let texture_size = textureDimensions(noise_texture_out); + + if (gid.x >= texture_size.x || gid.y >= texture_size.y) { + return; + } + + // Normalized coordinates [0, 1) + let uv = (vec2(gid.xy) + 0.5) / vec2(texture_size); + + // Generate packed *tileable* cloud noise. + // We work in "cell space" where the period matches the texture dimensions so wrapping with + // sampler repeat is seamless (no hard seam/wall at the edges). + let p = uv * vec2(texture_size) * fbm_params.frequency; + + // R: coverage (macro placement) + let coverage = fbm_2d(p * 0.5); + + // G/B: "type" controls used to shape the vertical profile (NUBIS-style top/bottom types). + // Use low-frequency tileable noise so types vary slowly over the world. + let bottom_type = clamp(value_noise_2d(p * 0.12 + vec2(37.0, 11.0)) * 0.5 + 0.5, 0.0, 1.0); + let top_type = clamp(value_noise_2d(p * 0.09 + vec2(5.0, 71.0)) * 0.5 + 0.5, 0.0, 1.0); + + // A: detail noise (used for erosion / "up-rez"). + // Higher frequency, but still tileable due to wrapped lattice. + let detail = clamp(fbm_2d(p * 4.0 + vec2(13.0, 53.0)), 0.0, 1.0); + + textureStore( + noise_texture_out, + vec2(gid.xy), + vec4(coverage, bottom_type, top_type, detail), + ); +} + +// Wrap lattice coordinates to a 2D period (used to generate *tileable* value noise). +fn wrap2(v: vec2, size: vec2) -> vec2 { + let ix = i32(v.x); + let iy = i32(v.y); + let wx = ((ix % size.x) + size.x) % size.x; + let wy = ((iy % size.y) + size.y) % size.y; + return vec2(f32(wx), f32(wy)); +} + +// 2D FBM noise function +fn fbm_2d(p: vec2) -> f32 { + var value = 0.0; + var amplitude = fbm_params.amplitude; + var frequency = 1.0; + var position = p; + + for (var i = 0u; i < fbm_params.octaves; i++) { + value += amplitude * value_noise_2d(position * frequency); + frequency *= fbm_params.lacunarity; + amplitude *= fbm_params.persistence; + } + + // Normalize to [0, 1] range. + // Note: the underlying noise sum can overshoot slightly; clamp to keep downstream + // shaping (pow/smoothstep) well-defined. + return clamp(value * 0.5 + 0.5, 0.0, 1.0); +} + +fn value_noise_2d(p: vec2) -> f32 { + // Period for seamless wrapping: texture dimensions. + // This makes noise tile cleanly when sampled with AddressMode::Repeat. + let size = vec2(textureDimensions(noise_texture_out)); + let i = floor(p); + let f = fract(p); + let u = f * f * (3.0 - 2.0 * f); + + let a = hash_2d(wrap2(i + vec2(0.0, 0.0), size)); + let b = hash_2d(wrap2(i + vec2(1.0, 0.0), size)); + let c = hash_2d(wrap2(i + vec2(0.0, 1.0), size)); + let d = hash_2d(wrap2(i + vec2(1.0, 1.0), size)); + + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 2.0 - 1.0; +} + +fn hash_2d(p: vec2) -> f32 { + let p3 = fract(vec3(p.x, p.y, p.x) * 0.1031); + let s = dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * (p3.z + s)); +} + diff --git a/crates/bevy_pbr/src/atmosphere/functions.wgsl b/crates/bevy_pbr/src/atmosphere/functions.wgsl index cb2eb88ed80c0..bce67b74aceb1 100644 --- a/crates/bevy_pbr/src/atmosphere/functions.wgsl +++ b/crates/bevy_pbr/src/atmosphere/functions.wgsl @@ -1,6 +1,7 @@ #define_import_path bevy_pbr::atmosphere::functions #import bevy_render::maths::{PI, HALF_PI, PI_2, fast_acos, fast_acos_4, fast_atan2, ray_sphere_intersect} +#import bevy_pbr::utils::interleaved_gradient_noise #import bevy_pbr::atmosphere::{ types::Atmosphere, @@ -8,6 +9,7 @@ atmosphere, settings, view, lights, transmittance_lut, atmosphere_lut_sampler, multiscattering_lut, sky_view_lut, aerial_view_lut, atmosphere_transforms, medium_density_lut, medium_scattering_lut, medium_sampler, + cloud_shadow_map, }, bruneton_functions::{ transmittance_lut_r_mu_to_uv, ray_intersects_ground, @@ -15,6 +17,19 @@ }, } +#ifdef CLOUDS_ENABLED +#import bevy_pbr::atmosphere::bindings::stbn_texture +#import bevy_pbr::atmosphere::clouds::{ + get_cloud_coverage, + get_cloud_medium_density, + sample_cloud_field_density_and_grad, + cloud_layer_segment, + get_cloud_absorption_coeff, + get_cloud_scattering_coeff, + sample_cloud_contribution, +} +#endif + // NOTE FOR CONVENTIONS: // r: // radius, or distance from planet center @@ -43,6 +58,18 @@ const ROOT_2: f32 = 1.41421356; // √2 const EPSILON: f32 = 1.0; // 1 meter const MIN_EXTINCTION: vec3 = vec3(1e-12); +// Henyey-Greenstein phase function (normalized by 1/(4π)). +fn hg_phase(cos_theta: f32, g: f32) -> f32 { + let gg = g * g; + // p(θ) = (1/4π) * (1 - g²) / (1 + g² - 2g*cos(θ))^(3/2) + return FRAC_4_PI * (1.0 - gg) / pow(max(1.0 + gg - 2.0 * g * cos_theta, 1e-6), 1.5); +} + +// Dual-lobe HG phase (Frostbite-style): blend a forward and backward lobe. +fn dual_lobe_hg_phase(cos_theta: f32, g_fwd: f32, g_bwd: f32, lobe_lerp: f32) -> f32 { + return mix(hg_phase(cos_theta, g_fwd), hg_phase(cos_theta, g_bwd), lobe_lerp); +} + // During raymarching, each segment is sampled at a single point. This constant determines // where in the segment that sample is taken (0.0 = start, 0.5 = middle, 1.0 = end). // We use 0.3 to sample closer to the start of each segment, which better approximates @@ -208,23 +235,205 @@ fn sample_scattering_lut(r: f32, neg_LdotV: f32) -> vec3 { return textureSampleLevel(medium_scattering_lut, medium_sampler, uv, 0.0).xyz; } +#ifdef CLOUDS_ENABLED +struct LightBasis { + x: vec3, + y: vec3, +}; + +fn safe_normalize(v: vec3) -> vec3 { + let l2 = dot(v, v); + if (l2 <= 1e-12) { + return vec3(0.0, 1.0, 0.0); + } + return v * inverseSqrt(l2); +} + +fn build_light_basis(light_dir: vec3) -> LightBasis { + // Pick a vector not parallel to `light_dir`, then build an orthonormal basis. + // IMPORTANT: keep this selection stable; switching too early causes the basis to “flip” + // when the light gets moderately close to zenith, which looks like the shadow map drifts/rotates. + let world_up = vec3(0.0, 1.0, 0.0); + let a = select(world_up, vec3(1.0, 0.0, 0.0), abs(dot(light_dir, world_up)) > 0.999); + let x = safe_normalize(cross(a, light_dir)); + let y = cross(light_dir, x); + return LightBasis(x, y); +} + +/// Helper for cloud shadow PCF: evaluates transmittance from shadow map data. +fn cloud_shadow_transmittance(data: vec3, d_sample: f32) -> f32 { + let front_depth = data.r * 1000.0; + let mean_ext = data.g; + let max_optical_depth = data.b; + let delta = max(0.0, d_sample - front_depth); + var tau = mean_ext * delta; + tau = min(tau, max_optical_depth); + return exp(-tau); +} + +/// Ground illuminance variant: uses only the stored optical depth (B channel), ignoring front depth. +fn cloud_shadow_optical_depth(data: vec3) -> f32 { + let optical_depth = data.b; + return exp(-optical_depth); +} + +fn snap_anchor_to_texel_grid(anchor: vec3, basis: LightBasis, extent: f32, size: vec2) -> vec3 { + // Keep producer/consumer aligned by snapping the anchor to the shadow-map texel grid. + let res = vec2(size); + let texel_size = (2.0 * extent) / max(res, vec2(1.0)); + + let ax = dot(anchor, basis.x); + let ay = dot(anchor, basis.y); + + let snapped_ax = round(ax / texel_size.x) * texel_size.x; + let snapped_ay = round(ay / texel_size.y) * texel_size.y; + + let dx = snapped_ax - ax; + let dy = snapped_ay - ay; + return anchor + basis.x * dx + basis.y * dy; +} + +/// Unreal-style cloud shadow map evaluation. +/// +/// The `cloud_shadow_map` stores: +/// - R: front depth (meters) from the light-volume near plane +/// - G: mean extinction (1/m) +/// - B: max optical depth (unitless) +/// +/// For a world-space sample point, we compute: +/// tau = mean_ext * (d_sample - d_front), clamped by max_optical_depth +/// T = exp(-tau) +/// +/// When `use_optical_depth_only` is true (e.g. for ground illuminance), front depth is ignored +/// and transmittance is computed from the stored optical depth only: T = exp(-optical_depth). +fn sample_cloud_shadow_map(world_pos: vec3, direction_to_light: vec3, use_optical_depth_only: bool) -> f32 { + // Only meaningful in raymarched mode. + if (settings.rendering_method != 1u) { + return 1.0; + } + + let extent = settings.cloud_shadow_map_extent; + // Keep consumer consistent with producer: half-depth is derived from extent (Unreal-style). + let half_depth = extent * 2.0; + if (extent <= 0.0 || half_depth <= 0.0) { + return 1.0; + } + + // IMPORTANT: use the same trace direction as the compute pass: + // light -> surface (opposite of Bevy's `direction_to_light` which is surface -> light). + let trace_dir = safe_normalize(-direction_to_light); + let basis = build_light_basis(trace_dir); + + // Anchor must match the compute shader's anchor to keep lookups stable. + var anchor = get_view_position(); + anchor = snap_anchor_to_texel_grid(anchor, basis, extent, settings.cloud_shadow_map_size); + let rel = world_pos - anchor; + + // Map world point to the orthographic light volume. + let x = dot(rel, basis.x); + let y = dot(rel, basis.y); + if (abs(x) > extent || abs(y) > extent) { + return 1.0; + } + + // Depth from near plane along light direction. + // Near plane is located at `anchor - light_dir * half_depth`. + let d_sample = dot(rel, trace_dir) + half_depth; + let max_t = 2.0 * half_depth; + if (d_sample <= 0.0 || d_sample >= max_t) { + return 1.0; + } + + let uv = vec2(x, y) / (2.0 * extent) + vec2(0.5); + + #ifdef CLOUD_SHADOW_SIMPLE_SAMPLING + let d = textureSampleLevel(cloud_shadow_map, atmosphere_lut_sampler, uv, 0.0).rgb; + if (use_optical_depth_only) { + return cloud_shadow_optical_depth(d); + } + return cloud_shadow_transmittance(d, d_sample); + #else + // 4-tap PCF (2x2 half-texel) to reduce aliasing and soften shadow edges. + // Samples are offset by ±0.25 texels so the 2x2 footprint covers the sampling area. + let size = vec2(settings.cloud_shadow_map_size); + let texel = vec2(1.0 / max(size.x, 1.0), 1.0 / max(size.y, 1.0)); + let o = 0.25 * texel; + + let d00 = textureSampleLevel(cloud_shadow_map, atmosphere_lut_sampler, uv + vec2(-o.x, -o.y), 0.0).rgb; + let d10 = textureSampleLevel(cloud_shadow_map, atmosphere_lut_sampler, uv + vec2(o.x, -o.y), 0.0).rgb; + let d01 = textureSampleLevel(cloud_shadow_map, atmosphere_lut_sampler, uv + vec2(-o.x, o.y), 0.0).rgb; + let d11 = textureSampleLevel(cloud_shadow_map, atmosphere_lut_sampler, uv + vec2(o.x, o.y), 0.0).rgb; + + let t00 = select(cloud_shadow_transmittance(d00, d_sample), cloud_shadow_optical_depth(d00), use_optical_depth_only); + let t10 = select(cloud_shadow_transmittance(d10, d_sample), cloud_shadow_optical_depth(d10), use_optical_depth_only); + let t01 = select(cloud_shadow_transmittance(d01, d_sample), cloud_shadow_optical_depth(d01), use_optical_depth_only); + let t11 = select(cloud_shadow_transmittance(d11, d_sample), cloud_shadow_optical_depth(d11), use_optical_depth_only); + + return (t00 + t10 + t01 + t11) * 0.25; + #endif +} +#endif + /// evaluates L_scat, equation 3 in the paper, which gives the total single-order scattering towards the view at a single point -fn sample_local_inscattering(local_scattering: vec3, ray_dir: vec3, world_pos: vec3) -> vec3 { +fn sample_local_inscattering(local_scattering: vec3, ray_dir: vec3, world_pos: vec3, pixel_coords: vec2) -> vec3 { let local_r = length(world_pos); let local_up = normalize(world_pos); var inscattering = vec3(0.0); + + #ifdef CLOUDS_ENABLED + // Sample cloud *medium density* at this point (includes `cloud_layer.cloud_density`) + // so clouds respond consistently across scattering vs shadowing. + let cloud_density = get_cloud_medium_density(local_r, world_pos); + #endif + for (var light_i: u32 = 0u; light_i < lights.n_directional_lights; light_i++) { let light = &lights.directional_lights[light_i]; let mu_light = dot((*light).direction_to_light, local_up); - // -(L . V) == (L . -V). -V here is our ray direction, which points away from the view - // instead of towards it (as is the convention for V) + // NOTE ON SIGN CONVENTIONS: + // `ray_dir` points *away* from the camera (camera -> sample). + // `direction_to_light` (L) points from the sample *towards the light* (sample -> sun). + // + // Many texts define phase in terms of propagation directions ωi (sun -> sample) and ωo (sample -> camera), + // in which case: ωi = -L and ωo = -ray_dir, so: + // cos(theta) = dot(ωi, ωo) = dot(-L, -ray_dir) = dot(L, ray_dir) + // + // So for our phase functions, the correct cosine is simply dot(L, ray_dir). let neg_LdotV = dot((*light).direction_to_light, ray_dir); let transmittance_to_light = sample_transmittance_lut(local_r, mu_light); - let shadow_factor = transmittance_to_light * f32(!ray_intersects_ground(local_r, mu_light)); - let scattering_coeff = sample_scattering_lut(local_r, neg_LdotV); + var shadow_factor = transmittance_to_light * f32(!ray_intersects_ground(local_r, mu_light)); + var scattering_coeff = sample_scattering_lut(local_r, neg_LdotV); + + #ifdef CLOUDS_ENABLED + // NUBIS: Add volumetric cloud scattering with proper physical integration + // Clouds contribute to inscattering via Henyey-Greenstein phase function + // Compute volumetric shadow from clouds via the cloud shadow map (stable at grazing angles). + shadow_factor *= sample_cloud_shadow_map(world_pos, (*light).direction_to_light, false); + + // Cloud scattering coefficient: σ_s_cloud = density * scattering_coeff + // Using physically correct coefficients (units: m^-1 per unit density) + // Density is normalized [0, 1], coefficients represent actual physical values + // Water droplet clouds have scattering ~0.0008-0.001 m^-1 per unit density + let cloud_scattering_coeff = cloud_density * get_cloud_scattering_coeff(); + + // Henyey-Greenstein phase function for anisotropic cloud scattering + // NUBIS/Frostbite-style: dual-lobe HG to better match real clouds. + // + // `neg_LdotV` here is dot(L, ray_dir); see sign convention note above. + let cos_theta = clamp(neg_LdotV, -1.0, 1.0); + + // Forward lobe (strong forward scattering) and a weaker backward lobe. + // Matches the approach used in `bevy-volumetric-clouds` (Frostbite-inspired). + let g_fwd = 0.85; + let g_bwd = -0.2; + let lobe_lerp = 0.5; + let cloud_phase = dual_lobe_hg_phase(cos_theta, g_fwd, g_bwd, lobe_lerp); + + // Add cloud scattering contribution: σ_s_cloud * p(θ) + scattering_coeff += cloud_scattering_coeff * cloud_phase; + #endif // Transmittance from scattering event to light source let scattering_factor = shadow_factor * scattering_coeff; @@ -386,6 +595,32 @@ struct RaymarchSegment { end: f32, } +// Inverse CDF mapping for cloud-aware global stratification. +// Maps u in [0,1] to a distance t along the ray in [t_start, t_end], +// given piecewise weights for pre/cloud/post segments. +fn cloud_shadow_inv_cdf( + u: f32, + t_start: f32, + cloud_start: f32, + cloud_end: f32, + t_end: f32, + w_pre: f32, + w_cloud: f32, + w_post: f32, + w_sum: f32, +) -> f32 { + let x = u * w_sum; + if (x < w_pre) { + return t_start + (select(0.0, x / w_pre, w_pre > 0.0)) * (cloud_start - t_start); + } + let x2 = x - w_pre; + if (x2 < w_cloud) { + return cloud_start + (select(0.0, x2 / w_cloud, w_cloud > 0.0)) * (cloud_end - cloud_start); + } + let x3 = x2 - w_cloud; + return cloud_end + (select(0.0, x3 / max(1e-6, w_post), w_post > 0.0)) * (t_end - cloud_end); +} + fn get_raymarch_segment(r: f32, mu: f32) -> RaymarchSegment { // Get both intersection points with atmosphere let atmosphere_intersections = ray_sphere_intersect(r, mu, atmosphere.outer_radius); @@ -432,6 +667,9 @@ fn raymarch_atmosphere( let up = normalize(pos); let mu = dot(ray_dir, up); + // Convert UV to pixel coordinates for noise jittering + // Assuming viewport size from view uniform (typically available) + let pixel_coords = uv * view.viewport.zw; // Optimization: Reduce sample count at close proximity to the scene let sample_count = mix(1.0, f32(max_samples), saturate(t_max * 0.01)); @@ -451,17 +689,138 @@ fn raymarch_atmosphere( return result; } - var prev_t = t_start; var optical_depth = vec3(0.0); + + // Convert the sample count into a hard budget. + let sample_budget = max(1u, u32(sample_count)); + + #ifdef CLOUDS_ENABLED + // Cloud-aware global stratification (no temporal noise): + // Instead of using separate per-segment grids (which can introduce visible boundaries), + // we stratify over [0,1] globally and map through a piecewise-linear CDF that allocates + // more samples to the cloud layer region. + + let seg = cloud_layer_segment(pos, ray_dir); + // IMPORTANT: + // If the view ray does *not* intersect the cloud shell, we must still integrate the atmosphere. + // A previous version of this logic set all segment lengths to 0 when `has_cloud_layer == false`, + // which collapses the inverse CDF to a constant and yields dt==0 => no scattering (black sky). + let cloud_start_raw = clamp(seg.x, t_start, t_end); + let cloud_end_raw = clamp(seg.y, t_start, t_end); + let has_cloud_layer = (seg.z > 0.5) && (cloud_end_raw > cloud_start_raw); + + // When there is no cloud segment, treat the entire raymarch segment as "outside". + let cloud_start = select(t_end, cloud_start_raw, has_cloud_layer); + let cloud_end = select(t_end, cloud_end_raw, has_cloud_layer); + + let len_pre = max(0.0, cloud_start - t_start); + let len_cloud = max(0.0, cloud_end - cloud_start); + let len_post = max(0.0, t_end - cloud_end); + + // Importance multiplier for the cloud layer segment. + // Higher => more samples in clouds, fewer outside, with *continuous* sampling across boundaries. + let cloud_importance = 16.0; + + var w_pre = len_pre; + var w_cloud = len_cloud * cloud_importance; + var w_post = len_post; + + let w_sum = max(1e-6, w_pre + w_cloud + w_post); + + // Gradient scale factor (unit: meters) to turn |∇density| (1/m) into a dimensionless importance. + const GRAD_IMPORTANCE_M: f32 = 1500.0; + const CLOUD_MAX_SUBSTEPS: u32 = 2u; + + // STBN: use different layer per frame for temporal stratification + let stbn_dims = textureDimensions(stbn_texture); + let stbn_use = all(stbn_dims > vec2(1u)); + let stbn_layer = select(0, i32(view.frame_count % u32(textureNumLayers(stbn_texture))), stbn_use); + let stbn_px = vec2(floor(pixel_coords)) % vec2(stbn_dims); + + var stop: bool = false; + for (var s: u32 = 0u; s < sample_budget; s += 1u) { + if (stop) { break; } + + let u0 = f32(s) / f32(sample_budget); + let u1 = f32(s + 1u) / f32(sample_budget); + var j: f32; + if (stbn_use) { + let stbn_noise = textureLoad(stbn_texture, stbn_px, stbn_layer, 0); + j = fract(stbn_noise.r + f32(s) * 0.618033988749895); + } else { + j = interleaved_gradient_noise(pixel_coords, 500u + s); + } + let u = mix(u0, u1, j); + + let t0 = cloud_shadow_inv_cdf(u0, t_start, cloud_start, cloud_end, t_end, w_pre, w_cloud, w_post, w_sum); + let t1 = cloud_shadow_inv_cdf(u1, t_start, cloud_start, cloud_end, t_end, w_pre, w_cloud, w_post, w_sum); + let dt = max(0.0, t1 - t0); + if (dt <= 0.0) { continue; } + + let t = cloud_shadow_inv_cdf(u, t_start, cloud_start, cloud_end, t_end, w_pre, w_cloud, w_post, w_sum); + let sample_pos = pos + ray_dir * t; + let local_r = length(sample_pos); + + // Inside-cloud substepping for sharp density transitions. + let in_cloud = has_cloud_layer && (t >= cloud_start) && (t < cloud_end); + var sub_steps: u32 = 1u; + if (in_cloud) { + let field = sample_cloud_field_density_and_grad(local_r, sample_pos); + let g = max(0.0, field.y * GRAD_IMPORTANCE_M); + sub_steps = min(CLOUD_MAX_SUBSTEPS, max(1u, u32(ceil(1.0 + g)))); + } + + let sub_dt = dt / f32(sub_steps); + for (var k: u32 = 0u; k < sub_steps; k += 1u) { + if (stop) { break; } + // Jitter within each sub-step using STBN for temporal stratification. + var j2: f32; + if (stbn_use) { + let stbn_noise2 = textureLoad(stbn_texture, stbn_px, stbn_layer, 0); + j2 = fract(stbn_noise2.g + f32(s * 8u + k) * 0.618033988749895); + } else { + j2 = interleaved_gradient_noise(pixel_coords, 2000u + s * 8u + k); + } + let sub_t = t0 + (f32(k) + clamp(j2, 0.05, 0.95)) * sub_dt; + let p = pos + ray_dir * sub_t; + let r_p = length(p); + + let absorption = sample_density_lut(r_p, ABSORPTION_DENSITY); + let scattering = sample_density_lut(r_p, SCATTERING_DENSITY); + var extinction = absorption + scattering; + + // Cloud extinction on the view ray. + let cloud_density = get_cloud_medium_density(r_p, p); + if (cloud_density > 0.0) { + let cloud_extinction = cloud_density * (get_cloud_scattering_coeff() + get_cloud_absorption_coeff()); + extinction += vec3(cloud_extinction); + } + + let sample_optical_depth = extinction * sub_dt; + optical_depth += sample_optical_depth; + let sample_transmittance = exp(-sample_optical_depth); + + let inscattering = sample_local_inscattering(scattering, ray_dir, p, pixel_coords); + let s_int = (inscattering - inscattering * sample_transmittance) / max(extinction, MIN_EXTINCTION); + result.inscattering += result.transmittance * s_int; + result.transmittance *= sample_transmittance; + + if all(result.transmittance < vec3(0.001)) { + stop = true; + } + } + } + #else + // Default uniform stepping when clouds are disabled. + var prev_t = t_start; for (var s = 0.0; s < sample_count; s += 1.0) { - // Linear distribution from atmosphere entry to exit/ground - let t_i = t_start + t_total * (s + MIDPOINT_RATIO) / sample_count; + let jitter = interleaved_gradient_noise(pixel_coords, u32(s)); // [0, 1] + let t_i = t_start + t_total * (s + jitter) / sample_count; let dt_i = (t_i - prev_t); prev_t = t_i; let sample_pos = pos + ray_dir * t_i; let local_r = length(sample_pos); - let local_up = normalize(sample_pos); let absorption = sample_density_lut(local_r, ABSORPTION_DENSITY); let scattering = sample_density_lut(local_r, SCATTERING_DENSITY); @@ -471,20 +830,15 @@ fn raymarch_atmosphere( optical_depth += sample_optical_depth; let sample_transmittance = exp(-sample_optical_depth); - let inscattering = sample_local_inscattering( - scattering, - ray_dir, - sample_pos - ); - + let inscattering = sample_local_inscattering(scattering, ray_dir, sample_pos, pixel_coords); let s_int = (inscattering - inscattering * sample_transmittance) / max(extinction, MIN_EXTINCTION); result.inscattering += result.transmittance * s_int; - result.transmittance *= sample_transmittance; if all(result.transmittance < vec3(0.001)) { break; } } + #endif // include reflected luminance from planet ground if ground && ray_intersects_ground(r, mu) { @@ -498,7 +852,10 @@ fn raymarch_atmosphere( let sphere_normal = normalize(sphere_point); let mu_light = dot(light_dir, sphere_normal); let transmittance_to_light = sample_transmittance_lut(0.0, mu_light); - let light_luminance = transmittance_to_light * max(mu_light, 0.0) * light_color; + var light_luminance = transmittance_to_light * max(mu_light, 0.0) * light_color; + #ifdef CLOUDS_ENABLED + light_luminance *= sample_cloud_shadow_map(sphere_point, light_dir, true); + #endif // Normalized Lambert BRDF let ground_luminance = transmittance_to_ground * atmosphere.ground_albedo / PI; result.inscattering += ground_luminance * light_luminance; diff --git a/crates/bevy_pbr/src/atmosphere/mod.rs b/crates/bevy_pbr/src/atmosphere/mod.rs index 183c3e69132e5..3ba3b67309fbd 100644 --- a/crates/bevy_pbr/src/atmosphere/mod.rs +++ b/crates/bevy_pbr/src/atmosphere/mod.rs @@ -37,7 +37,9 @@ //! [Unreal Engine Implementation]: https://github.com/sebh/UnrealEngineSkyAtmosphere mod environment; +pub mod fbm_noise; mod node; +pub mod perlin_worley_noise; pub mod resources; use bevy_app::{App, Plugin, Update}; @@ -50,15 +52,15 @@ use bevy_core_pipeline::{ use bevy_ecs::{ component::Component, entity::Entity, - query::{Changed, With}, + query::{Changed, QueryItem, With}, schedule::IntoScheduleConfigs, - system::{Commands, Query}, + system::{lifetimeless::Read, Commands, Query}, }; use bevy_light::{atmosphere::ScatteringMedium, Atmosphere}; use bevy_math::{Mat4, UVec2, UVec3, Vec3}; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_render::{ - extract_component::{ExtractComponentPlugin, UniformComponentPlugin}, + extract_component::{ExtractComponent, ExtractComponentPlugin, UniformComponentPlugin}, render_resource::{DownlevelFlags, ShaderType, SpecializedRenderPipelines}, renderer::RenderDevice, sync_component::{SyncComponent, SyncComponentPlugin}, @@ -68,7 +70,7 @@ use bevy_render::{ use bevy_render::{ render_resource::{TextureFormat, TextureUsages}, renderer::RenderAdapter, - GpuResourceAppExt, Render, RenderApp, RenderSystems, + Render, RenderApp, RenderSystems, }; use bevy_transform::components::GlobalTransform; @@ -78,7 +80,16 @@ use environment::{ prepare_atmosphere_probe_bind_groups, prepare_atmosphere_probe_components, prepare_probe_textures, AtmosphereEnvironmentMap, }; +use fbm_noise::{ + generate_fbm_noise_once, init_fbm_noise_params_buffer, init_fbm_noise_texture, + prepare_fbm_noise_bind_group, FbmNoiseBindGroupLayout, FbmNoisePipeline, NoiseGenerated, +}; use node::{atmosphere_luts, render_sky}; +use perlin_worley_noise::{ + generate_perlin_worley_noise_once, init_perlin_worley_noise_params_buffer, + init_perlin_worley_noise_texture, prepare_perlin_worley_noise_bind_group, + PerlinWorleyNoiseBindGroupLayout, PerlinWorleyNoiseGenerated, PerlinWorleyNoisePipeline, +}; use resources::{ prepare_atmosphere_transforms, prepare_atmosphere_uniforms, queue_render_sky_pipelines, AtmosphereTransforms, GpuAtmosphere, RenderSkyBindGroupLayouts, @@ -88,8 +99,10 @@ use tracing::warn; use crate::resources::prepare_atmosphere_buffers; use self::resources::{ - prepare_atmosphere_bind_groups, prepare_atmosphere_textures, AtmosphereBindGroupLayouts, - AtmosphereLutPipelines, AtmosphereSampler, + prepare_atmosphere_bind_groups, prepare_atmosphere_textures, + prepare_cloud_shadow_temporal_params, AtmosphereBindGroupLayouts, AtmosphereLutPipelines, + AtmosphereSampler, CloudNoiseSampler, CloudShadowTemporalParamsBuffer, + CloudShadowTemporalState, }; #[doc(hidden)] @@ -101,6 +114,7 @@ impl Plugin for AtmospherePlugin { load_shader_library!(app, "functions.wgsl"); load_shader_library!(app, "bruneton_functions.wgsl"); load_shader_library!(app, "bindings.wgsl"); + load_shader_library!(app, "clouds.wgsl"); embedded_asset!(app, "transmittance_lut.wgsl"); embedded_asset!(app, "multiscattering_lut.wgsl"); @@ -108,12 +122,19 @@ impl Plugin for AtmospherePlugin { embedded_asset!(app, "aerial_view_lut.wgsl"); embedded_asset!(app, "render_sky.wgsl"); embedded_asset!(app, "environment.wgsl"); + embedded_asset!(app, "fbm_noise_3d.wgsl"); + embedded_asset!(app, "perlin_worley_noise_3d.wgsl"); + embedded_asset!(app, "cloud_shadow_map.wgsl"); + embedded_asset!(app, "cloud_shadow_filter.wgsl"); + embedded_asset!(app, "cloud_shadow_temporal.wgsl"); app.add_plugins(( ExtractComponentPlugin::::default(), + ExtractComponentPlugin::::default(), SyncComponentPlugin::::default(), UniformComponentPlugin::::default(), UniformComponentPlugin::::default(), + UniformComponentPlugin::::default(), )) .add_systems(Update, prepare_atmosphere_probe_components); @@ -158,14 +179,31 @@ impl Plugin for AtmospherePlugin { render_app .insert_resource(AtmosphereBindGroupLayouts::new()) - .init_gpu_resource::() - .init_gpu_resource::() - .init_gpu_resource::() - .init_gpu_resource::() - .init_gpu_resource::>() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::>() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_systems( RenderStartup, - (init_atmosphere_probe_layout, init_atmosphere_probe_pipeline).chain(), + ( + init_atmosphere_probe_layout, + init_atmosphere_probe_pipeline, + init_fbm_noise_texture, + init_fbm_noise_params_buffer, + init_perlin_worley_noise_texture, + init_perlin_worley_noise_params_buffer, + ) + .chain(), ) .add_systems( Render, @@ -181,7 +219,18 @@ impl Plugin for AtmospherePlugin { .before(RenderSystems::PrepareResources), prepare_atmosphere_probe_bind_groups.in_set(RenderSystems::PrepareBindGroups), prepare_atmosphere_transforms.in_set(RenderSystems::PrepareResources), + prepare_cloud_shadow_temporal_params + .in_set(RenderSystems::PrepareBindGroups) + .before(prepare_atmosphere_bind_groups), prepare_atmosphere_bind_groups.in_set(RenderSystems::PrepareBindGroups), + prepare_fbm_noise_bind_group.in_set(RenderSystems::PrepareBindGroups), + prepare_perlin_worley_noise_bind_group.in_set(RenderSystems::PrepareBindGroups), + generate_fbm_noise_once + .in_set(RenderSystems::Render) + .after(RenderSystems::PrepareBindGroups), + generate_perlin_worley_noise_once + .in_set(RenderSystems::Render) + .after(RenderSystems::PrepareBindGroups), prepare_atmosphere_buffers.in_set(RenderSystems::PrepareResources), ), ) @@ -328,6 +377,49 @@ pub struct AtmosphereSettings { /// The rendering method to use for the atmosphere. pub rendering_method: AtmosphereMode, + + /// Resolution of the cloud shadow map used to shadow directional lights when + /// rendering in [`AtmosphereMode::Raymarched`]. + /// + /// Higher resolution improves stability and reduces aliasing at grazing angles, + /// but increases compute cost. + pub cloud_shadow_map_size: UVec2, + + /// Half-extent of the orthographic cloud shadow map volume in meters. + /// + /// The shadow map covers a square region of size \(2 \cdot extent\) around the view + /// position, oriented in light space. + pub cloud_shadow_map_extent: f32, + + /// Number of samples used when building the cloud shadow map. + pub cloud_shadow_map_samples: u32, + + /// Strength multiplier applied to the cloud shadow map's stored extinction / optical depth. + pub cloud_shadow_map_strength: f32, + + /// Number of spatial filter iterations applied to the cloud shadow map after tracing. + /// + /// This is the main knob to trade performance for stability once the shadow map uses jittered sampling. + /// Values above 4 are usually not worth it. + pub cloud_shadow_map_spatial_filter_iterations: u32, + + /// Enable temporal filtering for the cloud shadow map to reduce edge flickering. + /// + /// When enabled, the shadow map is blended with the previous frame's result, + /// stabilizing edges over time. Based on Unreal's approach. + pub cloud_shadow_temporal_enabled: bool, + + /// Weight of the current frame in the temporal blend (0.0–1.0). + /// + /// Lower values (e.g. 0.1–0.2) produce more stable shadows but slower convergence. + /// Higher values (e.g. 0.5) converge faster but may retain more flicker. + pub cloud_shadow_temporal_alpha: f32, + + /// Light rotation threshold (degrees) beyond which history is invalidated. + /// + /// When the sun moves more than this angle, temporal accumulation is reset + /// to avoid ghosting. Unreal uses 10°. + pub cloud_shadow_temporal_light_rotation_cut_deg: f32, } impl Default for AtmosphereSettings { @@ -345,6 +437,16 @@ impl Default for AtmosphereSettings { aerial_view_lut_max_distance: 3.2e4, sky_max_samples: 16, rendering_method: AtmosphereMode::LookupTexture, + + // Cloud shadow map defaults (only used by Raymarched mode). + cloud_shadow_map_size: UVec2::new(1024, 1024), + cloud_shadow_map_extent: 64_000.0, + cloud_shadow_map_samples: 48, + cloud_shadow_map_strength: 1.0, + cloud_shadow_map_spatial_filter_iterations: 0, + cloud_shadow_temporal_enabled: false, + cloud_shadow_temporal_alpha: 0.15, + cloud_shadow_temporal_light_rotation_cut_deg: 10.0, } } } @@ -364,6 +466,14 @@ pub struct GpuAtmosphereSettings { pub aerial_view_lut_max_distance: f32, pub sky_max_samples: u32, pub rendering_method: u32, + pub cloud_shadow_map_size: UVec2, + pub cloud_shadow_map_extent: f32, + pub cloud_shadow_map_samples: u32, + pub cloud_shadow_map_strength: f32, + pub cloud_shadow_map_spatial_filter_iterations: u32, + pub cloud_shadow_temporal_enabled: u32, + pub cloud_shadow_temporal_alpha: f32, + pub cloud_shadow_temporal_light_rotation_cut_deg: f32, } impl Default for GpuAtmosphereSettings { @@ -387,6 +497,16 @@ impl From for GpuAtmosphereSettings { aerial_view_lut_max_distance: s.aerial_view_lut_max_distance, sky_max_samples: s.sky_max_samples, rendering_method: s.rendering_method as u32, + cloud_shadow_map_size: s.cloud_shadow_map_size, + cloud_shadow_map_extent: s.cloud_shadow_map_extent, + cloud_shadow_map_samples: s.cloud_shadow_map_samples, + cloud_shadow_map_strength: s.cloud_shadow_map_strength, + cloud_shadow_map_spatial_filter_iterations: s + .cloud_shadow_map_spatial_filter_iterations, + cloud_shadow_temporal_enabled: s.cloud_shadow_temporal_enabled as u32, + cloud_shadow_temporal_alpha: s.cloud_shadow_temporal_alpha, + cloud_shadow_temporal_light_rotation_cut_deg: s + .cloud_shadow_temporal_light_rotation_cut_deg, } } } @@ -422,3 +542,70 @@ pub enum AtmosphereMode { /// accurate long-distance lighting. Raymarched = 1, } + +/// Component that adds a volumetric cloud layer to the atmosphere. +/// Add this component to a 3D camera with [`AtmosphereSettings`] to enable clouds. +#[derive(Clone, Component, Reflect, ShaderType)] +#[reflect(Clone, Default)] +pub struct CloudLayer { + /// Altitude at which the cloud layer starts (from planet center) + /// units: m + pub cloud_layer_start: f32, + + /// Altitude at which the cloud layer ends (from planet center) + /// units: m + pub cloud_layer_end: f32, + + /// Density multiplier for the clouds + pub cloud_density: f32, + + /// Absorption coefficient for clouds + pub cloud_absorption: f32, + + /// Scattering coefficient for clouds + pub cloud_scattering: f32, + + /// Scale of the noise texture in world space + pub noise_scale: f32, + + /// Offset for animating the noise texture + pub noise_offset: Vec3, + + /// Scale of the detail noise in world space (smaller = higher frequency detail). + /// units: m + pub detail_noise_scale: f32, + + /// Strength of the detail noise modulation in [0,1]. + /// 0 = disabled, 1 = full modulation. + pub detail_strength: f32, +} + +impl Default for CloudLayer { + fn default() -> Self { + Self { + cloud_layer_start: 6_362_000.0, // 1km above Earth's surface + cloud_layer_end: 6_363_000.0, // 5km above Earth's surface + cloud_density: 1.0, + cloud_absorption: 0.00005, // Physically correct: ~0.00005 m^-1 per unit density + cloud_scattering: 0.0008, // Physically correct: ~0.0008 m^-1 per unit density + noise_scale: 64_000.0, + noise_offset: Vec3::ZERO, + detail_noise_scale: 16_000.0, + detail_strength: 1.0, + } + } +} + +impl SyncComponent for CloudLayer { + type Target = Self; +} + +impl ExtractComponent for CloudLayer { + type QueryData = Read; + type QueryFilter = With; + type Out = Self; + + fn extract_component(item: QueryItem<'_, '_, Self::QueryData>) -> Option { + Some(item.clone()) + } +} diff --git a/crates/bevy_pbr/src/atmosphere/node.rs b/crates/bevy_pbr/src/atmosphere/node.rs index 8a531e743b7e5..42388a281cb6d 100644 --- a/crates/bevy_pbr/src/atmosphere/node.rs +++ b/crates/bevy_pbr/src/atmosphere/node.rs @@ -1,5 +1,6 @@ use bevy_camera::{MainPassResolutionOverride, Viewport}; use bevy_ecs::system::Res; +use bevy_image::ToExtents; use bevy_math::{UVec2, Vec3Swizzles}; use bevy_render::{ camera::ExtractedCamera, @@ -13,21 +14,23 @@ use crate::{resources::GpuAtmosphere, ViewLightsUniformOffset}; use super::{ resources::{ - AtmosphereBindGroups, AtmosphereLutPipelines, AtmosphereTransformsOffset, - RenderSkyPipelineId, + AtmosphereBindGroups, AtmosphereLutPipelines, AtmosphereTextures, + AtmosphereTransformsOffset, RenderSkyPipelineId, }, - GpuAtmosphereSettings, + CloudLayer, GpuAtmosphereSettings, }; pub fn atmosphere_luts( view: ViewQuery<( &GpuAtmosphereSettings, &AtmosphereBindGroups, + &AtmosphereTextures, &DynamicUniformIndex, &DynamicUniformIndex, &AtmosphereTransformsOffset, &ViewUniformOffset, &ViewLightsUniformOffset, + Option<&DynamicUniformIndex>, )>, pipelines: Res, pipeline_cache: Res, @@ -36,11 +39,13 @@ pub fn atmosphere_luts( let ( settings, bind_groups, + textures, atmosphere_uniforms_offset, settings_uniforms_offset, atmosphere_transforms_offset, view_uniforms_offset, lights_uniforms_offset, + cloud_layer_uniforms_offset, ) = view.into_inner(); let ( @@ -58,12 +63,11 @@ pub fn atmosphere_luts( return; }; - let command_encoder = ctx.command_encoder(); - - let mut luts_pass = command_encoder.begin_compute_pass(&ComputePassDescriptor { - label: Some("atmosphere_luts"), - timestamp_writes: None, - }); + let (cloud_shadow_map_pipeline, cloud_shadow_filter_pipeline, cloud_shadow_temporal_pipeline) = ( + pipeline_cache.get_compute_pipeline(pipelines.cloud_shadow_map), + pipeline_cache.get_compute_pipeline(pipelines.cloud_shadow_filter), + pipeline_cache.get_compute_pipeline(pipelines.cloud_shadow_temporal), + ); fn dispatch_2d(compute_pass: &mut ComputePass, size: UVec2) { const WORKGROUP_SIZE: u32 = 16; @@ -72,70 +76,168 @@ pub fn atmosphere_luts( compute_pass.dispatch_workgroups(workgroups_x, workgroups_y, 1); } - // Transmittance LUT + fn dispatch_2d_temporal(compute_pass: &mut ComputePass, size: UVec2) { + const WORKGROUP_SIZE: u32 = 8; + let workgroups_x = size.x.div_ceil(WORKGROUP_SIZE); + let workgroups_y = size.y.div_ceil(WORKGROUP_SIZE); + compute_pass.dispatch_workgroups(workgroups_x, workgroups_y, 1); + } - luts_pass.set_pipeline(transmittance_lut_pipeline); - luts_pass.set_bind_group( - 0, - &bind_groups.transmittance_lut, - &[ - atmosphere_uniforms_offset.index(), - settings_uniforms_offset.index(), - ], - ); + let command_encoder = ctx.command_encoder(); - dispatch_2d(&mut luts_pass, settings.transmittance_lut_size); + // Pass 1: build all LUTs (+ cloud shadow map tracing) in a single compute pass. + { + let mut luts_pass = command_encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("atmosphere_luts"), + timestamp_writes: None, + }); - // Multiscattering LUT + // Transmittance LUT + luts_pass.set_pipeline(transmittance_lut_pipeline); + luts_pass.set_bind_group( + 0, + &bind_groups.transmittance_lut, + &[ + atmosphere_uniforms_offset.index(), + settings_uniforms_offset.index(), + ], + ); + dispatch_2d(&mut luts_pass, settings.transmittance_lut_size); - luts_pass.set_pipeline(multiscattering_lut_pipeline); - luts_pass.set_bind_group( - 0, - &bind_groups.multiscattering_lut, - &[ - atmosphere_uniforms_offset.index(), - settings_uniforms_offset.index(), - ], - ); + // Multiscattering LUT + luts_pass.set_pipeline(multiscattering_lut_pipeline); + luts_pass.set_bind_group( + 0, + &bind_groups.multiscattering_lut, + &[ + atmosphere_uniforms_offset.index(), + settings_uniforms_offset.index(), + ], + ); + luts_pass.dispatch_workgroups( + settings.multiscattering_lut_size.x, + settings.multiscattering_lut_size.y, + 1, + ); - luts_pass.dispatch_workgroups( - settings.multiscattering_lut_size.x, - settings.multiscattering_lut_size.y, - 1, - ); + // Sky View LUT + luts_pass.set_pipeline(sky_view_lut_pipeline); + luts_pass.set_bind_group( + 0, + &bind_groups.sky_view_lut, + &[ + atmosphere_uniforms_offset.index(), + settings_uniforms_offset.index(), + atmosphere_transforms_offset.index(), + view_uniforms_offset.offset, + lights_uniforms_offset.offset, + ], + ); + dispatch_2d(&mut luts_pass, settings.sky_view_lut_size); - // Sky View LUT - - luts_pass.set_pipeline(sky_view_lut_pipeline); - luts_pass.set_bind_group( - 0, - &bind_groups.sky_view_lut, - &[ - atmosphere_uniforms_offset.index(), - settings_uniforms_offset.index(), - atmosphere_transforms_offset.index(), - view_uniforms_offset.offset, - lights_uniforms_offset.offset, - ], - ); + // Aerial View LUT + luts_pass.set_pipeline(aerial_view_lut_pipeline); + luts_pass.set_bind_group( + 0, + &bind_groups.aerial_view_lut, + &[ + atmosphere_uniforms_offset.index(), + settings_uniforms_offset.index(), + view_uniforms_offset.offset, + lights_uniforms_offset.offset, + ], + ); + dispatch_2d(&mut luts_pass, settings.aerial_view_lut_size.xy()); - dispatch_2d(&mut luts_pass, settings.sky_view_lut_size); + // Cloud shadow map (Unreal-style front depth + extinction stats) + // Only needed for the Raymarched mode. + if settings.rendering_method == 1 { + if let (Some(cloud_shadow_map_pipeline), Some(_cloud_shadow_filter_pipeline)) = + (cloud_shadow_map_pipeline, cloud_shadow_filter_pipeline) + { + if let (Some(cloud_layer_uniforms_offset), Some(cloud_shadow_map_bg)) = ( + cloud_layer_uniforms_offset.as_ref(), + bind_groups.cloud_shadow_map.as_ref(), + ) { + luts_pass.set_pipeline(cloud_shadow_map_pipeline); + luts_pass.set_bind_group( + 0, + cloud_shadow_map_bg, + &[ + atmosphere_uniforms_offset.index(), + settings_uniforms_offset.index(), + view_uniforms_offset.offset, + lights_uniforms_offset.offset, + cloud_layer_uniforms_offset.index(), + ], + ); + dispatch_2d(&mut luts_pass, settings.cloud_shadow_map_size); + } + } + } + } - // Aerial View LUT + // IMPORTANT: + // We run the cloud shadow *filter* in a separate compute pass so the backend can insert the + // required resource state transitions (storage-write -> sampled-read) between tracing and filtering. + // Without this, the filter can appear to do nothing on some backends. + if settings.rendering_method == 1 + && settings.cloud_shadow_map_spatial_filter_iterations > 0 + && cloud_layer_uniforms_offset.is_some() + && bind_groups.cloud_shadow_filter_a_to_b.is_some() + && bind_groups.cloud_shadow_filter_b_to_a.is_some() + { + let Some(cloud_shadow_filter_pipeline) = cloud_shadow_filter_pipeline else { + return; + }; + let iters = settings.cloud_shadow_map_spatial_filter_iterations; + let iters_even = (iters + 1) & !1; + if iters_even > 0 { + let mut filter_pass = command_encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("cloud_shadow_filter"), + timestamp_writes: None, + }); - luts_pass.set_pipeline(aerial_view_lut_pipeline); - luts_pass.set_bind_group( - 0, - &bind_groups.aerial_view_lut, - &[ - atmosphere_uniforms_offset.index(), - settings_uniforms_offset.index(), - view_uniforms_offset.offset, - lights_uniforms_offset.offset, - ], - ); + filter_pass.set_pipeline(cloud_shadow_filter_pipeline); + for i in 0..iters_even { + let bg = if (i & 1) == 0 { + bind_groups.cloud_shadow_filter_a_to_b.as_ref().unwrap() + } else { + bind_groups.cloud_shadow_filter_b_to_a.as_ref().unwrap() + }; + filter_pass.set_bind_group(0, bg, &[settings_uniforms_offset.index()]); + dispatch_2d(&mut filter_pass, settings.cloud_shadow_map_size); + } + } + } + + // Temporal filter: blend current (tmp) with history, write to cloud_shadow_map. + // Then copy cloud_shadow_map -> history for next frame. + if settings.rendering_method == 1 + && settings.cloud_shadow_temporal_enabled != 0 + && cloud_layer_uniforms_offset.is_some() + { + if let (Some(temporal_pipeline), Some(temporal_bg)) = ( + cloud_shadow_temporal_pipeline, + bind_groups.cloud_shadow_temporal.as_ref(), + ) { + let mut temporal_pass = command_encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("cloud_shadow_temporal"), + timestamp_writes: None, + }); + temporal_pass.set_pipeline(temporal_pipeline); + temporal_pass.set_bind_group(0, temporal_bg, &[]); + dispatch_2d_temporal(&mut temporal_pass, settings.cloud_shadow_map_size); + } - dispatch_2d(&mut luts_pass, settings.aerial_view_lut_size.xy()); + // Copy cloud_shadow_map to history for next frame. + let copy_size = settings.cloud_shadow_map_size.to_extents(); + command_encoder.copy_texture_to_texture( + textures.cloud_shadow_map.texture.as_image_copy(), + textures.cloud_shadow_map_history.texture.as_image_copy(), + copy_size, + ); + } } pub fn render_sky( @@ -148,6 +250,7 @@ pub fn render_sky( &AtmosphereTransformsOffset, &ViewUniformOffset, &ViewLightsUniformOffset, + Option<&DynamicUniformIndex>, &RenderSkyPipelineId, Option<&MainPassResolutionOverride>, )>, @@ -163,6 +266,7 @@ pub fn render_sky( atmosphere_transforms_offset, view_uniforms_offset, lights_uniforms_offset, + cloud_layer_uniforms_offset, render_sky_pipeline_id, resolution_override, ) = view.into_inner(); @@ -197,16 +301,39 @@ pub fn render_sky( } render_sky_pass.set_pipeline(render_sky_pipeline); - render_sky_pass.set_bind_group( - 0, - &atmosphere_bind_groups.render_sky, - &[ - atmosphere_uniforms_offset.index(), - settings_uniforms_offset.index(), - atmosphere_transforms_offset.index(), - view_uniforms_offset.offset, - lights_uniforms_offset.offset, - ], - ); + + // Select correct bind group + dynamic offsets based on whether the view has CloudLayer. + // No-cloud variant omits the CloudLayer binding entirely. + // + // If cloud bind group isn't ready yet, skip this pass (pipeline cache will catch up next frame). + let (bind_group, cloud_layer_offset) = match ( + cloud_layer_uniforms_offset.as_ref(), + atmosphere_bind_groups.render_sky_clouds.as_ref(), + ) { + (Some(offset), Some(bg)) => (bg, Some(offset)), + _ => (&atmosphere_bind_groups.render_sky_no_clouds, None), + }; + + let offsets_no_clouds = [ + atmosphere_uniforms_offset.index(), + settings_uniforms_offset.index(), + atmosphere_transforms_offset.index(), + view_uniforms_offset.offset, + lights_uniforms_offset.offset, + ]; + + if let Some(cloud_layer_offset) = cloud_layer_offset { + let offsets_clouds = [ + offsets_no_clouds[0], + offsets_no_clouds[1], + offsets_no_clouds[2], + offsets_no_clouds[3], + offsets_no_clouds[4], + cloud_layer_offset.index(), + ]; + render_sky_pass.set_bind_group(0, bind_group, &offsets_clouds); + } else { + render_sky_pass.set_bind_group(0, bind_group, &offsets_no_clouds); + } render_sky_pass.draw(0..3, 0..1); } diff --git a/crates/bevy_pbr/src/atmosphere/perlin_worley_noise.rs b/crates/bevy_pbr/src/atmosphere/perlin_worley_noise.rs new file mode 100644 index 0000000000000..7759427738453 --- /dev/null +++ b/crates/bevy_pbr/src/atmosphere/perlin_worley_noise.rs @@ -0,0 +1,260 @@ +//! Tileable 3D Perlin–Worley Noise Generation for Volumetric Clouds +//! +//! Generates a 3D texture (RGBA) similar to the reference Shadertoy: +//! - R: Perlin–Worley (remapped billowy Perlin by low-frequency Worley FBM) +//! - G: Worley FBM (base frequency) +//! - B: Worley FBM (2x frequency) +//! - A: Worley FBM (4x frequency) + +use bevy_asset::load_embedded_asset; +use bevy_ecs::{ + resource::Resource, + system::{Res, ResMut}, + world::{FromWorld, World}, +}; +use bevy_math::UVec3; +use bevy_render::{ + render_resource::{binding_types::*, *}, + renderer::{RenderDevice, RenderQueue}, + texture::CachedTexture, +}; +use bevy_utils::default; + +/// Parameters for controlling Perlin–Worley noise generation. +#[derive(Clone, Copy, ShaderType, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +pub struct PerlinWorleyNoiseParams { + /// Base frequency / tile period (must be > 0). + pub base_frequency: u32, + /// Number of octaves for Perlin FBM. + pub perlin_octaves: u32, + /// Z animation offset (in texture space). + pub z_offset: f32, + /// Padding. + pub _pad0: f32, +} + +impl Default for PerlinWorleyNoiseParams { + fn default() -> Self { + Self { + base_frequency: 4, + perlin_octaves: 7, + z_offset: 0.0, + _pad0: 0.0, + } + } +} + +/// Size of the 3D noise texture. +#[derive(Clone, Copy)] +pub struct PerlinWorleyTextureSize { + pub size: UVec3, +} + +impl Default for PerlinWorleyTextureSize { + fn default() -> Self { + Self { + // Reference uses 128 slices; this is ~16MB at RGBA16F. + size: UVec3::new(16, 16, 16), + } + } +} + +#[derive(Resource)] +pub struct PerlinWorleyNoiseBindGroupLayout { + pub layout: BindGroupLayout, + pub descriptor: BindGroupLayoutDescriptor, +} + +impl FromWorld for PerlinWorleyNoiseBindGroupLayout { + fn from_world(world: &mut World) -> Self { + let render_device = world.resource::(); + let descriptor = BindGroupLayoutDescriptor::new( + "perlin_worley_noise_bind_group_layout", + &BindGroupLayoutEntries::with_indices( + ShaderStages::COMPUTE, + ( + ( + // 3D noise texture storage + 13, + texture_storage_3d( + TextureFormat::Rgba16Float, + StorageTextureAccess::WriteOnly, + ), + ), + ( + // params uniform buffer + 14, + uniform_buffer::(false), + ), + ), + ), + ); + + let layout = + render_device.create_bind_group_layout(descriptor.label.as_ref(), &descriptor.entries); + + Self { layout, descriptor } + } +} + +#[derive(Resource)] +pub struct PerlinWorleyNoisePipeline { + pub pipeline: CachedComputePipelineId, +} + +impl FromWorld for PerlinWorleyNoisePipeline { + fn from_world(world: &mut World) -> Self { + let pipeline_cache = world.resource::(); + let layout = world.resource::(); + + let pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some("perlin_worley_noise_3d_pipeline".into()), + layout: vec![layout.descriptor.clone()], + shader: load_embedded_asset!(world, "perlin_worley_noise_3d.wgsl"), + ..default() + }); + + Self { pipeline } + } +} + +/// The generated 3D Perlin–Worley texture. +#[derive(Resource)] +pub struct PerlinWorleyNoiseTexture { + pub texture: CachedTexture, + pub size: UVec3, +} + +#[derive(Resource)] +pub struct PerlinWorleyNoiseBindGroup { + pub bind_group: BindGroup, +} + +#[derive(Resource)] +pub struct PerlinWorleyNoiseParamsBuffer { + pub buffer: Buffer, +} + +/// Tracks if the 3D noise has been generated. +#[derive(Resource, Default)] +pub struct PerlinWorleyNoiseGenerated(pub bool); + +/// Allocates GPU memory directly for the same reason as `init_fbm_noise_texture` in `fbm_noise`. +pub fn init_perlin_worley_noise_texture( + render_device: Res, + mut commands: bevy_ecs::system::Commands, +) { + let size = PerlinWorleyTextureSize::default(); + + let texture_descriptor = TextureDescriptor { + label: Some("perlin_worley_noise_3d_texture"), + size: Extent3d { + width: size.size.x, + height: size.size.y, + depth_or_array_layers: size.size.z, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D3, + format: TextureFormat::Rgba16Float, + usage: TextureUsages::STORAGE_BINDING | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }; + + let texture = render_device.create_texture(&texture_descriptor); + let default_view = texture.create_view(&TextureViewDescriptor::default()); + commands.insert_resource(PerlinWorleyNoiseTexture { + texture: CachedTexture { + texture, + default_view, + }, + size: size.size, + }); +} + +pub fn init_perlin_worley_noise_params_buffer( + mut commands: bevy_ecs::system::Commands, + render_device: Res, +) { + let params = PerlinWorleyNoiseParams::default(); + let buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { + label: Some("perlin_worley_noise_params_buffer"), + contents: bytemuck::cast_slice(&[params]), + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + }); + + commands.insert_resource(PerlinWorleyNoiseParamsBuffer { buffer }); +} + +pub fn prepare_perlin_worley_noise_bind_group( + mut commands: bevy_ecs::system::Commands, + render_device: Res, + layout: Res, + texture: Res, + params_buffer: Res, +) { + let bind_group = render_device.create_bind_group( + "perlin_worley_noise_bind_group", + &layout.layout, + &BindGroupEntries::with_indices(( + (13, &texture.texture.default_view), + (14, params_buffer.buffer.as_entire_binding()), + )), + ); + + commands.insert_resource(PerlinWorleyNoiseBindGroup { bind_group }); +} + +pub fn get_3d_dispatch_size(texture_size: UVec3) -> UVec3 { + const WORKGROUP_SIZE: u32 = 4; + UVec3::new( + texture_size.x.div_ceil(WORKGROUP_SIZE), + texture_size.y.div_ceil(WORKGROUP_SIZE), + texture_size.z.div_ceil(WORKGROUP_SIZE), + ) +} + +/// Generate the Perlin–Worley 3D texture once (after the pipeline is ready). +pub fn generate_perlin_worley_noise_once( + render_device: Res, + render_queue: Res, + pipeline_cache: Res, + pipeline: Res, + bind_group: Option>, + texture: Res, + mut generated: ResMut, +) { + if generated.0 { + return; + } + + let Some(bind_group) = bind_group else { + return; + }; + + let Some(compute_pipeline) = pipeline_cache.get_compute_pipeline(pipeline.pipeline) else { + return; + }; + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor { + label: Some("perlin_worley_noise_generation"), + }); + + { + let mut compute_pass = encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("perlin_worley_noise_3d_pass"), + timestamp_writes: None, + }); + + compute_pass.set_pipeline(compute_pipeline); + compute_pass.set_bind_group(0, &bind_group.bind_group, &[]); + + let dispatch_size = get_3d_dispatch_size(texture.size); + compute_pass.dispatch_workgroups(dispatch_size.x, dispatch_size.y, dispatch_size.z); + } + + let command_buffer = encoder.finish(); + render_queue.submit([command_buffer]); + generated.0 = true; +} diff --git a/crates/bevy_pbr/src/atmosphere/perlin_worley_noise_3d.wgsl b/crates/bevy_pbr/src/atmosphere/perlin_worley_noise_3d.wgsl new file mode 100644 index 0000000000000..7974461b170fe --- /dev/null +++ b/crates/bevy_pbr/src/atmosphere/perlin_worley_noise_3d.wgsl @@ -0,0 +1,176 @@ +// Tileable 3D Perlin–Worley Noise Generator +// Writes RGBA channels: +// - R: Perlin–Worley (remapped billowy Perlin by low-frequency Worley FBM) +// - G: Worley FBM (freq) +// - B: Worley FBM (2*freq) +// - A: Worley FBM (4*freq) + +@group(0) @binding(13) var noise_texture_out: texture_storage_3d; + +struct Params { + base_frequency: u32, + perlin_octaves: u32, + z_offset: f32, + _pad0: f32, +} + +@group(0) @binding(14) var params: Params; + +const UI0: u32 = 1597334673u; +const UI1: u32 = 3812015801u; +const UI2: vec2 = vec2(UI0, UI1); +const UI3: vec3 = vec3(UI0, UI1, 2798796415u); +const UIF: f32 = 1.0 / 4294967295.0; + +fn hash33(p: vec3) -> vec3 { + // Hash by David_Hoskins, adapted to WGSL. + let ip = vec3(p); + var q: vec3 = vec3(u32(ip.x), u32(ip.y), u32(ip.z)) * UI3; + q = (q.xxx ^ q.yyy ^ q.zzz) * UI3; + return -1.0 + 2.0 * vec3(q) * UIF; +} + +fn remap(x: f32, a: f32, b: f32, c: f32, d: f32) -> f32 { + let t = clamp((x - a) / max(1e-6, b - a), 0.0, 1.0); + return mix(c, d, t); +} + +fn imod(a: i32, m: i32) -> i32 { + // positive modulus + let r = a % m; + return select(r + m, r, r >= 0); +} + +fn wrap3(p: vec3, m: i32) -> vec3 { + return vec3( + f32(imod(p.x, m)), + f32(imod(p.y, m)), + f32(imod(p.z, m)), + ); +} + +// Gradient noise by iq (modified to be tileable) +fn gradientNoise(x: vec3, freq: u32) -> f32 { + let period: i32 = max(1, i32(freq)); + // grid + let p = floor(x); + let w = fract(x); + + // quintic interpolant + let u = w * w * w * (w * (w * 6.0 - 15.0) + 10.0); + + // gradients (wrapped lattice) + let pi = vec3(p); + let ga = hash33(wrap3(pi + vec3(0, 0, 0), period)); + let gb = hash33(wrap3(pi + vec3(1, 0, 0), period)); + let gc = hash33(wrap3(pi + vec3(0, 1, 0), period)); + let gd = hash33(wrap3(pi + vec3(1, 1, 0), period)); + let ge = hash33(wrap3(pi + vec3(0, 0, 1), period)); + let gf = hash33(wrap3(pi + vec3(1, 0, 1), period)); + let gg = hash33(wrap3(pi + vec3(0, 1, 1), period)); + let gh = hash33(wrap3(pi + vec3(1, 1, 1), period)); + + // projections + let va = dot(ga, w - vec3(0.0, 0.0, 0.0)); + let vb = dot(gb, w - vec3(1.0, 0.0, 0.0)); + let vc = dot(gc, w - vec3(0.0, 1.0, 0.0)); + let vd = dot(gd, w - vec3(1.0, 1.0, 0.0)); + let ve = dot(ge, w - vec3(0.0, 0.0, 1.0)); + let vf = dot(gf, w - vec3(1.0, 0.0, 1.0)); + let vg = dot(gg, w - vec3(0.0, 1.0, 1.0)); + let vh = dot(gh, w - vec3(1.0, 1.0, 1.0)); + + // interpolation (expanded trilinear) + return va + + u.x * (vb - va) + + u.y * (vc - va) + + u.z * (ve - va) + + u.x * u.y * (va - vb - vc + vd) + + u.y * u.z * (va - vc - ve + vg) + + u.z * u.x * (va - vb - ve + vf) + + u.x * u.y * u.z * (-va + vb + vc - vd + ve - vf - vg + vh); +} + +// Tileable 3D Worley noise (inverted) +fn worleyNoise(uv: vec3, freq: u32) -> f32 { + let period: i32 = max(1, i32(freq)); + let id = floor(uv); + let p = fract(uv); + + var minDist = 1e9; + let idi = vec3(id); + + for (var xo: i32 = -1; xo <= 1; xo++) { + for (var yo: i32 = -1; yo <= 1; yo++) { + for (var zo: i32 = -1; zo <= 1; zo++) { + let offseti = vec3(xo, yo, zo); + let h = hash33(wrap3(idi + offseti, period)) * 0.5 + 0.5; + let hp = h + vec3(f32(xo), f32(yo), f32(zo)); + let d = p - hp; + minDist = min(minDist, dot(d, d)); + } + } + } + + return 1.0 - minDist; +} + +// Tileable Worley FBM inspired by Andrew Schneider's Real-Time Volumetric Cloudscapes (GPU Pro 7) +fn worleyFbm(p: vec3, freq: u32) -> f32 { + let f0 = max(1u, freq); + let f1 = max(1u, f0 * 2u); + let f2 = max(1u, f0 * 4u); + return worleyNoise(p * f32(f0), f0) * 0.625 + + worleyNoise(p * f32(f1), f1) * 0.25 + + worleyNoise(p * f32(f2), f2) * 0.125; +} + +// FBM for Perlin/gradient noise (iq-inspired) — returns roughly [-1,1] +fn perlinfbm(p: vec3, freq: u32, octaves: u32) -> f32 { + let G = exp2(-0.85); + var amp = 1.0; + var f = max(1u, freq); + var n = 0.0; + for (var i: u32 = 0u; i < octaves; i++) { + n += amp * gradientNoise(p * f32(f), f); + f *= 2u; + amp *= G; + } + return n; +} + +@compute +@workgroup_size(4, 4, 4) +fn main(@builtin(global_invocation_id) gid: vec3) { + let dims = textureDimensions(noise_texture_out); + if (gid.x >= dims.x || gid.y >= dims.y || gid.z >= dims.z) { + return; + } + + // Normalized coordinates in [0,1) + let uvw = (vec3(gid) + 0.5) / vec3(dims); + let p = vec3(uvw.xy, fract(uvw.z + params.z_offset)); + + let base = max(1u, params.base_frequency); + + // Billowy Perlin FBM in [0,1] + var pfbm = mix(1.0, perlinfbm(p, base, params.perlin_octaves), 0.5); + pfbm = abs(pfbm * 2.0 - 1.0); + pfbm = clamp(pfbm, 0.0, 1.0); + + // Worley FBMs at 3 frequencies + let w0 = clamp(worleyFbm(p, base), 0.0, 1.0); + let w1 = clamp(worleyFbm(p, base * 2u), 0.0, 1.0); + let w2 = clamp(worleyFbm(p, base * 4u), 0.0, 1.0); + + // Perlin–Worley: remap Perlin by low-frequency Worley + let pw = remap(pfbm, 0.0, 1.0, w0, 1.0); + + textureStore( + noise_texture_out, + vec3(gid), + vec4(pw, w0, w1, w2), + ); +} + + diff --git a/crates/bevy_pbr/src/atmosphere/render_sky.wgsl b/crates/bevy_pbr/src/atmosphere/render_sky.wgsl index 16a9aa994a3b1..f65a91f908954 100644 --- a/crates/bevy_pbr/src/atmosphere/render_sky.wgsl +++ b/crates/bevy_pbr/src/atmosphere/render_sky.wgsl @@ -1,7 +1,7 @@ enable dual_source_blending; #import bevy_pbr::atmosphere::{ - bindings::{view, settings}, + bindings::{view, settings, cloud_shadow_map, atmosphere_lut_sampler}, functions::{ sample_transmittance_lut, sample_transmittance_lut_segment, sample_sky_view_lut, direction_world_to_atmosphere, diff --git a/crates/bevy_pbr/src/atmosphere/resources.rs b/crates/bevy_pbr/src/atmosphere/resources.rs index 6f77899ab1f98..8393b57ccf3fe 100644 --- a/crates/bevy_pbr/src/atmosphere/resources.rs +++ b/crates/bevy_pbr/src/atmosphere/resources.rs @@ -1,5 +1,7 @@ use crate::{ - ExtractedAtmosphere, GpuLights, GpuScatteringMedium, LightMeta, ScatteringMediumSampler, + fbm_noise::FbmNoiseTexture, perlin_worley_noise::PerlinWorleyNoiseTexture, Bluenoise, + CloudLayer, ExtractedAtmosphere, ExtractedDirectionalLight, GpuLights, GpuScatteringMedium, + LightMeta, ScatteringMediumSampler, }; use bevy_asset::{load_embedded_asset, AssetId, Handle}; use bevy_camera::{Camera, Camera3d}; @@ -16,13 +18,13 @@ use bevy_ecs::{ }; use bevy_image::ToExtents; use bevy_light::atmosphere::ScatteringMedium; -use bevy_math::{Affine3A, Mat4, Vec3, Vec3A}; +use bevy_math::{Affine3A, Mat4, UVec2, Vec2, Vec3, Vec3A}; use bevy_render::{ extract_component::ComponentUniforms, render_asset::RenderAssets, - render_resource::{binding_types::*, *}, + render_resource::{binding_types::*, TextureViewDescriptor, TextureViewDimension, *}, renderer::{RenderDevice, RenderQueue}, - texture::{CachedTexture, TextureCache}, + texture::{CachedTexture, FallbackImage, GpuImage, TextureCache}, view::{ExtractedView, Msaa, ViewDepthTexture, ViewUniform, ViewUniforms}, }; use bevy_shader::Shader; @@ -36,12 +38,22 @@ pub(crate) struct AtmosphereBindGroupLayouts { pub multiscattering_lut: BindGroupLayoutDescriptor, pub sky_view_lut: BindGroupLayoutDescriptor, pub aerial_view_lut: BindGroupLayoutDescriptor, + pub cloud_shadow_map: BindGroupLayoutDescriptor, + // Accessed from the render-world graph; some tooling can miss the cross-module usage. + #[allow(dead_code)] + pub cloud_shadow_filter: BindGroupLayoutDescriptor, + pub cloud_shadow_temporal: BindGroupLayoutDescriptor, } #[derive(Resource)] pub(crate) struct RenderSkyBindGroupLayouts { - pub render_sky: BindGroupLayoutDescriptor, - pub render_sky_msaa: BindGroupLayoutDescriptor, + // Two variants: + // - *_clouds: includes CloudLayer + cloud noise + cloud shadow map bindings + // - *_no_clouds: omits cloud-related bindings (cheaper, and works when CloudLayer is removed) + pub render_sky_clouds: BindGroupLayoutDescriptor, + pub render_sky_msaa_clouds: BindGroupLayoutDescriptor, + pub render_sky_no_clouds: BindGroupLayoutDescriptor, + pub render_sky_msaa_no_clouds: BindGroupLayoutDescriptor, pub fullscreen_shader: FullscreenShader, pub fragment_shader: Handle, } @@ -156,19 +168,142 @@ impl AtmosphereBindGroupLayouts { ), ); + let cloud_shadow_map = BindGroupLayoutDescriptor::new( + "cloud_shadow_map_bind_group_layout", + &BindGroupLayoutEntries::with_indices( + ShaderStages::COMPUTE, + ( + (0, uniform_buffer::(true)), + (1, uniform_buffer::(true)), + (3, uniform_buffer::(true)), + (4, uniform_buffer::(true)), + (14, uniform_buffer::(true)), + ( + 15, + texture_2d(TextureSampleType::Float { filterable: true }), + ), + (16, sampler(SamplerBindingType::Filtering)), + // 3D Perlin–Worley noise texture used by cloud density shaping + ( + 18, + texture_3d(TextureSampleType::Float { filterable: true }), + ), + // STBN for temporal stratification (different layer per frame) + ( + 19, + texture_2d_array(TextureSampleType::Float { filterable: true }), + ), + ( + 13, + texture_storage_2d( + TextureFormat::Rgba16Float, + StorageTextureAccess::WriteOnly, + ), + ), + ), + ), + ); + + // Cloud shadow spatial filter (ping-pong). + // Reads: sampled shadow map (rgba16f) + // Writes: storage shadow map (rgba16f) + let cloud_shadow_filter = BindGroupLayoutDescriptor::new( + "cloud_shadow_filter_bind_group_layout", + &BindGroupLayoutEntries::with_indices( + ShaderStages::COMPUTE, + ( + (1, uniform_buffer::(true)), + (12, sampler(SamplerBindingType::Filtering)), + ( + 17, + texture_2d(TextureSampleType::Float { filterable: true }), + ), + ( + 13, + texture_storage_2d( + TextureFormat::Rgba16Float, + StorageTextureAccess::WriteOnly, + ), + ), + ), + ), + ); + + // Cloud shadow temporal filter. + // Reads: traced output (storage), history (sampled) + // Writes: output (storage) + let cloud_shadow_temporal = BindGroupLayoutDescriptor::new( + "cloud_shadow_temporal_bind_group_layout", + &BindGroupLayoutEntries::with_indices( + ShaderStages::COMPUTE, + ( + (0, uniform_buffer::(false)), + ( + 1, + texture_storage_2d( + TextureFormat::Rgba16Float, + StorageTextureAccess::ReadOnly, + ), + ), + (2, texture_2d(TextureSampleType::Float { filterable: true })), + (3, sampler(SamplerBindingType::Filtering)), + ( + 4, + texture_storage_2d( + TextureFormat::Rgba16Float, + StorageTextureAccess::WriteOnly, + ), + ), + ), + ), + ); + Self { transmittance_lut, multiscattering_lut, sky_view_lut, aerial_view_lut, + cloud_shadow_map, + cloud_shadow_filter, + cloud_shadow_temporal, } } } +/// Parameters for the cloud shadow temporal filter pass. +/// Used to reproject history from the previous frame. +#[derive(Clone, Default, ShaderType)] +pub struct CloudShadowTemporalParams { + pub curr_anchor: Vec3, + pub curr_light_dir: Vec3, + pub curr_basis_x: Vec3, + pub curr_basis_y: Vec3, + pub prev_anchor: Vec3, + pub prev_light_dir: Vec3, + pub prev_basis_x: Vec3, + pub prev_basis_y: Vec3, + pub extent: f32, + pub temporal_alpha: f32, + pub history_valid: u32, + pub anchor_moved: u32, + pub size: UVec2, +} + +/// Per-view state for cloud shadow temporal filtering. +/// Persists between frames to enable history reprojection. +#[derive(Resource, Default)] +pub struct CloudShadowTemporalState { + pub anchor: Vec3, + pub light_dir: Vec3, + pub basis_x: Vec3, + pub basis_y: Vec3, + pub initialized: bool, +} + impl FromWorld for RenderSkyBindGroupLayouts { fn from_world(world: &mut World) -> Self { - let render_sky = BindGroupLayoutDescriptor::new( - "render_sky_bind_group_layout", + let render_sky_clouds = BindGroupLayoutDescriptor::new( + "render_sky_bind_group_layout_clouds", &BindGroupLayoutEntries::with_indices( ShaderStages::FRAGMENT, ( @@ -189,12 +324,109 @@ impl FromWorld for RenderSkyBindGroupLayouts { (12, sampler(SamplerBindingType::Filtering)), // view depth texture (13, texture_2d(TextureSampleType::Depth)), + (14, uniform_buffer::(true)), // cloud layer parameters + ( + // 2D noise texture for cloud coverage + 15, + texture_2d(TextureSampleType::Float { filterable: true }), + ), + (16, sampler(SamplerBindingType::Filtering)), // noise sampler + // cloud shadow map + ( + 17, + texture_2d(TextureSampleType::Float { filterable: true }), + ), + // 3D Perlin–Worley noise texture for volumetric shaping + ( + 18, + texture_3d(TextureSampleType::Float { filterable: true }), + ), + // STBN for temporal stratification (different layer per frame) + ( + 19, + texture_2d_array(TextureSampleType::Float { filterable: true }), + ), ), ), ); - let render_sky_msaa = BindGroupLayoutDescriptor::new( - "render_sky_msaa_bind_group_layout", + let render_sky_msaa_clouds = BindGroupLayoutDescriptor::new( + "render_sky_msaa_bind_group_layout_clouds", + &BindGroupLayoutEntries::with_indices( + ShaderStages::FRAGMENT, + ( + (0, uniform_buffer::(true)), + (1, uniform_buffer::(true)), + (2, uniform_buffer::(true)), + (3, uniform_buffer::(true)), + (4, uniform_buffer::(true)), + // scattering medium luts and sampler + (5, texture_2d(TextureSampleType::default())), + (6, texture_2d(TextureSampleType::default())), + (7, sampler(SamplerBindingType::Filtering)), + // atmosphere luts and sampler + (8, texture_2d(TextureSampleType::default())), // transmittance + (9, texture_2d(TextureSampleType::default())), // multiscattering + (10, texture_2d(TextureSampleType::default())), // sky view + (11, texture_3d(TextureSampleType::default())), // aerial view + (12, sampler(SamplerBindingType::Filtering)), + // view depth texture + (13, texture_2d_multisampled(TextureSampleType::Depth)), + (14, uniform_buffer::(true)), // cloud layer parameters + ( + // 2D noise texture for cloud coverage + 15, + texture_2d(TextureSampleType::Float { filterable: true }), + ), + (16, sampler(SamplerBindingType::Filtering)), // noise sampler + // cloud shadow map + ( + 17, + texture_2d(TextureSampleType::Float { filterable: true }), + ), + // 3D Perlin–Worley noise texture for volumetric shaping + ( + 18, + texture_3d(TextureSampleType::Float { filterable: true }), + ), + // STBN for temporal stratification (different layer per frame) + ( + 19, + texture_2d_array(TextureSampleType::Float { filterable: true }), + ), + ), + ), + ); + + // No-cloud layouts (omit CloudLayer + noise + cloud shadow map bindings). + let render_sky_no_clouds = BindGroupLayoutDescriptor::new( + "render_sky_bind_group_layout_no_clouds", + &BindGroupLayoutEntries::with_indices( + ShaderStages::FRAGMENT, + ( + (0, uniform_buffer::(true)), + (1, uniform_buffer::(true)), + (2, uniform_buffer::(true)), + (3, uniform_buffer::(true)), + (4, uniform_buffer::(true)), + // scattering medium luts and sampler + (5, texture_2d(TextureSampleType::default())), + (6, texture_2d(TextureSampleType::default())), + (7, sampler(SamplerBindingType::Filtering)), + // atmosphere luts and sampler + (8, texture_2d(TextureSampleType::default())), // transmittance + (9, texture_2d(TextureSampleType::default())), // multiscattering + (10, texture_2d(TextureSampleType::default())), // sky view + (11, texture_3d(TextureSampleType::default())), // aerial view + (12, sampler(SamplerBindingType::Filtering)), + // view depth texture + (13, texture_2d(TextureSampleType::Depth)), + ), + ), + ); + + let render_sky_msaa_no_clouds = BindGroupLayoutDescriptor::new( + "render_sky_msaa_bind_group_layout_no_clouds", &BindGroupLayoutEntries::with_indices( ShaderStages::FRAGMENT, ( @@ -220,8 +452,10 @@ impl FromWorld for RenderSkyBindGroupLayouts { ); Self { - render_sky, - render_sky_msaa, + render_sky_clouds, + render_sky_msaa_clouds, + render_sky_no_clouds, + render_sky_msaa_no_clouds, fullscreen_shader: world.resource::().clone(), fragment_shader: load_embedded_asset!(world, "render_sky.wgsl"), } @@ -246,12 +480,38 @@ impl FromWorld for AtmosphereSampler { } } +#[derive(Resource, Deref)] +pub struct CloudNoiseSampler(Sampler); + +impl FromWorld for CloudNoiseSampler { + fn from_world(world: &mut World) -> Self { + let render_device = world.resource::(); + + let sampler = render_device.create_sampler(&SamplerDescriptor { + address_mode_u: AddressMode::Repeat, + address_mode_v: AddressMode::Repeat, + address_mode_w: AddressMode::Repeat, + mag_filter: FilterMode::Linear, + min_filter: FilterMode::Linear, + mipmap_filter: MipmapFilterMode::Nearest, + ..Default::default() + }); + + Self(sampler) + } +} + #[derive(Resource)] pub(crate) struct AtmosphereLutPipelines { pub transmittance_lut: CachedComputePipelineId, pub multiscattering_lut: CachedComputePipelineId, pub sky_view_lut: CachedComputePipelineId, pub aerial_view_lut: CachedComputePipelineId, + pub cloud_shadow_map: CachedComputePipelineId, + // Accessed from the render-world graph; some tooling can miss the cross-module usage. + #[allow(dead_code)] + pub cloud_shadow_filter: CachedComputePipelineId, + pub cloud_shadow_temporal: CachedComputePipelineId, } impl FromWorld for AtmosphereLutPipelines { @@ -278,6 +538,8 @@ impl FromWorld for AtmosphereLutPipelines { label: Some("sky_view_lut_pipeline".into()), layout: vec![layouts.sky_view_lut.clone()], shader: load_embedded_asset!(world, "sky_view_lut.wgsl"), + // Note: Clouds disabled for sky_view_lut - too complex for precomputation + // Clouds are rendered in real-time raymarching instead ..default() }); @@ -288,11 +550,37 @@ impl FromWorld for AtmosphereLutPipelines { ..default() }); + let cloud_shadow_map = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some("cloud_shadow_map_pipeline".into()), + layout: vec![layouts.cloud_shadow_map.clone()], + shader: load_embedded_asset!(world, "cloud_shadow_map.wgsl"), + ..default() + }); + + let cloud_shadow_filter = + pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some("cloud_shadow_filter_pipeline".into()), + layout: vec![layouts.cloud_shadow_filter.clone()], + shader: load_embedded_asset!(world, "cloud_shadow_filter.wgsl"), + ..default() + }); + + let cloud_shadow_temporal = + pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some("cloud_shadow_temporal_pipeline".into()), + layout: vec![layouts.cloud_shadow_temporal.clone()], + shader: load_embedded_asset!(world, "cloud_shadow_temporal.wgsl"), + ..default() + }); + Self { transmittance_lut, multiscattering_lut, sky_view_lut, aerial_view_lut, + cloud_shadow_map, + cloud_shadow_filter, + cloud_shadow_temporal, } } } @@ -304,6 +592,7 @@ pub(crate) struct RenderSkyPipelineId(pub CachedRenderPipelineId); pub(crate) struct RenderSkyPipelineKey { pub msaa_samples: u32, pub dual_source_blending: bool, + pub clouds_enabled: bool, } impl SpecializedRenderPipeline for RenderSkyBindGroupLayouts { @@ -319,6 +608,12 @@ impl SpecializedRenderPipeline for RenderSkyBindGroupLayouts { shader_defs.push("DUAL_SOURCE_BLENDING".into()); } + // Enable cloud rendering only when a CloudLayer component is present. + if key.clouds_enabled { + shader_defs.push("CLOUDS_ENABLED".into()); + shader_defs.push("CLOUD_SHADOW_SIMPLE_SAMPLING".into()); + } + let dst_factor = if key.dual_source_blending { BlendFactor::Src1 } else { @@ -327,10 +622,11 @@ impl SpecializedRenderPipeline for RenderSkyBindGroupLayouts { RenderPipelineDescriptor { label: Some(format!("render_sky_pipeline_{}", key.msaa_samples).into()), - layout: vec![if key.msaa_samples == 1 { - self.render_sky.clone() - } else { - self.render_sky_msaa.clone() + layout: vec![match (key.msaa_samples == 1, key.clouds_enabled) { + (true, true) => self.render_sky_clouds.clone(), + (false, true) => self.render_sky_msaa_clouds.clone(), + (true, false) => self.render_sky_no_clouds.clone(), + (false, false) => self.render_sky_msaa_no_clouds.clone(), }], vertex: self.fullscreen_shader.to_vertex_state(), fragment: Some(FragmentState { @@ -364,14 +660,14 @@ impl SpecializedRenderPipeline for RenderSkyBindGroupLayouts { } pub(super) fn queue_render_sky_pipelines( - views: Query<(Entity, &Msaa), (With, With)>, + views: Query<(Entity, &Msaa, Option<&CloudLayer>), (With, With)>, pipeline_cache: Res, layouts: Res, mut specializer: ResMut>, render_device: Res, mut commands: Commands, ) { - for (entity, msaa) in &views { + for (entity, msaa, cloud_layer) in &views { let id = specializer.specialize( &pipeline_cache, &layouts, @@ -380,6 +676,7 @@ pub(super) fn queue_render_sky_pipelines( dual_source_blending: render_device .features() .contains(WgpuFeatures::DUAL_SOURCE_BLENDING), + clouds_enabled: cloud_layer.is_some(), }, ); commands.entity(entity).insert(RenderSkyPipelineId(id)); @@ -392,6 +689,9 @@ pub struct AtmosphereTextures { pub multiscattering_lut: CachedTexture, pub sky_view_lut: CachedTexture, pub aerial_view_lut: CachedTexture, + pub cloud_shadow_map: CachedTexture, + pub cloud_shadow_map_tmp: CachedTexture, + pub cloud_shadow_map_history: CachedTexture, } pub(super) fn prepare_atmosphere_textures( @@ -457,12 +757,61 @@ pub(super) fn prepare_atmosphere_textures( }, ); + let cloud_shadow_map = texture_cache.get( + &render_device, + TextureDescriptor { + label: Some("cloud_shadow_map"), + size: lut_settings.cloud_shadow_map_size.to_extents(), + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Rgba16Float, + usage: TextureUsages::STORAGE_BINDING + | TextureUsages::TEXTURE_BINDING + | TextureUsages::COPY_SRC, + view_formats: &[], + }, + ); + + let cloud_shadow_map_tmp = texture_cache.get( + &render_device, + TextureDescriptor { + label: Some("cloud_shadow_map_tmp"), + size: lut_settings.cloud_shadow_map_size.to_extents(), + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Rgba16Float, + usage: TextureUsages::STORAGE_BINDING | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ); + + let cloud_shadow_map_history = texture_cache.get( + &render_device, + TextureDescriptor { + label: Some("cloud_shadow_map_history"), + size: lut_settings.cloud_shadow_map_size.to_extents(), + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Rgba16Float, + usage: TextureUsages::STORAGE_BINDING + | TextureUsages::TEXTURE_BINDING + | TextureUsages::COPY_DST, + view_formats: &[], + }, + ); + commands.entity(entity).insert({ AtmosphereTextures { transmittance_lut, multiscattering_lut, sky_view_lut, aerial_view_lut, + cloud_shadow_map, + cloud_shadow_map_tmp, + cloud_shadow_map_history, } }); } @@ -595,7 +944,17 @@ pub(crate) struct AtmosphereBindGroups { pub multiscattering_lut: BindGroup, pub sky_view_lut: BindGroup, pub aerial_view_lut: BindGroup, - pub render_sky: BindGroup, + pub cloud_shadow_map: Option, + // Accessed from the render-world graph; some tooling can miss the cross-module usage. + #[allow(dead_code)] + pub cloud_shadow_filter_a_to_b: Option, + // Accessed from the render-world graph; some tooling can miss the cross-module usage. + #[allow(dead_code)] + pub cloud_shadow_filter_b_to_a: Option, + /// Temporal filter bind group; present when cloud_shadow_temporal_enabled. + pub cloud_shadow_temporal: Option, + pub render_sky_clouds: Option, + pub render_sky_no_clouds: BindGroup, } #[derive(Copy, Clone, Debug, thiserror::Error)] @@ -614,6 +973,115 @@ enum AtmosphereBindGroupError { LightUniforms, } +pub(super) fn prepare_cloud_shadow_temporal_params( + views: Query< + (&ExtractedView, &ExtractedAtmosphere, &GpuAtmosphereSettings), + (With, With), + >, + directional_lights: Query<&ExtractedDirectionalLight>, + mut temporal_state: ResMut, + mut temporal_params_buffer: ResMut, + render_device: Res, + render_queue: Res, +) { + let Some((view, atmosphere, settings)) = views.iter().next() else { + return; + }; + if settings.rendering_method != 1 || settings.cloud_shadow_temporal_enabled == 0 { + return; + } + let light_dir = directional_lights + .iter() + .next() + .map(|light| Vec3::from(light.transform.back())) + .unwrap_or(Vec3::NEG_Y); + + let extent = settings.cloud_shadow_map_extent; + let temporal_alpha = settings.cloud_shadow_temporal_alpha; + let light_rotation_cut_deg = settings.cloud_shadow_temporal_light_rotation_cut_deg; + + let inner_radius = atmosphere.inner_radius; + let camera_pos = view.world_from_view.translation(); + let world_pos = atmosphere.world_to_atmosphere.transform_point3(camera_pos); + let min_radius = inner_radius + 1.0; + let r = world_pos.length(); + let anchor = if r < min_radius { + world_pos.normalize_or_zero() * min_radius + } else { + world_pos + }; + + let trace_dir = (-light_dir).normalize_or_zero(); + let world_up = Vec3::Y; + let a = if trace_dir.dot(world_up).abs() > 0.999 { + Vec3::X + } else { + world_up + }; + let basis_x = a.cross(trace_dir).normalize_or_zero(); + let basis_y = trace_dir.cross(basis_x); + + let size = settings.cloud_shadow_map_size; + let res = size.as_vec2(); + let texel_size = (2.0 * extent) / res.max(Vec2::ONE); + let ax = anchor.dot(basis_x); + let ay = anchor.dot(basis_y); + let snapped_ax = (ax / texel_size.x).round() * texel_size.x; + let snapped_ay = (ay / texel_size.y).round() * texel_size.y; + let anchor = anchor + basis_x * (snapped_ax - ax) + basis_y * (snapped_ay - ay); + + let prev_anchor = temporal_state.anchor; + let prev_light_dir = temporal_state.light_dir; + let anchor_moved = (anchor - prev_anchor).length_squared() > 1e-6; + let light_rot_cos = (light_rotation_cut_deg * std::f32::consts::PI / 180.0).cos(); + let light_rotated = prev_light_dir.dot(light_dir.normalize_or_zero()) < light_rot_cos; + let history_valid = temporal_state.initialized && !light_rotated; + + *temporal_state = CloudShadowTemporalState { + anchor, + light_dir: light_dir.normalize_or_zero(), + basis_x, + basis_y, + initialized: true, + }; + + let prev_anchor = if history_valid { prev_anchor } else { anchor }; + let prev_light_dir_to_light = if history_valid { + prev_light_dir + } else { + light_dir.normalize_or_zero() + }; + let prev_trace_dir = -prev_light_dir_to_light; + let prev_a = if prev_light_dir_to_light.dot(Vec3::Y).abs() > 0.999 { + Vec3::X + } else { + Vec3::Y + }; + let prev_basis_x = prev_a.cross(prev_light_dir_to_light).normalize_or_zero(); + let prev_basis_y = prev_light_dir_to_light.cross(prev_basis_x); + + temporal_params_buffer + .buffer + .set(CloudShadowTemporalParams { + curr_anchor: anchor, + curr_light_dir: trace_dir, + curr_basis_x: basis_x, + curr_basis_y: basis_y, + prev_anchor, + prev_light_dir: prev_trace_dir, + prev_basis_x, + prev_basis_y, + extent, + temporal_alpha, + history_valid: history_valid as u32, + anchor_moved: anchor_moved as u32, + size, + }); + temporal_params_buffer + .buffer + .write_buffer(&render_device, &render_queue); +} + pub(super) fn prepare_atmosphere_bind_groups( views: Query< ( @@ -622,13 +1090,20 @@ pub(super) fn prepare_atmosphere_bind_groups( &AtmosphereTextures, &ViewDepthTexture, &Msaa, + Option<&CloudLayer>, + Option<&GpuAtmosphereSettings>, ), (With, With), >, render_device: Res, layouts: Res, render_sky_layouts: Res, - atmosphere_sampler: Res, + (atmosphere_sampler, cloud_noise_sampler): (Res, Res), + (bluenoise, render_images, fallback_image): ( + Res, + Res>, + Res, + ), view_uniforms: Res, lights_uniforms: Res, atmosphere_transforms: Res, @@ -637,6 +1112,12 @@ pub(super) fn prepare_atmosphere_bind_groups( gpu_media: Res>, medium_sampler: Res, pipeline_cache: Res, + (cloud_layer_uniforms, fbm_noise_texture, perlin_worley_noise_texture, temporal_params): ( + Res>, + Res, + Res, + Res, + ), mut commands: Commands, ) -> Result<(), BevyError> { if views.iter().len() == 0 { @@ -666,11 +1147,26 @@ pub(super) fn prepare_atmosphere_bind_groups( .binding() .ok_or(AtmosphereBindGroupError::LightUniforms)?; - for (entity, atmosphere, textures, view_depth_texture, msaa) in &views { + // Optional: when no view has a CloudLayer component, the uniform buffer can be absent. + // We build no-cloud bind groups in that case. + let cloud_layer_binding = cloud_layer_uniforms.binding(); + + // STBN texture for temporal stratification; use fallback when not yet loaded. + let stbn_view: std::borrow::Cow<'_, TextureView> = match render_images.get(&bluenoise.texture) { + Some(gpu) => std::borrow::Cow::Owned(gpu.texture.create_view(&TextureViewDescriptor { + dimension: Some(TextureViewDimension::D2Array), + ..Default::default() + })), + None => std::borrow::Cow::Borrowed(&fallback_image.d2_array.texture_view), + }; + + for (entity, atmosphere, textures, view_depth_texture, msaa, cloud_layer, settings) in &views { + let temporal_enabled = settings + .map(|s| s.rendering_method == 1 && s.cloud_shadow_temporal_enabled != 0) + .unwrap_or(false); let gpu_medium = gpu_media .get(atmosphere.medium) .ok_or(ScatteringMediumMissingError(atmosphere.medium))?; - let transmittance_lut = render_device.create_bind_group( "transmittance_lut_bind_group", &pipeline_cache.get_bind_group_layout(&layouts.transmittance_lut), @@ -751,12 +1247,111 @@ pub(super) fn prepare_atmosphere_bind_groups( )), ); - let render_sky = render_device.create_bind_group( - "render_sky_bind_group", + // When temporal enabled: trace writes to tmp; filter reads tmp first. + // When temporal disabled: trace writes to cloud_shadow_map; filter reads cloud_shadow_map first. + let trace_storage = if temporal_enabled { + &textures.cloud_shadow_map_tmp.default_view + } else { + &textures.cloud_shadow_map.default_view + }; + let (filter_read_a, filter_write_a) = if temporal_enabled { + ( + &textures.cloud_shadow_map_tmp.default_view, + &textures.cloud_shadow_map.default_view, + ) + } else { + ( + &textures.cloud_shadow_map.default_view, + &textures.cloud_shadow_map_tmp.default_view, + ) + }; + let (filter_read_b, filter_write_b) = if temporal_enabled { + ( + &textures.cloud_shadow_map.default_view, + &textures.cloud_shadow_map_tmp.default_view, + ) + } else { + ( + &textures.cloud_shadow_map_tmp.default_view, + &textures.cloud_shadow_map.default_view, + ) + }; + + let cloud_shadow_map = cloud_layer_binding.as_ref().map(|cloud_layer_binding| { + render_device.create_bind_group( + "cloud_shadow_map_bind_group", + &pipeline_cache.get_bind_group_layout(&layouts.cloud_shadow_map), + &BindGroupEntries::with_indices(( + (0, atmosphere_binding.clone()), + (1, settings_binding.clone()), + (3, view_binding.clone()), + (4, lights_binding.clone()), + (14, cloud_layer_binding.clone()), + (15, &fbm_noise_texture.texture.default_view), + (16, &**cloud_noise_sampler), + (18, &perlin_worley_noise_texture.texture.default_view), + (19, stbn_view.as_ref()), + (13, trace_storage), + )), + ) + }); + + // Cloud shadow spatial filter ping-pong bind groups. + // When temporal: A=tmp (trace output), B=cloud_shadow_map. When not: A=cloud_shadow_map, B=tmp. + let cloud_shadow_filter_a_to_b = cloud_shadow_map.as_ref().map(|_| { + render_device.create_bind_group( + "cloud_shadow_filter_a_to_b_bind_group", + &pipeline_cache.get_bind_group_layout(&layouts.cloud_shadow_filter), + &BindGroupEntries::with_indices(( + (1, settings_binding.clone()), + (12, &**atmosphere_sampler), + (17, filter_read_a), + (13, filter_write_a), + )), + ) + }); + + let cloud_shadow_filter_b_to_a = cloud_shadow_map.as_ref().map(|_| { + render_device.create_bind_group( + "cloud_shadow_filter_b_to_a_bind_group", + &pipeline_cache.get_bind_group_layout(&layouts.cloud_shadow_filter), + &BindGroupEntries::with_indices(( + (1, settings_binding.clone()), + (12, &**atmosphere_sampler), + (17, filter_read_b), + (13, filter_write_b), + )), + ) + }); + + // Temporal filter: curr=tmp (or cloud_shadow_map when no filter), prev=history, out=cloud_shadow_map. + let cloud_shadow_temporal = (cloud_shadow_map.is_some() && temporal_enabled) + .then(|| { + temporal_params + .buffer + .binding() + .map(|temporal_params_binding| { + render_device.create_bind_group( + "cloud_shadow_temporal_bind_group", + &pipeline_cache.get_bind_group_layout(&layouts.cloud_shadow_temporal), + &BindGroupEntries::with_indices(( + (0, temporal_params_binding), + (1, &textures.cloud_shadow_map_tmp.default_view), + (2, &textures.cloud_shadow_map_history.default_view), + (3, &**atmosphere_sampler), + (4, &textures.cloud_shadow_map.default_view), + )), + ) + }) + }) + .flatten(); + + let render_sky_no_clouds = render_device.create_bind_group( + "render_sky_bind_group_no_clouds", &pipeline_cache.get_bind_group_layout(if *msaa == Msaa::Off { - &render_sky_layouts.render_sky + &render_sky_layouts.render_sky_no_clouds } else { - &render_sky_layouts.render_sky_msaa + &render_sky_layouts.render_sky_msaa_no_clouds }), &BindGroupEntries::with_indices(( // uniforms @@ -780,12 +1375,59 @@ pub(super) fn prepare_atmosphere_bind_groups( )), ); + // Only create clouds bind group for views that have CloudLayer; otherwise use no_clouds + // to avoid passing clouds layout to no_clouds pipeline when disabling clouds. + let render_sky_clouds = cloud_layer_binding + .as_ref() + .filter(|_| cloud_layer.is_some()) + .map(|cloud_layer_binding| { + render_device.create_bind_group( + "render_sky_bind_group_clouds", + &pipeline_cache.get_bind_group_layout(if *msaa == Msaa::Off { + &render_sky_layouts.render_sky_clouds + } else { + &render_sky_layouts.render_sky_msaa_clouds + }), + &BindGroupEntries::with_indices(( + // uniforms + (0, atmosphere_binding.clone()), + (1, settings_binding.clone()), + (2, transforms_binding.clone()), + (3, view_binding.clone()), + (4, lights_binding.clone()), + // scattering medium luts and sampler + (5, &gpu_medium.density_lut_view), + (6, &gpu_medium.scattering_lut_view), + (7, medium_sampler.sampler()), + // atmosphere luts and sampler + (8, &textures.transmittance_lut.default_view), + (9, &textures.multiscattering_lut.default_view), + (10, &textures.sky_view_lut.default_view), + (11, &textures.aerial_view_lut.default_view), + (12, &**atmosphere_sampler), + // view depth texture + (13, view_depth_texture.view()), + (14, cloud_layer_binding.clone()), + (15, &fbm_noise_texture.texture.default_view), + (16, &**cloud_noise_sampler), + (17, &textures.cloud_shadow_map.default_view), + (18, &perlin_worley_noise_texture.texture.default_view), + (19, stbn_view.as_ref()), + )), + ) + }); + commands.entity(entity).insert(AtmosphereBindGroups { transmittance_lut, multiscattering_lut, sky_view_lut, aerial_view_lut, - render_sky, + cloud_shadow_map, + cloud_shadow_filter_a_to_b, + cloud_shadow_filter_b_to_a, + cloud_shadow_temporal, + render_sky_clouds, + render_sky_no_clouds, }); } @@ -804,6 +1446,19 @@ pub struct AtmosphereBuffer { pub(crate) buffer: StorageBuffer, } +#[derive(Resource)] +pub struct CloudShadowTemporalParamsBuffer { + pub(crate) buffer: UniformBuffer, +} + +impl Default for CloudShadowTemporalParamsBuffer { + fn default() -> Self { + Self { + buffer: UniformBuffer::default(), + } + } +} + pub(crate) fn prepare_atmosphere_buffers( device: Res, queue: Res, diff --git a/crates/bevy_pbr/src/atmosphere/types.wgsl b/crates/bevy_pbr/src/atmosphere/types.wgsl index 6b4bf5eab3805..c955be5cea245 100644 --- a/crates/bevy_pbr/src/atmosphere/types.wgsl +++ b/crates/bevy_pbr/src/atmosphere/types.wgsl @@ -23,6 +23,11 @@ struct AtmosphereSettings { aerial_view_lut_max_distance: f32, sky_max_samples: u32, rendering_method: u32, + cloud_shadow_map_size: vec2, + cloud_shadow_map_extent: f32, + cloud_shadow_map_samples: u32, + cloud_shadow_map_strength: f32, + cloud_shadow_map_spatial_filter_iterations: u32, } // "Atmosphere space" uses local up for the zenith so the horizon-detail diff --git a/examples/3d/atmosphere.rs b/examples/3d/atmosphere.rs index 99396ccc7d78a..98deedfca5ccf 100644 --- a/examples/3d/atmosphere.rs +++ b/examples/3d/atmosphere.rs @@ -14,12 +14,14 @@ use bevy::{ }, input::keyboard::KeyCode, light::{ - atmosphere::ScatteringMedium, light_consts::lux, Atmosphere, AtmosphereEnvironmentMapLight, - FogVolume, VolumetricFog, VolumetricLight, + atmosphere::{Falloff, PhaseFunction, ScatteringMedium, ScatteringTerm}, + light_consts::lux, + Atmosphere, AtmosphereEnvironmentMapLight, CascadeShadowConfigBuilder, FogVolume, + VolumetricFog, VolumetricLight, }, pbr::{ - AtmosphereMode, AtmosphereSettings, DefaultOpaqueRendererMethod, ExtendedMaterial, - MaterialExtension, ScreenSpaceReflections, + AtmosphereMode, AtmosphereSettings, CloudLayer, DefaultOpaqueRendererMethod, + ExtendedMaterial, MaterialExtension, ScreenSpaceReflections, }, post_process::bloom::Bloom, prelude::*, @@ -27,11 +29,17 @@ use bevy::{ shader::ShaderRef, }; -#[derive(Resource, Default)] +#[derive(Resource)] struct GameState { paused: bool, } +impl Default for GameState { + fn default() -> Self { + Self { paused: true } + } +} + #[derive(Resource)] struct AtmospherePresets { earth: Handle, @@ -64,6 +72,8 @@ fn print_controls() { println!(" 2 - Switch to raymarched rendering method"); println!(" 3 - Switch to Earth atmosphere"); println!(" 4 - Switch to Mars atmosphere"); + println!(" C - Toggle cloud layer"); + println!(" Left/Right - Decrease/Increase cloud density"); println!(" Enter - Pause/Resume sun motion"); println!(" Up/Down - Increase/Decrease exposure"); } @@ -72,6 +82,9 @@ fn atmosphere_controls( keyboard_input: Res>, mut planet_atmosphere: Query<(&mut Atmosphere, &mut GlobalTransform)>, mut camera_settings: Query<&mut AtmosphereSettings, With>, + mut cloud_layers: Query<&mut CloudLayer>, + mut commands: Commands, + cameras: Query>, atmosphere_presets: Res, mut game_state: ResMut, mut camera_exposure: Query<&mut Exposure, With>, @@ -107,6 +120,36 @@ fn atmosphere_controls( } } + if keyboard_input.just_pressed(KeyCode::KeyC) { + if cloud_layers.iter().count() > 0 { + // Remove cloud layer + for entity in &cameras { + commands.entity(entity).remove::(); + } + println!("Cloud layer disabled"); + } else { + // Add cloud layer + for entity in &cameras { + commands.entity(entity).insert(CloudLayer::default()); + } + println!("Cloud layer enabled"); + } + } + + if keyboard_input.pressed(KeyCode::ArrowLeft) { + for mut cloud_layer in &mut cloud_layers { + cloud_layer.cloud_density = (cloud_layer.cloud_density - time.delta_secs()).max(0.0); + println!("Cloud density: {:.2}", cloud_layer.cloud_density); + } + } + + if keyboard_input.pressed(KeyCode::ArrowRight) { + for mut cloud_layer in &mut cloud_layers { + cloud_layer.cloud_density = (cloud_layer.cloud_density + time.delta_secs()).min(100.0); + println!("Cloud density: {:.2}", cloud_layer.cloud_density); + } + } + if keyboard_input.just_pressed(KeyCode::Enter) { game_state.paused = !game_state.paused; } @@ -122,6 +165,11 @@ fn atmosphere_controls( exposure.ev100 += time.delta_secs() * 2.0; } } + + // Animate clouds by updating noise offset + for mut cloud_layer in &mut cloud_layers { + // cloud_layer.noise_offset.x += time.delta_secs() * 100.0; + } } fn setup_camera_fog( @@ -129,7 +177,14 @@ fn setup_camera_fog( mut scattering_mediums: ResMut>, asset_server: Res, ) { - let earth_medium = scattering_mediums.add(ScatteringMedium::earth(256, 256)); + let mut medium = ScatteringMedium::earth(256, 256); + medium.terms.push(ScatteringTerm { + absorption: Vec3::splat(2e-5), + scattering: Vec3::splat(1e-4), + falloff: Falloff::Exponential { scale: 0.2 / 60.0 }, + phase: PhaseFunction::Mie { asymmetry: 0.76 }, + }); + let earth_medium = scattering_mediums.add(medium); let mars_phase = asset_server.load("textures/mars_mie_phase.ktx2"); let mars_medium = scattering_mediums.add(ScatteringMedium::mars(256, 256, mars_phase)); @@ -144,8 +199,32 @@ fn setup_camera_fog( commands.spawn(( Camera3d::default(), Transform::from_xyz(-2.8, 0.045, 0.0).looking_at(Vec3::ZERO, Vec3::Y), - // Can be adjusted to change the rendering quality - AtmosphereSettings::default(), + // Can be adjusted to change the scene scale and rendering quality + AtmosphereSettings { + rendering_method: AtmosphereMode::Raymarched, + ..default() + }, + // Add a volumetric cloud layer using 3D FBM noise + // Physically realistic parameters (units: m^-1 per unit density): + // - Density is normalized to [0, 1] in the shader + // - cloud_scattering: 0.0008 m^-1 per unit density (physically correct for water droplets) + // At max density (1.0): scattering_coeff = 0.0008 + // After phase amplification (~6.5x at forward angles): 0.0008 * 6.5 ≈ 0.005 + // This matches atmospheric scattering magnitudes (~0.001-0.01 range) + // - cloud_absorption: 0.00005 m^-1 per unit density (realistic for water clouds) + // Single-scattering albedo ≈ 0.94 (typical water clouds have albedo 0.95-0.99) + CloudLayer { + cloud_layer_start: 6_362_000.0, // 2km above Earth's surface + cloud_layer_end: 6_364_000.0, // 4km above Earth's surface (7km thick layer) + cloud_density: 1.0, // Used for enabling/disabling, actual density comes from noise (normalized [0, 1]) + cloud_absorption: 0.00005, // Physically correct: ~0.00005 m^-1 per unit density + cloud_scattering: 0.0008, // Physically correct: ~0.0008 m^-1 per unit density + // Larger scale = larger cloud features (more “big cumulus”, less “small puffs”) + noise_scale: 64_000.0, + noise_offset: Vec3::ZERO, + detail_noise_scale: 16_000.0, // Smaller scale = higher-frequency breakup + detail_strength: 1.0, + }, // The directional light illuminance used in this scene // (the one recommended for use with this feature) is // quite bright, so raising the exposure compensation helps @@ -231,11 +310,11 @@ fn setup_terrain_scene( VolumetricLight, )); - // spawn the fog volume - commands.spawn(( - FogVolume::default(), - Transform::from_scale(Vec3::new(10.0, 1.0, 10.0)).with_translation(Vec3::Y * 0.5), - )); + // // spawn the fog volume + // commands.spawn(( + // FogVolume::default(), + // Transform::from_scale(Vec3::new(10.0, 1.0, 10.0)).with_translation(Vec3::Y * 0.5), + // )); // Terrain commands.spawn(( @@ -248,12 +327,12 @@ fn setup_terrain_scene( .with_rotation(Quat::from_rotation_y(PI / 2.0)), )); - spawn_water( - &mut commands, - &asset_server, - &mut meshes, - &mut water_materials, - ); + // spawn_water( + // &mut commands, + // &asset_server, + // &mut meshes, + // &mut water_materials, + // ); } // Spawns the water plane. @@ -311,6 +390,6 @@ fn dynamic_scene( // Only rotate the sun if motion is not paused if !sun_motion_state.paused { suns.iter_mut() - .for_each(|mut tf| tf.rotate_x(-time.delta_secs() * PI / 10.0)); + .for_each(|mut tf| tf.rotate_x(-time.delta_secs() * PI / 40.0)); } }