Blacksite/crates/game_hot/src/rendering/volumes.rs
Rbanh f29712d158
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Initial project import
2026-06-05 21:44:45 -04:00

488 lines
16 KiB
Rust

//! Post-process volume blending and active camera profile resolution.
use bevy::prelude::*;
use settings::{
resolve_effective_render_stack, resolve_gi_path, write_effective_render_stack,
ActiveCameraRenderProfile, ActiveVolumeContribution, EffectiveRenderStack, GiPath,
ProjectSettings, RenderingCapabilities, RenderingSettings, VolumeContribution,
};
use shared::{
inspector_component_active, InspectorOrder, LevelObject, PostProcessVolumeDesc,
PostProcessVolumeOverrides, COMPONENT_POST_PROCESS_VOLUME,
};
use super::solari::SolariRaytracingSceneStats;
use super::viewport_camera::has_local_shadow_lights;
/// Edge blend weight for a point in volume local space.
pub fn volume_edge_weight(local: Vec3, half_extents: Vec3, blend_distance: f32) -> f32 {
let dist = half_extents - local.abs();
if dist.x < 0.0 || dist.y < 0.0 || dist.z < 0.0 {
return 0.0;
}
if blend_distance <= 0.0 {
return 1.0;
}
let min_dist = dist.x.min(dist.y).min(dist.z);
(min_dist / blend_distance).clamp(0.0, 1.0)
}
/// Applies volume overrides onto project rendering defaults.
pub fn resolve_volume_overrides(
project: &RenderingSettings,
desc: &PostProcessVolumeDesc,
) -> ActiveCameraRenderProfile {
let gi_path = GiPath::Forward;
let mut profile = ActiveCameraRenderProfile::from_project(project, gi_path);
apply_overrides(&mut profile, &desc.overrides);
profile.fullscreen_effect = desc.fullscreen_effect.clone();
profile
}
fn apply_overrides(
profile: &mut ActiveCameraRenderProfile,
overrides: &PostProcessVolumeOverrides,
) {
if let Some(v) = overrides.exposure_ev100 {
profile.exposure_ev100 = v;
}
if let Some(v) = overrides.fog_color {
profile.fog_color = [v.r, v.g, v.b];
}
if let Some(v) = overrides.fog_density {
profile.fog_density = v;
}
if let Some(v) = overrides.bloom {
profile.bloom = v;
}
if let Some(v) = overrides.taa {
profile.taa = v;
}
if let Some(v) = overrides.ssao {
profile.ssao = v;
}
if let Some(v) = overrides.tonemapping_aces {
profile.tonemapping_aces = v;
}
if let Some(v) = overrides.atmosphere {
profile.atmosphere = v;
}
if let Some(v) = overrides.hdr {
profile.hdr = v;
}
}
#[derive(Debug, Clone)]
pub struct VolumeSample {
entity: Entity,
weight: f32,
priority: i32,
desc: PostProcessVolumeDesc,
}
fn blend_profiles(
project: &RenderingSettings,
gi_path: GiPath,
samples: &[VolumeSample],
) -> ActiveCameraRenderProfile {
let mut profile = ActiveCameraRenderProfile::from_project(project, gi_path);
if samples.is_empty() {
return profile;
}
let mut sorted: Vec<&VolumeSample> = samples.iter().filter(|s| s.weight > 0.0).collect();
sorted.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| a.entity.index().cmp(&b.entity.index()))
});
for sample in &sorted {
if sample.desc.fullscreen_effect.is_some() {
profile.fullscreen_effect = sample.desc.fullscreen_effect.clone();
break;
}
}
for sample in &sorted {
if sample.desc.overrides.exposure_ev100.is_some() {
profile.exposure_ev100 = resolve_volume_overrides(project, &sample.desc).exposure_ev100;
profile.exposure_mode = settings::ExposureMode::Manual;
break;
}
}
for sample in &sorted {
if sample.desc.overrides.fog_density.is_some() {
profile.fog_density = resolve_volume_overrides(project, &sample.desc).fog_density;
break;
}
}
for sample in &sorted {
if sample.desc.overrides.fog_color.is_some() {
profile.fog_color = resolve_volume_overrides(project, &sample.desc).fog_color;
break;
}
}
// Edge blend for scalars without explicit overrides: weighted by spatial weight.
let mut total_w = 0.0f32;
let mut exposure = 0.0f32;
let mut fog_density = 0.0f32;
let mut fog_color = [0.0f32; 3];
let any_exposure_override = sorted
.iter()
.any(|s| s.desc.overrides.exposure_ev100.is_some());
let any_fog_density_override = sorted
.iter()
.any(|s| s.desc.overrides.fog_density.is_some());
let any_fog_color_override = sorted.iter().any(|s| s.desc.overrides.fog_color.is_some());
for sample in &sorted {
let resolved = resolve_volume_overrides(project, &sample.desc);
let w = sample.weight;
if !any_exposure_override {
exposure += resolved.exposure_ev100 * w;
}
if !any_fog_density_override {
fog_density += resolved.fog_density * w;
}
if !any_fog_color_override {
fog_color[0] += resolved.fog_color[0] * w;
fog_color[1] += resolved.fog_color[1] * w;
fog_color[2] += resolved.fog_color[2] * w;
}
total_w += w;
}
if total_w > 0.0 {
if !any_exposure_override {
profile.exposure_ev100 = exposure / total_w;
}
if !any_fog_density_override {
profile.fog_density = fog_density / total_w;
}
if !any_fog_color_override {
profile.fog_color = fog_color.map(|c| c / total_w);
}
}
for sample in &sorted {
let o = &sample.desc.overrides;
if let Some(v) = o.hdr {
profile.hdr = v;
break;
}
}
for sample in &sorted {
if let Some(v) = sample.desc.overrides.bloom {
profile.bloom = v;
break;
}
}
for sample in &sorted {
if let Some(v) = sample.desc.overrides.taa {
profile.taa = v;
break;
}
}
for sample in &sorted {
if let Some(v) = sample.desc.overrides.ssao {
profile.ssao = v;
break;
}
}
for sample in &sorted {
if let Some(v) = sample.desc.overrides.tonemapping_aces {
profile.tonemapping_aces = v;
break;
}
}
for sample in &sorted {
if let Some(v) = sample.desc.overrides.atmosphere {
profile.atmosphere = v;
break;
}
}
profile
}
/// Resolves the active camera render profile from project settings and scene volumes.
pub fn resolve_active_camera_render_profile(
camera_world: Vec3,
project: &RenderingSettings,
caps: &RenderingCapabilities,
volumes: &[(Entity, &PostProcessVolumeDesc, &GlobalTransform)],
) -> (ActiveCameraRenderProfile, Vec<VolumeContribution>) {
let gi_path = resolve_gi_path(project.gi_mode, caps);
let mut samples = Vec::new();
for (entity, desc, global) in volumes {
let local = global.affine().inverse().transform_point3(camera_world);
let weight = volume_edge_weight(local, desc.half_extents, desc.blend_distance);
if weight > 0.0 {
samples.push(VolumeSample {
entity: *entity,
weight,
priority: desc.priority,
desc: (*desc).clone(),
});
}
}
let mut contributions: Vec<VolumeContribution> = samples
.iter()
.map(|s| VolumeContribution {
entity_bits: s.entity.to_bits(),
weight: s.weight,
label: s
.desc
.label
.clone()
.unwrap_or_else(|| format!("Volume {}", s.entity.index())),
override_count: s.desc.overrides.override_count(),
})
.collect();
contributions.sort_by(|a, b| {
b.weight
.partial_cmp(&a.weight)
.unwrap_or(std::cmp::Ordering::Equal)
});
let profile = blend_profiles(project, gi_path, &samples);
(profile, contributions)
}
/// Bevy system: updates [`ActiveCameraRenderProfile`] and [`ActiveVolumeContribution`].
pub fn sync_active_camera_render_profile(
mut commands: Commands,
settings: Res<ProjectSettings>,
volumes: Query<
(
Entity,
&PostProcessVolumeDesc,
&GlobalTransform,
Option<&InspectorOrder>,
),
With<LevelObject>,
>,
cameras: Query<&GlobalTransform, With<settings::ProjectRenderCamera>>,
mut profile: ResMut<ActiveCameraRenderProfile>,
mut contributions: ResMut<ActiveVolumeContribution>,
mut caps: ResMut<RenderingCapabilities>,
solari_stats: Res<SolariRaytracingSceneStats>,
points: Query<&PointLight>,
spots: Query<&SpotLight>,
mut last_request: Local<Option<(settings::GiMode, GiPath, bool)>>,
mut last_stack: Local<Option<EffectiveRenderStack>>,
) {
let gi_path = resolve_gi_path(settings.rendering.gi_mode, &caps);
let request_key = (settings.rendering.gi_mode, gi_path, caps.rt_supported);
if *last_request != Some(request_key) {
info!(
"Rendering profile request: gi_mode={:?} requested={:?} rt_supported={}",
settings.rendering.gi_mode, gi_path, caps.rt_supported
);
*last_request = Some(request_key);
}
let local_shadow_lights = has_local_shadow_lights(&points, &spots);
let Some(camera_tf) = cameras.iter().next() else {
let resolved = ActiveCameraRenderProfile::from_project(&settings.rendering, gi_path);
*profile = resolved.clone();
contributions.set_entries(Vec::new());
publish_effective_stack(
&mut commands,
&mut caps,
&solari_stats,
resolved.gi_path,
local_shadow_lights,
&mut last_stack,
);
return;
};
let volume_list: Vec<_> = volumes
.iter()
.filter(|(_, _, _, order)| {
inspector_component_active(*order, COMPONENT_POST_PROCESS_VOLUME)
})
.map(|(entity, desc, transform, _)| (entity, desc, transform))
.collect();
let (resolved, entries) = resolve_active_camera_render_profile(
camera_tf.translation(),
&settings.rendering,
&caps,
&volume_list,
);
*profile = resolved.clone();
publish_effective_stack(
&mut commands,
&mut caps,
&solari_stats,
resolved.gi_path,
local_shadow_lights,
&mut last_stack,
);
contributions.set_entries(entries);
}
fn publish_effective_stack(
commands: &mut Commands,
caps: &mut RenderingCapabilities,
solari_stats: &SolariRaytracingSceneStats,
requested_gi_path: GiPath,
local_shadow_lights: bool,
last_stack: &mut Local<Option<EffectiveRenderStack>>,
) {
let stack = resolve_effective_render_stack(
requested_gi_path,
caps,
solari_stats.solari_ready(),
local_shadow_lights,
);
write_effective_render_stack(caps, stack);
commands.insert_resource(stack);
if **last_stack != Some(stack) {
info!(
"Rendering effective stack: requested={:?} effective={:?} fallback={:?} rt_supported={} solari_ready={} local_shadow_lights={} tagged_meshes={} render_instances={} render_directional_lights={} bind_group_ready={} emissive_meshes={}",
stack.requested_gi_path,
stack.effective_gi_path,
stack.fallback_reason,
caps.rt_supported,
stack.solari_ready,
stack.local_shadow_lights,
solari_stats.tagged_meshes,
solari_stats.render_instances,
solari_stats.render_directional_lights,
solari_stats.render_bind_group_ready,
solari_stats.emissive_meshes,
);
**last_stack = Some(stack);
}
}
/// Alias used by [`SolariRenderingPlugin`] and editor ordering.
pub use sync_active_camera_render_profile as resolve_active_camera_render_profile_system;
/// Returns entities whose post-process volumes contain `world_pos` with weight > 0.
pub fn sample_volumes_at(
world_pos: Vec3,
volumes: &[(Entity, &PostProcessVolumeDesc, &GlobalTransform)],
) -> Vec<Entity> {
volumes
.iter()
.filter_map(|(entity, desc, global)| {
let local = global.affine().inverse().transform_point3(world_pos);
let weight = volume_edge_weight(local, desc.half_extents, desc.blend_distance);
(weight > 0.0).then_some(*entity)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use shared::ColorDesc;
fn default_project() -> RenderingSettings {
RenderingSettings::default()
}
#[test]
fn edge_weight_center_is_one() {
let w = volume_edge_weight(Vec3::ZERO, Vec3::ONE, 0.5);
assert!((w - 1.0).abs() < 1e-5);
}
#[test]
fn edge_weight_outside_is_zero() {
let w = volume_edge_weight(Vec3::new(2.0, 0.0, 0.0), Vec3::ONE, 0.5);
assert_eq!(w, 0.0);
}
#[test]
fn inherit_vs_override_exposure() {
let project = default_project();
let mut desc = PostProcessVolumeDesc::default();
desc.overrides.exposure_ev100 = Some(8.0);
let resolved = resolve_volume_overrides(&project, &desc);
assert!((resolved.exposure_ev100 - 8.0).abs() < 1e-5);
assert_eq!(resolved.bloom, project.bloom);
}
#[test]
fn priority_wins_at_overlap_center() {
let project = default_project();
let low = VolumeSample {
entity: Entity::from_raw_u32(1).unwrap(),
weight: 1.0,
priority: 0,
desc: PostProcessVolumeDesc {
overrides: PostProcessVolumeOverrides {
exposure_ev100: Some(5.0),
..Default::default()
},
..Default::default()
},
};
let high = VolumeSample {
entity: Entity::from_raw_u32(2).unwrap(),
weight: 1.0,
priority: 10,
desc: PostProcessVolumeDesc {
overrides: PostProcessVolumeOverrides {
exposure_ev100: Some(15.0),
..Default::default()
},
..Default::default()
},
};
let profile = blend_profiles(&project, GiPath::Forward, &[low, high]);
assert!((profile.exposure_ev100 - 15.0).abs() < 1e-4);
}
#[test]
fn volume_exposure_override_forces_manual_mode() {
let mut project = default_project();
project.exposure_mode = settings::ExposureMode::Auto;
let sample = VolumeSample {
entity: Entity::from_raw_u32(1).unwrap(),
weight: 1.0,
priority: 0,
desc: PostProcessVolumeDesc {
overrides: PostProcessVolumeOverrides {
exposure_ev100: Some(9.0),
..Default::default()
},
..Default::default()
},
};
let profile = blend_profiles(&project, GiPath::Forward, &[sample]);
assert_eq!(profile.exposure_mode, settings::ExposureMode::Manual);
assert!((profile.exposure_ev100 - 9.0).abs() < 1e-5);
}
#[test]
fn solari_stays_active_when_rt_is_supported() {
let mut project = default_project();
project.gi_mode = settings::GiMode::Solari;
let caps = RenderingCapabilities {
rt_supported: true,
..Default::default()
};
assert_eq!(
resolve_gi_path(project.gi_mode, &caps),
GiPath::SolariDeferred
);
}
#[test]
fn fog_color_override() {
let project = default_project();
let mut desc = PostProcessVolumeDesc::default();
desc.overrides.fog_color = Some(ColorDesc::srgb(0.1, 0.2, 0.3));
let resolved = resolve_volume_overrides(&project, &desc);
assert!((resolved.fog_color[0] - 0.1).abs() < 1e-5);
}
}