Blacksite/crates/game_hot/src/rendering/solari.rs
Rbanh f29712d158
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Initial project import
2026-06-05 21:44:45 -04:00

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