Blacksite/crates/editor/src/viewport/rendering_diagnostics.rs
Rbanh 9f95ba3082
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add brush authoring schema
2026-06-06 05:42:19 -04:00

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);
}