565 lines
18 KiB
Rust
565 lines
18 KiB
Rust
//! 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<Mutex<SolariRenderSceneStats>>);
|
|
|
|
pub struct SolariRenderingPlugin;
|
|
|
|
impl Plugin for SolariRenderingPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.add_plugins(SolariPlugins)
|
|
.init_resource::<RenderingCapabilities>()
|
|
.init_resource::<settings::EffectiveRenderStack>()
|
|
.init_resource::<SolariRaytracingSceneStats>()
|
|
.init_resource::<SolariRenderSceneReadback>()
|
|
.init_resource::<settings::ActiveCameraRenderProfile>()
|
|
.init_resource::<settings::ActiveVolumeContribution>()
|
|
.add_systems(Startup, init_opaque_renderer_method)
|
|
.add_systems(
|
|
Update,
|
|
detect_rendering_capabilities
|
|
.run_if(not(resource_exists::<RenderingCapabilitiesDetected>)),
|
|
)
|
|
.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::<SolariRenderSceneReadback>().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<Res<RenderDevice>>,
|
|
render_adapter: Option<Res<RenderAdapter>>,
|
|
mut caps: ResMut<RenderingCapabilities>,
|
|
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<ProjectSettings>,
|
|
caps: Res<RenderingCapabilities>,
|
|
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<RenderingCapabilities>,
|
|
stats: Res<SolariRaytracingSceneStats>,
|
|
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<RenderingCapabilities>,
|
|
mut commands: Commands,
|
|
cameras: Query<
|
|
Entity,
|
|
(
|
|
With<Camera3d>,
|
|
Without<SolariLighting>,
|
|
Without<DeferredPrepass>,
|
|
),
|
|
>,
|
|
) {
|
|
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<RenderingCapabilities>,
|
|
mut commands: Commands,
|
|
mut stats: ResMut<SolariRaytracingSceneStats>,
|
|
materials: Res<Assets<StandardMaterial>>,
|
|
mesh_assets: Res<Assets<Mesh>>,
|
|
meshes: Query<(
|
|
Entity,
|
|
&Mesh3d,
|
|
Option<&MeshMaterial3d<StandardMaterial>>,
|
|
Option<&RaytracingMesh3d>,
|
|
Option<&RaytracingExcluded>,
|
|
)>,
|
|
raytraced: Query<Entity, With<RaytracingMesh3d>>,
|
|
parents: Query<&ChildOf>,
|
|
level_roots: Query<(), With<LevelObject>>,
|
|
) {
|
|
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::<RaytracingMesh3d>();
|
|
}
|
|
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::<RaytracingMesh3d>();
|
|
}
|
|
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::<RaytracingMesh3d>();
|
|
}
|
|
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::<RaytracingMesh3d>();
|
|
}
|
|
}
|
|
|
|
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<StandardMaterial>>,
|
|
Option<&RaytracingMesh3d>,
|
|
Option<&RaytracingExcluded>,
|
|
)>,
|
|
parents: &Query<&ChildOf>,
|
|
level_roots: &Query<(), With<LevelObject>>,
|
|
) -> 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<LevelObject>>,
|
|
) -> 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<RenderingCapabilities>,
|
|
readback: Res<SolariRenderSceneReadback>,
|
|
mut stats: ResMut<SolariRaytracingSceneStats>,
|
|
) {
|
|
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<SolariRenderSceneReadback>,
|
|
scene_bindings: Option<Res<RaytracingSceneBindings>>,
|
|
raytracing_instances: Query<(), With<RaytracingMesh3d>>,
|
|
directional_lights: Query<(), With<ExtractedDirectionalLight>>,
|
|
) {
|
|
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<LevelObject>>)> =
|
|
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<LevelObject>>)> =
|
|
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
|
|
);
|
|
}
|
|
}
|