488 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|