//! Solari GI path integration and capability detection. use bevy::camera::CameraMainTextureUsages; use bevy::core_pipeline::prepass::{ DeferredPrepass, DeferredPrepassDoubleBuffer, DepthPrepass, DepthPrepassDoubleBuffer, MotionVectorPrepass, }; use bevy::mesh::{Indices, PrimitiveTopology}; use bevy::pbr::{DefaultOpaqueRendererMethod, ExtractedDirectionalLight}; use bevy::prelude::*; use bevy::render::render_resource::TextureUsages; use bevy::render::renderer::{RenderAdapter, RenderDevice}; use bevy::render::{Render, RenderApp, RenderSystems}; use bevy_solari::prelude::{RaytracingMesh3d, SolariLighting}; use bevy_solari::scene::RaytracingSceneBindings; use bevy_solari::SolariPlugins; use settings::{GiPath, ProjectSettings, RenderingCapabilities}; use shared::{LevelObject, RaytracingExcluded}; use std::sync::{Arc, Mutex}; #[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct SolariRaytracingSceneStats { pub project_meshes: usize, pub tagged_meshes: usize, pub excluded_meshes: usize, pub unsupported_meshes: usize, pub missing_level_root_meshes: usize, pub emissive_meshes: usize, pub compatible_mesh_assets: usize, pub incompatible_mesh_assets: usize, pub mesh_assets_missing_tangents: usize, pub mesh_assets_missing_u32_indices: usize, pub render_instances: usize, pub render_directional_lights: usize, pub render_bind_group_ready: bool, } impl SolariRaytracingSceneStats { pub fn compatible_light_count(&self) -> usize { self.render_directional_lights + self.emissive_meshes } pub fn solari_ready(&self) -> bool { self.render_bind_group_ready && self.render_instances > 0 && self.compatible_light_count() > 0 } } #[derive(Debug, Default, Clone, Copy)] struct SolariRenderSceneStats { render_instances: usize, render_directional_lights: usize, render_bind_group_ready: bool, } #[derive(Resource, Debug, Clone, Default)] struct SolariRenderSceneReadback(Arc>); pub struct SolariRenderingPlugin; impl Plugin for SolariRenderingPlugin { fn build(&self, app: &mut App) { app.add_plugins(SolariPlugins) .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_systems(Startup, init_opaque_renderer_method) .add_systems( Update, detect_rendering_capabilities .run_if(not(resource_exists::)), ) .add_systems( PostUpdate, ( super::volumes::resolve_active_camera_render_profile_system, sync_render_solari_scene_readback, sync_opaque_renderer_method, sync_auxiliary_camera_deferred_prepass, sync_hydrated_raytracing_meshes, ) .chain(), ); } fn finish(&self, app: &mut App) { let readback = app.world().resource::().clone(); let Some(render_app) = app.get_sub_app_mut(RenderApp) else { return; }; render_app.insert_resource(readback).add_systems( Render, write_render_solari_scene_readback .in_set(RenderSystems::Prepare) .after(RenderSystems::PrepareBindGroups), ); } } /// Set once GPU features have been probed via [`RenderDevice`]. #[derive(Resource, Default)] struct RenderingCapabilitiesDetected; fn detect_rendering_capabilities( render_device: Option>, render_adapter: Option>, mut caps: ResMut, mut commands: Commands, ) { let Some(render_device) = render_device else { return; }; let rt_supported = probe_solari_rt_support(&render_device); caps.rt_supported = rt_supported; caps.auto_exposure_supported = render_adapter .as_deref() .map(super::auto_exposure::probe_auto_exposure_support) .unwrap_or(false); caps.requested_gi_path = GiPath::Forward; caps.effective_gi_path = GiPath::Forward; caps.active_gi_path = GiPath::Forward; caps.fallback_reason = None; if rt_supported { info!("Rendering: Solari RT features available when GiMode permits"); } else { warn!("Rendering: Solari RT features unavailable — GiMode::Auto uses forward PBR"); } if caps.auto_exposure_supported { info!("Rendering: auto exposure (compute) available"); } else { warn!("Rendering: auto exposure unavailable — using manual EV100 fallback"); } commands.insert_resource(RenderingCapabilitiesDetected); } /// Returns whether the active GPU exposes all wgpu features required by [`SolariPlugins`]. pub fn probe_solari_rt_support(render_device: &RenderDevice) -> bool { if let Ok(value) = std::env::var("BEVY_FPS_FORCE_SOLARI") { return !matches!( value.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | "no" ); } let required = SolariPlugins::required_wgpu_features(); render_device.features().contains(required) } fn init_opaque_renderer_method( settings: Res, caps: Res, mut commands: Commands, ) { let requested = settings::resolve_gi_path(settings.rendering.gi_mode, &caps); commands.insert_resource(opaque_renderer_method_for(requested)); } fn sync_opaque_renderer_method( caps: Res, stats: Res, mut commands: Commands, ) { if !caps.is_changed() && !stats.is_changed() { return; } commands.insert_resource(opaque_renderer_method_for(caps.effective_gi_path)); } fn opaque_renderer_method_for(path: GiPath) -> DefaultOpaqueRendererMethod { match path { GiPath::SolariDeferred => DefaultOpaqueRendererMethod::deferred(), GiPath::Forward => DefaultOpaqueRendererMethod::forward(), } } /// Non-primary 3D cameras still need deferred prepass targets when Solari is active. fn sync_auxiliary_camera_deferred_prepass( caps: Res, mut commands: Commands, cameras: Query< Entity, ( With, Without, Without, ), >, ) { if caps.effective_gi_path != GiPath::SolariDeferred { return; } for entity in &cameras { commands.entity(entity).insert(( DeferredPrepass, DepthPrepass, MotionVectorPrepass, DeferredPrepassDoubleBuffer, DepthPrepassDoubleBuffer, )); } } fn sync_hydrated_raytracing_meshes( caps: Res, mut commands: Commands, mut stats: ResMut, materials: Res>, mesh_assets: Res>, meshes: Query<( Entity, &Mesh3d, Option<&MeshMaterial3d>, Option<&RaytracingMesh3d>, Option<&RaytracingExcluded>, )>, raytraced: Query>, parents: Query<&ChildOf>, level_roots: Query<(), With>, ) { let mut next_stats = *stats; next_stats.project_meshes = 0; next_stats.tagged_meshes = 0; next_stats.excluded_meshes = 0; next_stats.unsupported_meshes = 0; next_stats.missing_level_root_meshes = 0; next_stats.emissive_meshes = 0; next_stats.compatible_mesh_assets = 0; next_stats.incompatible_mesh_assets = 0; next_stats.mesh_assets_missing_tangents = 0; next_stats.mesh_assets_missing_u32_indices = 0; if caps.requested_gi_path != GiPath::SolariDeferred { for entity in &raytraced { commands.entity(entity).remove::(); } next_stats.render_instances = 0; next_stats.render_directional_lights = 0; next_stats.render_bind_group_ready = false; if *stats != next_stats { info!( "Solari raytracing scene disabled: requested={:?}; removing {} raytracing mesh tags", caps.requested_gi_path, raytraced.iter().len() ); *stats = next_stats; } return; } for (entity, mesh, material, rt_mesh, excluded) in &meshes { if excluded.is_some() { next_stats.excluded_meshes += 1; if rt_mesh.is_some() { commands.entity(entity).remove::(); } continue; } if !has_level_object_ancestor(entity, &parents, &level_roots) { next_stats.missing_level_root_meshes += 1; continue; } next_stats.project_meshes += 1; let Some(material) = material else { next_stats.unsupported_meshes += 1; if rt_mesh.is_some() { commands.entity(entity).remove::(); } continue; }; if materials .get(&material.0) .is_some_and(|material| material.emissive.to_vec3() != Vec3::ZERO) { next_stats.emissive_meshes += 1; } match mesh_assets .get(&mesh.0) .map(classify_solari_mesh_compatibility) { Some(SolariMeshCompatibility::Compatible) => { next_stats.compatible_mesh_assets += 1; } Some(SolariMeshCompatibility::MissingTangents) => { next_stats.incompatible_mesh_assets += 1; next_stats.mesh_assets_missing_tangents += 1; } Some(SolariMeshCompatibility::MissingU32Indices) => { next_stats.incompatible_mesh_assets += 1; next_stats.mesh_assets_missing_u32_indices += 1; } Some(SolariMeshCompatibility::Other) | None => { next_stats.incompatible_mesh_assets += 1; } } next_stats.tagged_meshes += 1; let needs_insert = rt_mesh.is_none_or(|rt_mesh| rt_mesh.0 != mesh.0); if needs_insert { commands .entity(entity) .insert(RaytracingMesh3d(mesh.0.clone())); } } for entity in &raytraced { if !mesh_is_solari_project_geometry(entity, &meshes, &parents, &level_roots) { commands.entity(entity).remove::(); } } if *stats != next_stats { info!( "Solari mesh eligibility: requested={:?} eligible={} tagged={} compatible_assets={} incompatible_assets={} missing_tangents={} missing_u32_indices={} unsupported={} excluded={} outside_level_roots={} emissive={}", caps.requested_gi_path, next_stats.project_meshes, next_stats.tagged_meshes, next_stats.compatible_mesh_assets, next_stats.incompatible_mesh_assets, next_stats.mesh_assets_missing_tangents, next_stats.mesh_assets_missing_u32_indices, next_stats.unsupported_meshes, next_stats.excluded_meshes, next_stats.missing_level_root_meshes, next_stats.emissive_meshes, ); *stats = next_stats; } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SolariMeshCompatibility { Compatible, MissingTangents, MissingU32Indices, Other, } fn classify_solari_mesh_compatibility(mesh: &Mesh) -> SolariMeshCompatibility { if mesh.primitive_topology() != PrimitiveTopology::TriangleList || !mesh.enable_raytracing { return SolariMeshCompatibility::Other; } let attribute_ids: Vec<_> = mesh .attributes() .map(|(attribute, _)| attribute.id) .collect(); let expected = [ Mesh::ATTRIBUTE_POSITION.id, Mesh::ATTRIBUTE_NORMAL.id, Mesh::ATTRIBUTE_UV_0.id, Mesh::ATTRIBUTE_TANGENT.id, ]; if attribute_ids != expected { if !attribute_ids.contains(&Mesh::ATTRIBUTE_TANGENT.id) { return SolariMeshCompatibility::MissingTangents; } return SolariMeshCompatibility::Other; } if !matches!(mesh.indices(), Some(Indices::U32(_))) { return SolariMeshCompatibility::MissingU32Indices; } SolariMeshCompatibility::Compatible } fn mesh_is_solari_project_geometry( entity: Entity, meshes: &Query<( Entity, &Mesh3d, Option<&MeshMaterial3d>, Option<&RaytracingMesh3d>, Option<&RaytracingExcluded>, )>, parents: &Query<&ChildOf>, level_roots: &Query<(), With>, ) -> bool { let Ok((_, _, material, _, excluded)) = meshes.get(entity) else { return false; }; excluded.is_none() && material.is_some() && has_level_object_ancestor(entity, parents, level_roots) } fn has_level_object_ancestor( mut entity: Entity, parents: &Query<&ChildOf>, level_roots: &Query<(), With>, ) -> bool { const MAX_DEPTH: usize = 64; for _ in 0..MAX_DEPTH { if level_roots.contains(entity) { return true; } let Ok(parent) = parents.get(entity) else { return false; }; entity = parent.parent(); } false } fn sync_render_solari_scene_readback( caps: Res, readback: Res, mut stats: ResMut, ) { if caps.requested_gi_path != GiPath::SolariDeferred { let mut next = *stats; next.render_instances = 0; next.render_directional_lights = 0; next.render_bind_group_ready = false; if *stats != next { *stats = next; } return; } let Ok(render_stats) = readback.0.lock() else { return; }; let mut next = *stats; next.render_instances = render_stats.render_instances; next.render_directional_lights = render_stats.render_directional_lights; next.render_bind_group_ready = render_stats.render_bind_group_ready; if *stats != next { let was_ready = stats.solari_ready(); let is_ready = next.solari_ready(); if was_ready != is_ready || stats.render_instances != next.render_instances || stats.render_directional_lights != next.render_directional_lights || stats.render_bind_group_ready != next.render_bind_group_ready { info!( "Solari render readback: ready={} instances={} directional_lights={} bind_group_ready={} compatible_lights={}", is_ready, next.render_instances, next.render_directional_lights, next.render_bind_group_ready, next.compatible_light_count(), ); } *stats = next; } } fn write_render_solari_scene_readback( readback: Res, scene_bindings: Option>, raytracing_instances: Query<(), With>, directional_lights: Query<(), With>, ) { let Ok(mut stats) = readback.0.lock() else { return; }; *stats = SolariRenderSceneStats { render_instances: raytracing_instances.iter().len(), render_directional_lights: directional_lights.iter().len(), render_bind_group_ready: scene_bindings .is_some_and(|bindings| bindings.bind_group.is_some()), }; } pub fn effective_gi_path_for_camera( requested: GiPath, stats: Option<&SolariRaytracingSceneStats>, ) -> GiPath { let _ = stats; requested } pub fn sync_gi_path(commands: &mut Commands, entity: Entity, path: GiPath) { match path { GiPath::SolariDeferred => { commands.entity(entity).insert(( SolariLighting::default(), CameraMainTextureUsages::default().with(TextureUsages::STORAGE_BINDING), )); } GiPath::Forward => { commands .entity(entity) .remove::<(SolariLighting, CameraMainTextureUsages)>(); } } } pub fn strip_gi_path(commands: &mut Commands, entity: Entity) { sync_gi_path(commands, entity, GiPath::Forward); } #[cfg(test)] mod tests { use super::*; use bevy::ecs::system::SystemState; #[test] fn level_object_descendant_is_project_geometry() { let mut world = World::new(); let root = world.spawn(LevelObject).id(); let child = world.spawn(ChildOf(root)).id(); let mut state: SystemState<(Query<&ChildOf>, Query<(), With>)> = SystemState::new(&mut world); let (parents, level_roots) = state.get(&world); assert!(has_level_object_ancestor(child, &parents, &level_roots)); assert!(has_level_object_ancestor(root, &parents, &level_roots)); } #[test] fn orphan_mesh_is_not_project_geometry() { let mut world = World::new(); let orphan = world.spawn_empty().id(); let mut state: SystemState<(Query<&ChildOf>, Query<(), With>)> = SystemState::new(&mut world); let (parents, level_roots) = state.get(&world); assert!(!has_level_object_ancestor(orphan, &parents, &level_roots)); } #[test] fn solari_request_can_start_before_render_scene_is_ready() { let stats = SolariRaytracingSceneStats { project_meshes: 1, tagged_meshes: 1, render_instances: 1, render_directional_lights: 1, render_bind_group_ready: false, ..default() }; assert_eq!( effective_gi_path_for_camera(GiPath::SolariDeferred, Some(&stats)), GiPath::SolariDeferred ); } #[test] fn solari_request_uses_solari_when_render_scene_is_ready() { let stats = SolariRaytracingSceneStats { project_meshes: 1, tagged_meshes: 1, render_instances: 1, render_directional_lights: 1, render_bind_group_ready: true, ..default() }; assert_eq!( effective_gi_path_for_camera(GiPath::SolariDeferred, Some(&stats)), GiPath::SolariDeferred ); } }