481 lines
17 KiB
Rust
481 lines
17 KiB
Rust
//! Window → Rendering: active camera, project defaults, and volume list.
|
|
|
|
use bevy::prelude::*;
|
|
use bevy_egui::egui;
|
|
use game::rendering::{
|
|
camera_auto_exposure_active, effective_viewport_render_stack, hdr_enabled_profile,
|
|
SolariRaytracingSceneStats,
|
|
};
|
|
use settings::{
|
|
ActiveCameraRenderProfile, ActiveVolumeContribution, ExposureMode, GiPath, ProjectSettings,
|
|
RenderFallbackReason, RenderingCapabilities,
|
|
};
|
|
use shared::{
|
|
ActorKind, AuthoringLightKind, LevelObject, LightDesc, PostProcessVolumeDesc, ProjectSun,
|
|
};
|
|
|
|
use crate::history::{spawn_with_history, EditorEntitySnapshot};
|
|
use crate::project::settings_ui::{open_project_settings_panel, ProjectSettingsPanel};
|
|
use crate::selection::SelectedEntity;
|
|
use crate::ui::helpers::focus_editor_camera_on_selection;
|
|
use crate::viewport::EditorViewportMode;
|
|
|
|
#[derive(Resource, Default)]
|
|
pub struct RenderingDiagnosticsPanel {
|
|
pub open: bool,
|
|
tab: RenderingPanelTab,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
enum RenderingPanelTab {
|
|
#[default]
|
|
Active,
|
|
Project,
|
|
Volumes,
|
|
}
|
|
|
|
pub fn rendering_diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut bool) {
|
|
if !*open {
|
|
return;
|
|
}
|
|
egui::Window::new("Rendering")
|
|
.open(open)
|
|
.default_width(440.0)
|
|
.show(ctx, |ui| {
|
|
rendering_diagnostics_ui(world, ui);
|
|
});
|
|
}
|
|
|
|
pub fn rendering_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
|
|
let mut tab = world.resource::<RenderingDiagnosticsPanel>().tab;
|
|
|
|
ui.horizontal(|ui| {
|
|
ui.selectable_value(&mut tab, RenderingPanelTab::Active, "Active Camera");
|
|
ui.selectable_value(&mut tab, RenderingPanelTab::Project, "Project");
|
|
ui.selectable_value(&mut tab, RenderingPanelTab::Volumes, "Volumes");
|
|
});
|
|
world.resource_mut::<RenderingDiagnosticsPanel>().tab = tab;
|
|
ui.separator();
|
|
|
|
match tab {
|
|
RenderingPanelTab::Active => active_camera_tab(world, ui),
|
|
RenderingPanelTab::Project => project_tab(world, ui),
|
|
RenderingPanelTab::Volumes => volumes_tab(world, ui),
|
|
}
|
|
}
|
|
|
|
fn active_camera_tab(world: &mut World, ui: &mut egui::Ui) {
|
|
let profile = world.resource::<ActiveCameraRenderProfile>().clone();
|
|
let caps = world.resource::<RenderingCapabilities>().clone();
|
|
let contributions = world.resource::<ActiveVolumeContribution>().clone();
|
|
let solari_stats = world.get_resource::<SolariRaytracingSceneStats>().copied();
|
|
let local_shadow_lights = game::rendering::world_has_local_shadow_lights(world);
|
|
let stack = effective_viewport_render_stack(
|
|
profile.gi_path,
|
|
&caps,
|
|
solari_stats.as_ref(),
|
|
local_shadow_lights,
|
|
);
|
|
|
|
let gi_label = match (
|
|
stack.requested_gi_path,
|
|
stack.effective_gi_path,
|
|
stack.fallback_reason,
|
|
) {
|
|
(GiPath::Forward, _, _) => "Forward PBR",
|
|
(GiPath::SolariDeferred, GiPath::SolariDeferred, _) => "Solari deferred",
|
|
(GiPath::SolariDeferred, _, Some(RenderFallbackReason::RtUnsupported)) => {
|
|
"Solari requested, Forward (RT unsupported)"
|
|
}
|
|
(GiPath::SolariDeferred, _, None) => "Solari requested",
|
|
};
|
|
ui.label(format!("GI path: {gi_label}"));
|
|
ui.label(format!(
|
|
"Stack: requested={:?}, effective={:?}, fallback={:?}",
|
|
stack.requested_gi_path, stack.effective_gi_path, stack.fallback_reason
|
|
));
|
|
if stack.requested_gi_path == GiPath::SolariDeferred
|
|
&& stack.effective_gi_path == GiPath::SolariDeferred
|
|
{
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 180, 100),
|
|
"Solari deferred: directional lights and emissive meshes affect lighting; point/spot LightDesc components are disabled in Bevy 0.18.",
|
|
);
|
|
} else if stack.fallback_reason == Some(RenderFallbackReason::RtUnsupported)
|
|
|| (!caps.rt_supported && profile.gi_path == GiPath::Forward)
|
|
{
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 180, 100),
|
|
"Forward fallback — Solari RT features unavailable on this GPU.",
|
|
);
|
|
}
|
|
if stack.requested_gi_path == GiPath::SolariDeferred && !stack.solari_ready {
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 180, 100),
|
|
"Solari is active/requested but still warming up or missing scene readiness; see Solari raytracing scene below.",
|
|
);
|
|
}
|
|
|
|
runtime_lighting_summary(world, ui);
|
|
solari_geometry_summary(world, ui);
|
|
|
|
ui.separator();
|
|
let auto_active = camera_auto_exposure_active(&profile, &caps);
|
|
match profile.exposure_mode {
|
|
ExposureMode::Auto if auto_active => {
|
|
ui.label(format!(
|
|
"Exposure: Auto (brighten {:.1} / darken {:.1} f-stops/s)",
|
|
profile.auto_exposure_speed_brighten, profile.auto_exposure_speed_darken
|
|
));
|
|
}
|
|
ExposureMode::Auto => {
|
|
ui.label(format!(
|
|
"Exposure: Auto requested — using manual {:.2} EV100",
|
|
profile.exposure_ev100
|
|
));
|
|
if !profile.hdr {
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 180, 100),
|
|
"Fallback: HDR is disabled in project settings.",
|
|
);
|
|
} else if !hdr_enabled_profile(&profile) {
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 180, 100),
|
|
"Fallback: HDR pass off (check BEVY_FPS_HDR or project HDR).",
|
|
);
|
|
} else if !caps.auto_exposure_supported {
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 180, 100),
|
|
"Fallback: GPU compute shaders unavailable.",
|
|
);
|
|
}
|
|
}
|
|
ExposureMode::Manual => {
|
|
ui.label(format!(
|
|
"Exposure: Manual ({:.2} EV100)",
|
|
profile.exposure_ev100
|
|
));
|
|
let project = world.resource::<ProjectSettings>();
|
|
if project.rendering.exposure_mode == ExposureMode::Auto {
|
|
ui.small("Post-process volume overrides exposure EV100 (manual for this view).");
|
|
}
|
|
}
|
|
}
|
|
ui.label(format!(
|
|
"Fog: rgb({:.2},{:.2},{:.2}) density {:.4}",
|
|
profile.fog_color[0], profile.fog_color[1], profile.fog_color[2], profile.fog_density
|
|
));
|
|
ui.label(format!(
|
|
"Post-FX: hdr={} target_hdr={} bloom={} taa={} ssao={} atmosphere={} tonemap={}",
|
|
profile.hdr,
|
|
hdr_enabled_profile(&profile)
|
|
|| profile.atmosphere
|
|
|| stack.effective_gi_path == GiPath::SolariDeferred,
|
|
profile.bloom,
|
|
profile.taa,
|
|
profile.ssao,
|
|
profile.atmosphere,
|
|
profile.tonemapping_aces
|
|
));
|
|
if let Some(effect) = &profile.fullscreen_effect {
|
|
ui.label(format!("Fullscreen effect: {effect}"));
|
|
}
|
|
|
|
ui.separator();
|
|
ui.heading("Contributing volumes");
|
|
if contributions.entries.is_empty() {
|
|
ui.label("No volumes at camera position — using project defaults.");
|
|
} else {
|
|
for entry in &contributions.entries {
|
|
ui.label(format!(
|
|
"{} — {:.0}% weight, {} overrides",
|
|
entry.label,
|
|
entry.weight * 100.0,
|
|
entry.override_count
|
|
));
|
|
}
|
|
}
|
|
|
|
if ui.button("Select overlapping volumes at camera").clicked() {
|
|
select_volumes_at_camera(world);
|
|
}
|
|
|
|
if let Some(mode) = world.get_resource::<EditorViewportMode>() {
|
|
ui.separator();
|
|
let mode_label = match *mode {
|
|
EditorViewportMode::Lit => "Lit",
|
|
EditorViewportMode::Unlit => "Unlit",
|
|
EditorViewportMode::Collider => "Collider",
|
|
};
|
|
ui.label(format!("Viewport mode: {mode_label}"));
|
|
}
|
|
}
|
|
|
|
fn runtime_lighting_summary(world: &mut World, ui: &mut egui::Ui) {
|
|
ui.separator();
|
|
ui.heading("Runtime lights");
|
|
|
|
let directional = world.query::<&DirectionalLight>().iter(world).count();
|
|
let point = world.query::<&PointLight>().iter(world).count();
|
|
let spot = world.query::<&SpotLight>().iter(world).count();
|
|
let shadowed_directional = world
|
|
.query::<&DirectionalLight>()
|
|
.iter(world)
|
|
.filter(|light| light.shadows_enabled)
|
|
.count();
|
|
let shadowed_point = world
|
|
.query::<&PointLight>()
|
|
.iter(world)
|
|
.filter(|light| light.shadows_enabled)
|
|
.count();
|
|
let shadowed_spot = world
|
|
.query::<&SpotLight>()
|
|
.iter(world)
|
|
.filter(|light| light.shadows_enabled)
|
|
.count();
|
|
ui.label(format!(
|
|
"Counts: {directional} directional, {point} point, {spot} spot"
|
|
));
|
|
ui.label(format!(
|
|
"Shadow casters: {shadowed_directional} directional, {shadowed_point} point, {shadowed_spot} spot"
|
|
));
|
|
|
|
let scene_directionals: Vec<(f32, bool)> = world
|
|
.query_filtered::<&LightDesc, With<LevelObject>>()
|
|
.iter(world)
|
|
.filter_map(|light| {
|
|
matches!(light.kind, AuthoringLightKind::Directional)
|
|
.then_some((light.intensity, light.shadows))
|
|
})
|
|
.collect();
|
|
|
|
if scene_directionals.is_empty() {
|
|
ui.label("Scene sun: none (project sun drives outdoor lighting)");
|
|
} else {
|
|
let (lux, shadows) = scene_directionals[0];
|
|
ui.label(format!(
|
|
"Scene sun override: {lux:.0} lux, shadows={shadows} ({} directional LightDesc in level)",
|
|
scene_directionals.len()
|
|
));
|
|
}
|
|
|
|
let project_sun_active = world
|
|
.query_filtered::<&Visibility, With<ProjectSun>>()
|
|
.iter(world)
|
|
.any(|visibility| *visibility != Visibility::Hidden);
|
|
|
|
if project_sun_active {
|
|
ui.label("Project sun: active");
|
|
} else if !scene_directionals.is_empty() {
|
|
ui.label("Project sun: hidden (scene directional override)");
|
|
} else {
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 140, 140),
|
|
"Project sun: hidden and no scene directional — level may appear black.",
|
|
);
|
|
}
|
|
|
|
if directional + point + spot == 0 {
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 140, 140),
|
|
"Zero runtime lights in the world.",
|
|
);
|
|
}
|
|
}
|
|
|
|
fn solari_geometry_summary(world: &World, ui: &mut egui::Ui) {
|
|
let Some(stats) = world.get_resource::<SolariRaytracingSceneStats>() else {
|
|
return;
|
|
};
|
|
ui.separator();
|
|
ui.heading("Solari raytracing scene");
|
|
ui.label(format!(
|
|
"Project meshes: {} tagged, {} eligible, {} Solari-compatible assets, {} unsupported, {} excluded helpers, {} outside level roots",
|
|
stats.tagged_meshes,
|
|
stats.project_meshes,
|
|
stats.compatible_mesh_assets,
|
|
stats.unsupported_meshes,
|
|
stats.excluded_meshes,
|
|
stats.missing_level_root_meshes
|
|
));
|
|
if stats.incompatible_mesh_assets > 0 {
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 180, 100),
|
|
format!(
|
|
"Incompatible mesh assets: {} ({} missing tangents, {} missing U32 indices)",
|
|
stats.incompatible_mesh_assets,
|
|
stats.mesh_assets_missing_tangents,
|
|
stats.mesh_assets_missing_u32_indices
|
|
),
|
|
);
|
|
}
|
|
ui.label(format!(
|
|
"Render scene: {} instances, {} directional lights, bind group ready={}",
|
|
stats.render_instances, stats.render_directional_lights, stats.render_bind_group_ready
|
|
));
|
|
ui.label(format!(
|
|
"Solari-compatible lights: {} directional/emissive",
|
|
stats.compatible_light_count()
|
|
));
|
|
if stats.solari_ready() {
|
|
ui.label("Readiness: Solari active");
|
|
} else {
|
|
ui.colored_label(
|
|
egui::Color32::from_rgb(255, 180, 100),
|
|
format!("Readiness: {}", solari_readiness_reason(stats)),
|
|
);
|
|
}
|
|
}
|
|
|
|
fn solari_readiness_reason(stats: &SolariRaytracingSceneStats) -> &'static str {
|
|
if stats.tagged_meshes == 0 {
|
|
"no project meshes are tagged for raytracing"
|
|
} else if stats.compatible_mesh_assets == 0 {
|
|
"tagged meshes are not Solari-compatible mesh assets"
|
|
} else if stats.render_instances == 0 {
|
|
"raytracing meshes have not reached the render world"
|
|
} else if !stats.render_bind_group_ready {
|
|
"Bevy has not built the raytracing scene bind group"
|
|
} else if stats.compatible_light_count() == 0 {
|
|
"no extracted directional or emissive Solari light source"
|
|
} else {
|
|
"waiting for Solari readiness"
|
|
}
|
|
}
|
|
|
|
fn project_tab(world: &mut World, ui: &mut egui::Ui) {
|
|
let rendering = world.resource::<ProjectSettings>().rendering.clone();
|
|
ui.label(format!("GiMode: {:?}", rendering.gi_mode));
|
|
ui.label(format!("HDR: {}", rendering.hdr));
|
|
if rendering.exposure_mode == ExposureMode::Auto {
|
|
ui.label(format!(
|
|
"Exposure: Auto (brighten {:.1} / darken {:.1} f-stops/s)",
|
|
rendering.auto_exposure_speed_brighten, rendering.auto_exposure_speed_darken
|
|
));
|
|
} else {
|
|
ui.label(format!(
|
|
"Exposure: Manual ({:.2} EV100)",
|
|
rendering.exposure_ev100
|
|
));
|
|
}
|
|
ui.label(format!(
|
|
"Ambient brightness: {:.1}",
|
|
rendering.ambient_brightness
|
|
));
|
|
ui.label(format!(
|
|
"Sun illuminance: {:.0} lux",
|
|
rendering.sun_illuminance
|
|
));
|
|
ui.label(format!(
|
|
"Post-FX: bloom={} taa={} ssao={} atmosphere={}",
|
|
rendering.bloom, rendering.taa, rendering.ssao, rendering.atmosphere
|
|
));
|
|
if ui.button("Open Project Settings → Rendering").clicked() {
|
|
let mut panel = world.resource_mut::<ProjectSettingsPanel>();
|
|
open_project_settings_panel(&mut panel);
|
|
}
|
|
if ui.button("Scene → Lighting → Use project sun").clicked() {
|
|
crate::ui::helpers::use_project_sun(world);
|
|
}
|
|
if ui.button("Reset scene lighting to project").clicked() {
|
|
crate::ui::helpers::reset_scene_lighting_to_project_defaults(world);
|
|
}
|
|
}
|
|
|
|
fn volumes_tab(world: &mut World, ui: &mut egui::Ui) {
|
|
let mut volumes: Vec<(Entity, PostProcessVolumeDesc, String)> = world
|
|
.query_filtered::<(Entity, &PostProcessVolumeDesc, Option<&Name>), With<LevelObject>>()
|
|
.iter(world)
|
|
.map(|(entity, desc, name)| {
|
|
let label = desc
|
|
.label
|
|
.clone()
|
|
.or_else(|| name.map(|n| n.to_string()))
|
|
.unwrap_or_else(|| format!("Volume {entity:?}"));
|
|
(entity, desc.clone(), label)
|
|
})
|
|
.collect();
|
|
volumes.sort_by(|a, b| b.1.priority.cmp(&a.1.priority).then_with(|| a.0.cmp(&b.0)));
|
|
|
|
if volumes.is_empty() {
|
|
ui.label("No post-process volumes in this level.");
|
|
return;
|
|
}
|
|
|
|
for (entity, desc, label) in volumes {
|
|
ui.horizontal(|ui| {
|
|
if ui.button(&label).clicked() {
|
|
world.resource_mut::<SelectedEntity>().0 = Some(entity);
|
|
}
|
|
ui.label(format!("priority {}", desc.priority));
|
|
if ui.small_button("Focus").clicked() {
|
|
world.resource_mut::<SelectedEntity>().0 = Some(entity);
|
|
focus_editor_camera_on_selection(world);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn select_volumes_at_camera(world: &mut World) {
|
|
use game_hot::sample_volumes_at;
|
|
use settings::ProjectRenderCamera;
|
|
|
|
let camera_pos = world
|
|
.query_filtered::<&GlobalTransform, With<ProjectRenderCamera>>()
|
|
.iter(world)
|
|
.next()
|
|
.map(|t| t.translation())
|
|
.unwrap_or(Vec3::ZERO);
|
|
|
|
let volume_list: Vec<_> = world
|
|
.query_filtered::<(Entity, &PostProcessVolumeDesc, &GlobalTransform), With<LevelObject>>()
|
|
.iter(world)
|
|
.collect();
|
|
let samples = sample_volumes_at(camera_pos, &volume_list);
|
|
|
|
if !samples.is_empty() {
|
|
world.resource_mut::<SelectedEntity>().0 = Some(samples[0]);
|
|
}
|
|
}
|
|
|
|
pub fn spawn_post_process_volume_at_camera(world: &mut World) {
|
|
use shared::EditorVisibility;
|
|
|
|
let translation = world
|
|
.query_filtered::<&GlobalTransform, With<crate::camera::EditorCamera>>()
|
|
.iter(world)
|
|
.next()
|
|
.map(|t| t.translation())
|
|
.unwrap_or(Vec3::new(0.0, 2.0, 0.0));
|
|
|
|
let snapshot = EditorEntitySnapshot {
|
|
actor_id: None,
|
|
actor_kind: ActorKind::PostProcessVolume,
|
|
actor_name: None,
|
|
name: Some("Post Process Volume".into()),
|
|
transform: Transform::from_translation(translation),
|
|
primitive: None,
|
|
brush: None,
|
|
static_mesh_renderer: None,
|
|
material: None,
|
|
material_override: None,
|
|
rigid_body: None,
|
|
collider: None,
|
|
physics: None,
|
|
light: None,
|
|
player_spawn: false,
|
|
model: None,
|
|
prefab: None,
|
|
prefab_instance: None,
|
|
weapon_spawn: None,
|
|
trigger_volume: None,
|
|
post_process_volume: Some(PostProcessVolumeDesc::default()),
|
|
team_spawn: None,
|
|
objective: None,
|
|
hierarchy_sibling_index: 0,
|
|
editor_visibility: EditorVisibility::default(),
|
|
children: Vec::new(),
|
|
};
|
|
spawn_with_history(world, snapshot);
|
|
}
|