Advance M0 scene and rendering foundations
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
2fa64106c8
commit
2054fa2558
@ -476,7 +476,7 @@ fn labeled_asset_path(path: &str, label: &str) -> String {
|
||||
}
|
||||
|
||||
fn studio_render_image() -> Image {
|
||||
Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba8UnormSrgb, None)
|
||||
Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba16Float, None)
|
||||
}
|
||||
|
||||
fn finish_active_thumbnail(
|
||||
|
||||
@ -7,7 +7,7 @@ use bevy::scene::serde::SceneDeserializer;
|
||||
use bevy::scene::DynamicScene;
|
||||
use bevy::scene::DynamicSceneBuilder;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use scene::{migrate_scene_text, stamp_schema_version, strip_schema_version, validate_level_text};
|
||||
use scene::{document::SceneDocument, strip_schema_version, validate_level_text};
|
||||
use serde::de::DeserializeSeed;
|
||||
use shared::{
|
||||
flush_level_object_hydration, infer_actor_kind, strip_hydrated_entity, validate_actor, ActorId,
|
||||
@ -495,8 +495,8 @@ fn save_entities(world: &mut World, path: &Path, entities: Vec<Entity>) -> Resul
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("could not create {}: {err}", parent.display()))?;
|
||||
}
|
||||
let stamped = stamp_schema_version(&ron)?;
|
||||
std::fs::write(path, stamped)
|
||||
let document = SceneDocument::from_ron_text(&ron)?;
|
||||
std::fs::write(path, document.to_ron_text()?)
|
||||
.map_err(|err| format!("could not write {}: {err}", path.display()))?;
|
||||
finalize_scene_load(world);
|
||||
|
||||
@ -511,8 +511,8 @@ fn load_level(world: &mut World, path: &Path) -> Result<(), String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|err| format!("could not read {}: {err}", path.display()))?;
|
||||
validate_level_text(&text)?;
|
||||
let normalized = migrate_scene_text(&text)?;
|
||||
let bevy_ron = strip_schema_version(&normalized)?;
|
||||
let document = SceneDocument::from_ron_text(&text)?;
|
||||
let bevy_ron = strip_schema_version(document.normalized_ron())?;
|
||||
|
||||
clear_loaded_scene_roots(world);
|
||||
clear_level_objects(world);
|
||||
|
||||
@ -2415,6 +2415,14 @@ fn light_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
}
|
||||
});
|
||||
});
|
||||
let solari_disabled = solari_disables_local_light(world, &light);
|
||||
if solari_disabled {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(255, 180, 100),
|
||||
"Point and spot lights are disabled while Solari is active. Use a directional light or emissive material, or switch GI to Forward.",
|
||||
);
|
||||
}
|
||||
ui.add_enabled_ui(!solari_disabled, |ui| {
|
||||
let mut color = [light.color.r, light.color.g, light.color.b, light.color.a];
|
||||
property_row(ui, "Color", |ui| {
|
||||
if ui.color_edit_button_rgba_unmultiplied(&mut color).changed() {
|
||||
@ -2488,12 +2496,25 @@ fn light_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
ui.small("Controls project sun while this directional exists.");
|
||||
}
|
||||
});
|
||||
});
|
||||
apply_component_card_response(world, entity, card_response);
|
||||
if changed {
|
||||
set_light_with_history(world, entity, light);
|
||||
}
|
||||
}
|
||||
|
||||
fn solari_disables_local_light(world: &World, light: &LightDesc) -> bool {
|
||||
matches!(
|
||||
light.kind,
|
||||
AuthoringLightKind::Point | AuthoringLightKind::Spot
|
||||
) && world
|
||||
.get_resource::<settings::ActiveCameraRenderProfile>()
|
||||
.is_some_and(|profile| profile.gi_path == settings::GiPath::SolariDeferred)
|
||||
&& world
|
||||
.get_resource::<settings::RenderingCapabilities>()
|
||||
.is_some_and(|caps| caps.rt_supported)
|
||||
}
|
||||
|
||||
fn rigid_body_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
let Some(mut body) = world.get::<RigidBodyDesc>(entity).copied() else {
|
||||
return;
|
||||
|
||||
@ -82,6 +82,7 @@ struct RenderViewCache {
|
||||
profile: Option<ActiveCameraRenderProfile>,
|
||||
effective_gi: Option<GiPath>,
|
||||
stack: Option<EffectiveRenderStack>,
|
||||
local_shadow_lights: Option<bool>,
|
||||
}
|
||||
|
||||
/// Ensures exactly one camera has the full project rendering stack for the current mode.
|
||||
@ -146,6 +147,8 @@ fn sync_project_render_view(
|
||||
let effective_gi = stack.effective_gi_path;
|
||||
let solari_readiness_changed = cache.effective_gi != Some(effective_gi);
|
||||
cache.effective_gi = Some(effective_gi);
|
||||
let local_shadow_lights_changed = cache.local_shadow_lights != Some(local_shadow_lights.0);
|
||||
cache.local_shadow_lights = Some(local_shadow_lights.0);
|
||||
|
||||
let active_target = viewport_target.0.as_ref().map(|t| t.image.id());
|
||||
let target_changed = cache.target != active_target;
|
||||
@ -156,6 +159,7 @@ fn sync_project_render_view(
|
||||
&& !target_changed
|
||||
&& !profile_changed
|
||||
&& !solari_readiness_changed
|
||||
&& !local_shadow_lights_changed
|
||||
{
|
||||
return;
|
||||
}
|
||||
@ -183,9 +187,11 @@ fn sync_project_render_view(
|
||||
|| target_changed
|
||||
|| profile_changed
|
||||
|| solari_readiness_changed
|
||||
|| local_shadow_lights_changed
|
||||
{
|
||||
let force_full_apply = owner_changed || target_changed || solari_readiness_changed;
|
||||
for entity in &active {
|
||||
let apply = if owner_changed {
|
||||
let apply = if force_full_apply {
|
||||
ViewportStackApply::Full
|
||||
} else if let Ok(state) = camera_fx.get(*entity) {
|
||||
let snap = fx_snapshot(state);
|
||||
|
||||
@ -84,9 +84,6 @@ fn active_camera_tab(world: &mut World, ui: &mut egui::Ui) {
|
||||
) {
|
||||
(GiPath::Forward, _, _) => "Forward PBR",
|
||||
(GiPath::SolariDeferred, GiPath::SolariDeferred, _) => "Solari deferred",
|
||||
(GiPath::SolariDeferred, _, Some(RenderFallbackReason::LocalShadowLights)) => {
|
||||
"Solari requested, Forward (local shadow lights)"
|
||||
}
|
||||
(GiPath::SolariDeferred, _, Some(RenderFallbackReason::RtUnsupported)) => {
|
||||
"Solari requested, Forward (RT unsupported)"
|
||||
}
|
||||
@ -102,12 +99,7 @@ fn active_camera_tab(world: &mut World, ui: &mut egui::Ui) {
|
||||
{
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(255, 180, 100),
|
||||
"Solari deferred: directional lights and emissive meshes affect lighting; point/spot lights are Forward-only in Bevy 0.18.",
|
||||
);
|
||||
} else if stack.fallback_reason == Some(RenderFallbackReason::LocalShadowLights) {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(255, 180, 100),
|
||||
"Point/spot lights with shadows require Forward PBR; GI auto-falls back while they are in the scene.",
|
||||
"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)
|
||||
@ -176,7 +168,9 @@ fn active_camera_tab(world: &mut World, ui: &mut egui::Ui) {
|
||||
ui.label(format!(
|
||||
"Post-FX: hdr={} target_hdr={} bloom={} taa={} ssao={} atmosphere={} tonemap={}",
|
||||
profile.hdr,
|
||||
hdr_enabled_profile(&profile) || stack.effective_gi_path == GiPath::SolariDeferred,
|
||||
hdr_enabled_profile(&profile)
|
||||
|| profile.atmosphere
|
||||
|| stack.effective_gi_path == GiPath::SolariDeferred,
|
||||
profile.bloom,
|
||||
profile.taa,
|
||||
profile.ssao,
|
||||
|
||||
@ -72,7 +72,7 @@ mod hot {
|
||||
ActiveCameraRenderProfile, ProjectRenderCamera, ProjectSettings, RenderingCapabilities,
|
||||
SimTuning,
|
||||
};
|
||||
use shared::{LevelObject, LightDesc, ProjectSun};
|
||||
use shared::{InspectorOrder, LevelObject, LightDesc, ProjectSun};
|
||||
use sim::{
|
||||
CameraSensitivity, Crouching, GameInputEnabled, GameInputFocused, Grounded, JumpState,
|
||||
Player, PlayerCamera, PlayerVelocity, PlayerYawSensitivity,
|
||||
|
||||
@ -51,16 +51,24 @@ pub fn hdr_enabled_profile(profile: &ActiveCameraRenderProfile) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the camera render target must be HDR for either authored HDR or Solari storage writes.
|
||||
/// True when the camera render target must be HDR for authored HDR, atmosphere sky, or Solari storage writes.
|
||||
pub fn hdr_required_for_effective_gi(
|
||||
profile: &ActiveCameraRenderProfile,
|
||||
effective_gi: GiPath,
|
||||
) -> bool {
|
||||
hdr_required_for_render_stack(hdr_enabled_profile(profile), effective_gi)
|
||||
hdr_required_for_render_stack(
|
||||
hdr_enabled_profile(profile),
|
||||
profile.atmosphere,
|
||||
effective_gi,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn hdr_required_for_render_stack(hdr_enabled: bool, effective_gi: GiPath) -> bool {
|
||||
hdr_enabled || effective_gi == GiPath::SolariDeferred
|
||||
pub fn hdr_required_for_render_stack(
|
||||
hdr_enabled: bool,
|
||||
atmosphere_enabled: bool,
|
||||
effective_gi: GiPath,
|
||||
) -> bool {
|
||||
hdr_enabled || atmosphere_enabled || effective_gi == GiPath::SolariDeferred
|
||||
}
|
||||
|
||||
/// Applies the shared project rendering profile to all [`ProjectRenderCamera`] entities.
|
||||
@ -363,9 +371,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn solari_forces_hdr_render_target() {
|
||||
assert!(hdr_required_for_render_stack(false, GiPath::SolariDeferred));
|
||||
assert!(hdr_required_for_render_stack(true, GiPath::Forward));
|
||||
assert!(!hdr_required_for_render_stack(false, GiPath::Forward));
|
||||
assert!(hdr_required_for_render_stack(
|
||||
false,
|
||||
false,
|
||||
GiPath::SolariDeferred
|
||||
));
|
||||
assert!(hdr_required_for_render_stack(true, false, GiPath::Forward));
|
||||
assert!(!hdr_required_for_render_stack(
|
||||
false,
|
||||
false,
|
||||
GiPath::Forward
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atmosphere_forces_hdr_render_target() {
|
||||
assert!(hdr_required_for_render_stack(false, true, GiPath::Forward));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ pub fn resolve_viewport_camera_owner(
|
||||
}
|
||||
}
|
||||
|
||||
/// True when any point or spot light in the world casts shadows (forces Forward GI).
|
||||
/// True when any point or spot light in the world casts shadows.
|
||||
pub fn has_local_shadow_lights(points: &Query<&PointLight>, spots: &Query<&SpotLight>) -> bool {
|
||||
points.iter().any(|l| l.shadows_enabled) || spots.iter().any(|l| l.shadows_enabled)
|
||||
}
|
||||
@ -57,7 +57,7 @@ pub fn world_has_local_shadow_lights(world: &mut World) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Effective GI path for the main viewport, including auto-forward for local shadow lights.
|
||||
/// Effective GI path for the main viewport.
|
||||
pub fn effective_viewport_gi_path(
|
||||
requested: GiPath,
|
||||
caps: &RenderingCapabilities,
|
||||
@ -134,6 +134,7 @@ pub fn sync_viewport_camera_stack(
|
||||
|
||||
match mode {
|
||||
ViewportStackApply::Full => {
|
||||
strip_project_camera_fx(commands, entity);
|
||||
commands.entity(entity).insert(ProjectRenderCamera);
|
||||
apply_camera_render_profile(
|
||||
commands,
|
||||
@ -208,7 +209,7 @@ mod tests {
|
||||
use settings::RenderingCapabilities;
|
||||
|
||||
#[test]
|
||||
fn effective_viewport_gi_forces_forward_when_local_shadow_lights_exist() {
|
||||
fn effective_viewport_gi_keeps_solari_when_local_shadow_lights_exist() {
|
||||
let caps = RenderingCapabilities {
|
||||
rt_supported: true,
|
||||
..Default::default()
|
||||
@ -221,7 +222,7 @@ mod tests {
|
||||
});
|
||||
assert_eq!(
|
||||
effective_viewport_gi_path(GiPath::SolariDeferred, &caps, stats.as_ref(), true),
|
||||
GiPath::Forward
|
||||
GiPath::SolariDeferred
|
||||
);
|
||||
assert_eq!(
|
||||
effective_viewport_gi_path(GiPath::SolariDeferred, &caps, stats.as_ref(), false),
|
||||
|
||||
376
crates/scene/src/document.rs
Normal file
376
crates/scene/src/document.rs
Normal file
@ -0,0 +1,376 @@
|
||||
//! Schema-aware scene document seam over the current DynamicScene RON format.
|
||||
//!
|
||||
//! This module intentionally stays Bevy-free. The editor can keep loading and
|
||||
//! saving DynamicScene RON while tools start depending on stable actor/component
|
||||
//! document concepts instead of runtime ECS entity IDs.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ron::ser::PrettyConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
migrate_scene_text, read_schema_version, stamp_schema_version, strip_schema_version,
|
||||
validate_scene_authoring_only,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SceneDocument {
|
||||
pub schema_version: u32,
|
||||
pub entities: Vec<SceneEntity>,
|
||||
normalized_ron: String,
|
||||
}
|
||||
|
||||
impl SceneDocument {
|
||||
pub fn from_ron_text(text: &str) -> Result<Self, String> {
|
||||
let normalized = migrate_scene_text(text)?;
|
||||
validate_scene_authoring_only(&normalized)?;
|
||||
let body = strip_schema_version(&normalized)?;
|
||||
let parsed: DynamicSceneBody = ron::from_str(&body)
|
||||
.map_err(|err| format!("could not parse scene document body: {err}"))?;
|
||||
|
||||
let mut entities = Vec::with_capacity(parsed.entities.len());
|
||||
for (index, (_source_key, entity)) in parsed.entities.into_iter().enumerate() {
|
||||
let components = entity
|
||||
.components
|
||||
.into_iter()
|
||||
.map(|(type_name, value)| SceneComponentBlob::from_value(type_name, value))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let actor_id = extract_actor_id(&components);
|
||||
let name = extract_name(&components);
|
||||
let document_id = actor_id
|
||||
.as_ref()
|
||||
.map(|id| SceneEntityId::ActorId(id.clone()))
|
||||
.unwrap_or_else(|| SceneEntityId::Anonymous(format!("anonymous:{index}")));
|
||||
|
||||
entities.push(SceneEntity {
|
||||
document_id,
|
||||
actor_id,
|
||||
name,
|
||||
parent_actor_id: None,
|
||||
components,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
schema_version: read_schema_version(&normalized),
|
||||
entities,
|
||||
normalized_ron: normalized,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_ron_text(&self) -> Result<String, String> {
|
||||
stamp_schema_version(&self.normalized_ron)
|
||||
}
|
||||
|
||||
pub fn normalized_ron(&self) -> &str {
|
||||
&self.normalized_ron
|
||||
}
|
||||
|
||||
pub fn find_entity(&self, id: &SceneEntityId) -> Option<&SceneEntity> {
|
||||
self.entities
|
||||
.iter()
|
||||
.find(|entity| &entity.document_id == id)
|
||||
}
|
||||
|
||||
pub fn apply_patch(&mut self, patch: ScenePatch) -> Result<(), String> {
|
||||
match patch {
|
||||
ScenePatch::SetComponent { target, component } => {
|
||||
let entity = self
|
||||
.find_entity_mut(&target)
|
||||
.ok_or_else(|| format!("scene entity `{target}` not found"))?;
|
||||
if let Some(existing) = entity
|
||||
.components
|
||||
.iter_mut()
|
||||
.find(|existing| existing.type_name == component.type_name)
|
||||
{
|
||||
*existing = component;
|
||||
} else {
|
||||
entity.components.push(component);
|
||||
}
|
||||
}
|
||||
ScenePatch::RemoveComponent {
|
||||
target,
|
||||
component_type,
|
||||
} => {
|
||||
let entity = self
|
||||
.find_entity_mut(&target)
|
||||
.ok_or_else(|| format!("scene entity `{target}` not found"))?;
|
||||
entity
|
||||
.components
|
||||
.retain(|component| component.type_name != component_type);
|
||||
}
|
||||
ScenePatch::AddEntity { entity } => {
|
||||
if self.find_entity(&entity.document_id).is_some() {
|
||||
return Err(format!(
|
||||
"scene entity `{}` already exists",
|
||||
entity.document_id
|
||||
));
|
||||
}
|
||||
self.entities.push(entity);
|
||||
}
|
||||
ScenePatch::DeleteEntity { target } => {
|
||||
let before = self.entities.len();
|
||||
self.entities.retain(|entity| entity.document_id != target);
|
||||
if self.entities.len() == before {
|
||||
return Err(format!("scene entity `{target}` not found"));
|
||||
}
|
||||
}
|
||||
ScenePatch::Reparent {
|
||||
target,
|
||||
parent_actor_id,
|
||||
} => {
|
||||
let entity = self
|
||||
.find_entity_mut(&target)
|
||||
.ok_or_else(|| format!("scene entity `{target}` not found"))?;
|
||||
entity.parent_actor_id = parent_actor_id;
|
||||
}
|
||||
ScenePatch::Rename { target, name } => {
|
||||
let entity = self
|
||||
.find_entity_mut(&target)
|
||||
.ok_or_else(|| format!("scene entity `{target}` not found"))?;
|
||||
entity.name = Some(name);
|
||||
}
|
||||
ScenePatch::SetTransform { target, transform } => {
|
||||
let entity = self
|
||||
.find_entity_mut(&target)
|
||||
.ok_or_else(|| format!("scene entity `{target}` not found"))?;
|
||||
let component =
|
||||
SceneComponentBlob::from_serializable(TRANSFORM_COMPONENT, &transform)?;
|
||||
if let Some(existing) = entity
|
||||
.components
|
||||
.iter_mut()
|
||||
.find(|existing| existing.type_name == TRANSFORM_COMPONENT)
|
||||
{
|
||||
*existing = component;
|
||||
} else {
|
||||
entity.components.push(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_entity_mut(&mut self, id: &SceneEntityId) -> Option<&mut SceneEntity> {
|
||||
self.entities
|
||||
.iter_mut()
|
||||
.find(|entity| &entity.document_id == id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum SceneEntityId {
|
||||
ActorId(String),
|
||||
Anonymous(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SceneEntityId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ActorId(id) => write!(f, "actor:{id}"),
|
||||
Self::Anonymous(id) => write!(f, "{id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SceneEntity {
|
||||
pub document_id: SceneEntityId,
|
||||
pub actor_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub parent_actor_id: Option<String>,
|
||||
pub components: Vec<SceneComponentBlob>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SceneComponentBlob {
|
||||
pub type_name: String,
|
||||
pub ron: String,
|
||||
}
|
||||
|
||||
impl SceneComponentBlob {
|
||||
pub fn from_serializable(
|
||||
type_name: impl Into<String>,
|
||||
value: &impl Serialize,
|
||||
) -> Result<Self, String> {
|
||||
let pretty = PrettyConfig::new();
|
||||
let ron = ron::ser::to_string_pretty(value, pretty)
|
||||
.map_err(|err| format!("could not serialize component blob: {err}"))?;
|
||||
Ok(Self {
|
||||
type_name: type_name.into(),
|
||||
ron,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_value(type_name: String, value: ron::Value) -> Result<Self, String> {
|
||||
let pretty = PrettyConfig::new();
|
||||
let ron = ron::ser::to_string_pretty(&value, pretty)
|
||||
.map_err(|err| format!("could not serialize component value `{type_name}`: {err}"))?;
|
||||
Ok(Self { type_name, ron })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ScenePatch {
|
||||
SetComponent {
|
||||
target: SceneEntityId,
|
||||
component: SceneComponentBlob,
|
||||
},
|
||||
RemoveComponent {
|
||||
target: SceneEntityId,
|
||||
component_type: String,
|
||||
},
|
||||
AddEntity {
|
||||
entity: SceneEntity,
|
||||
},
|
||||
DeleteEntity {
|
||||
target: SceneEntityId,
|
||||
},
|
||||
Reparent {
|
||||
target: SceneEntityId,
|
||||
parent_actor_id: Option<String>,
|
||||
},
|
||||
Rename {
|
||||
target: SceneEntityId,
|
||||
name: String,
|
||||
},
|
||||
SetTransform {
|
||||
target: SceneEntityId,
|
||||
transform: SceneTransform,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SceneTransform {
|
||||
pub translation: [f32; 3],
|
||||
pub rotation: [f32; 4],
|
||||
pub scale: [f32; 3],
|
||||
}
|
||||
|
||||
impl Default for SceneTransform {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
translation: [0.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
scale: [1.0, 1.0, 1.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DynamicSceneBody {
|
||||
#[serde(default)]
|
||||
entities: BTreeMap<u64, DynamicSceneEntity>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DynamicSceneEntity {
|
||||
#[serde(default)]
|
||||
components: BTreeMap<String, ron::Value>,
|
||||
}
|
||||
|
||||
const ACTOR_ID_COMPONENT: &str = "shared::components::ActorId";
|
||||
const NAME_COMPONENT: &str = "bevy_ecs::name::Name";
|
||||
pub const TRANSFORM_COMPONENT: &str = "bevy_transform::components::transform::Transform";
|
||||
|
||||
fn extract_name(components: &[SceneComponentBlob]) -> Option<String> {
|
||||
components
|
||||
.iter()
|
||||
.find(|component| component.type_name == NAME_COMPONENT)
|
||||
.map(|component| component.ron.trim().trim_matches('"').to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
fn extract_actor_id(components: &[SceneComponentBlob]) -> Option<String> {
|
||||
let component = components
|
||||
.iter()
|
||||
.find(|component| component.type_name == ACTOR_ID_COMPONENT)?;
|
||||
ron::from_str::<String>(&component.ron)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
ron::from_str::<Vec<String>>(&component.ron)
|
||||
.ok()
|
||||
.and_then(|values| values.into_iter().next())
|
||||
})
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_scene_document_without_exposing_runtime_entity_ids() {
|
||||
let text = r#"(schema_version: 2,
|
||||
resources: {},
|
||||
entities: {
|
||||
4294967133: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"shared::components::ActorId": ("crate-01"),
|
||||
"shared::components::LevelObject": (),
|
||||
},
|
||||
),
|
||||
},
|
||||
)"#;
|
||||
let document = SceneDocument::from_ron_text(text).unwrap();
|
||||
assert_eq!(document.schema_version, crate::CURRENT_SCENE_SCHEMA_VERSION);
|
||||
assert_eq!(document.entities.len(), 1);
|
||||
assert_eq!(document.entities[0].actor_id.as_deref(), Some("crate-01"));
|
||||
assert_eq!(document.entities[0].name.as_deref(), Some("Crate"));
|
||||
assert_eq!(
|
||||
document.entities[0].document_id,
|
||||
SceneEntityId::ActorId("crate-01".into())
|
||||
);
|
||||
assert!(!format!("{:?}", document.entities[0].document_id).contains("4294967133"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_normalized_ron_text() {
|
||||
let body = "(resources: {}, entities: {})";
|
||||
let document = SceneDocument::from_ron_text(body).unwrap();
|
||||
let text = document.to_ron_text().unwrap();
|
||||
assert!(text.starts_with("(schema_version: 2,"));
|
||||
assert!(text.contains("entities: {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_rename_and_transform_are_representable() {
|
||||
let text = r#"(schema_version: 2,
|
||||
resources: {},
|
||||
entities: {
|
||||
1: (
|
||||
components: {
|
||||
"shared::components::ActorId": ("actor-a"),
|
||||
"shared::components::LevelObject": (),
|
||||
},
|
||||
),
|
||||
},
|
||||
)"#;
|
||||
let mut document = SceneDocument::from_ron_text(text).unwrap();
|
||||
let target = SceneEntityId::ActorId("actor-a".into());
|
||||
document
|
||||
.apply_patch(ScenePatch::Rename {
|
||||
target: target.clone(),
|
||||
name: "Renamed".into(),
|
||||
})
|
||||
.unwrap();
|
||||
document
|
||||
.apply_patch(ScenePatch::SetTransform {
|
||||
target: target.clone(),
|
||||
transform: SceneTransform {
|
||||
translation: [1.0, 2.0, 3.0],
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let entity = document.find_entity(&target).unwrap();
|
||||
assert_eq!(entity.name.as_deref(), Some("Renamed"));
|
||||
assert!(entity
|
||||
.components
|
||||
.iter()
|
||||
.any(|component| component.type_name == TRANSFORM_COMPONENT));
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
//! Bevy dynamic scenes are stored as RON tuples. On save the editor stamps
|
||||
//! `schema_version` as the first root field; loaders strip it before deserializing.
|
||||
|
||||
pub mod document;
|
||||
mod migrate;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@ -29,7 +29,6 @@ pub enum GiPath {
|
||||
#[reflect(Debug, PartialEq)]
|
||||
pub enum RenderFallbackReason {
|
||||
RtUnsupported,
|
||||
LocalShadowLights,
|
||||
}
|
||||
|
||||
/// Unified render-stack decision consumed by game, editor, and diagnostics.
|
||||
@ -204,10 +203,6 @@ pub fn resolve_effective_render_stack(
|
||||
GiPath::SolariDeferred if !caps.rt_supported => {
|
||||
(GiPath::Forward, Some(RenderFallbackReason::RtUnsupported))
|
||||
}
|
||||
GiPath::SolariDeferred if local_shadow_lights => (
|
||||
GiPath::Forward,
|
||||
Some(RenderFallbackReason::LocalShadowLights),
|
||||
),
|
||||
GiPath::SolariDeferred => (GiPath::SolariDeferred, None),
|
||||
};
|
||||
|
||||
@ -246,7 +241,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_stack_falls_back_for_local_shadow_lights() {
|
||||
fn effective_stack_keeps_solari_when_local_shadow_lights_exist() {
|
||||
let caps = RenderingCapabilities {
|
||||
rt_supported: true,
|
||||
..Default::default()
|
||||
@ -254,10 +249,8 @@ mod tests {
|
||||
|
||||
let stack = resolve_effective_render_stack(GiPath::SolariDeferred, &caps, true, true);
|
||||
|
||||
assert_eq!(stack.effective_gi_path, GiPath::Forward);
|
||||
assert_eq!(
|
||||
stack.fallback_reason,
|
||||
Some(RenderFallbackReason::LocalShadowLights)
|
||||
);
|
||||
assert_eq!(stack.effective_gi_path, GiPath::SolariDeferred);
|
||||
assert_eq!(stack.fallback_reason, None);
|
||||
assert!(stack.local_shadow_lights);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use bevy::light::{CascadeShadowConfig, CascadeShadowConfigBuilder};
|
||||
use bevy::prelude::*;
|
||||
use settings::RenderingSettings;
|
||||
use settings::{ActiveCameraRenderProfile, GiPath, RenderingCapabilities, RenderingSettings};
|
||||
|
||||
use crate::{
|
||||
inspector_component_active, AuthoringLightKind, InspectorOrder, LevelObject, LightDesc,
|
||||
@ -29,26 +29,29 @@ pub fn cascade_config_from_rendering(rendering: &RenderingSettings) -> CascadeSh
|
||||
pub fn hydrate_lights(
|
||||
mut commands: Commands,
|
||||
settings: Res<settings::ProjectSettings>,
|
||||
lights: Query<
|
||||
(Entity, &LightDesc, Option<&InspectorOrder>),
|
||||
(
|
||||
With<LevelObject>,
|
||||
Or<(
|
||||
Added<LightDesc>,
|
||||
Changed<LightDesc>,
|
||||
Changed<InspectorOrder>,
|
||||
)>,
|
||||
),
|
||||
>,
|
||||
profile: Option<Res<ActiveCameraRenderProfile>>,
|
||||
caps: Option<Res<RenderingCapabilities>>,
|
||||
lights: Query<(Entity, Ref<LightDesc>, Option<Ref<InspectorOrder>>), With<LevelObject>>,
|
||||
) {
|
||||
let rendering = &settings.rendering;
|
||||
let render_stack_changed = profile.as_ref().is_some_and(|profile| profile.is_changed())
|
||||
|| caps.as_ref().is_some_and(|caps| caps.is_changed());
|
||||
let profile = profile.as_deref();
|
||||
let caps = caps.as_deref();
|
||||
for (entity, light, order) in &lights {
|
||||
let mut entity_commands = commands.entity(entity);
|
||||
strip_light_components(&mut entity_commands);
|
||||
if !inspector_component_active(order, COMPONENT_LIGHT_DESC) {
|
||||
let order_changed = order.as_ref().is_some_and(|order| order.is_changed());
|
||||
if !light.is_changed() && !order_changed && !render_stack_changed {
|
||||
continue;
|
||||
}
|
||||
insert_light_from_desc(&mut entity_commands, light, rendering);
|
||||
let mut entity_commands = commands.entity(entity);
|
||||
strip_light_components(&mut entity_commands);
|
||||
if !inspector_component_active(order.as_deref(), COMPONENT_LIGHT_DESC) {
|
||||
continue;
|
||||
}
|
||||
if local_light_disabled_in_solari(&light, profile, caps) {
|
||||
continue;
|
||||
}
|
||||
insert_light_from_desc(&mut entity_commands, &light, rendering);
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,16 +80,27 @@ pub(crate) fn normalized_directional_lux(intensity: f32) -> f32 {
|
||||
pub fn reconcile_missing_runtime_lights(
|
||||
mut commands: Commands,
|
||||
settings: Res<settings::ProjectSettings>,
|
||||
profile: Option<Res<ActiveCameraRenderProfile>>,
|
||||
caps: Option<Res<RenderingCapabilities>>,
|
||||
lights: Query<(Entity, &LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
|
||||
points: Query<(), With<PointLight>>,
|
||||
spots: Query<(), With<SpotLight>>,
|
||||
directionals: Query<(), With<DirectionalLight>>,
|
||||
) {
|
||||
let rendering = &settings.rendering;
|
||||
let profile = profile.as_deref();
|
||||
let caps = caps.as_deref();
|
||||
for (entity, light, order) in &lights {
|
||||
if !inspector_component_active(order, COMPONENT_LIGHT_DESC) {
|
||||
continue;
|
||||
}
|
||||
if local_light_disabled_in_solari(light, profile, caps) {
|
||||
if points.contains(entity) || spots.contains(entity) || directionals.contains(entity) {
|
||||
let mut entity_commands = commands.entity(entity);
|
||||
strip_light_components(&mut entity_commands);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let missing = match light.kind {
|
||||
AuthoringLightKind::Point => !points.contains(entity),
|
||||
AuthoringLightKind::Spot => !spots.contains(entity),
|
||||
@ -101,6 +115,18 @@ pub fn reconcile_missing_runtime_lights(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn local_light_disabled_in_solari(
|
||||
light: &LightDesc,
|
||||
profile: Option<&ActiveCameraRenderProfile>,
|
||||
caps: Option<&RenderingCapabilities>,
|
||||
) -> bool {
|
||||
matches!(
|
||||
light.kind,
|
||||
AuthoringLightKind::Point | AuthoringLightKind::Spot
|
||||
) && profile.is_some_and(|profile| profile.gi_path == GiPath::SolariDeferred)
|
||||
&& caps.is_some_and(|caps| caps.rt_supported)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_light_from_desc(
|
||||
entity: &mut EntityCommands<'_>,
|
||||
light: &LightDesc,
|
||||
@ -156,7 +182,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{AuthoringLightKind, ColorDesc, LevelObject, LightDesc};
|
||||
use bevy::ecs::world::CommandQueue;
|
||||
use settings::RenderingSettings;
|
||||
use settings::{ActiveCameraRenderProfile, GiPath, RenderingCapabilities, RenderingSettings};
|
||||
|
||||
#[test]
|
||||
fn light_kind_swap_replaces_runtime_types() {
|
||||
@ -251,6 +277,34 @@ mod tests {
|
||||
assert!(world.get::<Visibility>(spot_entity).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solari_disables_point_and_spot_light_hydration() {
|
||||
let profile = ActiveCameraRenderProfile {
|
||||
gi_path: GiPath::SolariDeferred,
|
||||
..Default::default()
|
||||
};
|
||||
let caps = RenderingCapabilities {
|
||||
rt_supported: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(local_light_disabled_in_solari(
|
||||
&LightDesc::for_kind(AuthoringLightKind::Point),
|
||||
Some(&profile),
|
||||
Some(&caps)
|
||||
));
|
||||
assert!(local_light_disabled_in_solari(
|
||||
&LightDesc::for_kind(AuthoringLightKind::Spot),
|
||||
Some(&profile),
|
||||
Some(&caps)
|
||||
));
|
||||
assert!(!local_light_disabled_in_solari(
|
||||
&LightDesc::for_kind(AuthoringLightKind::Directional),
|
||||
Some(&profile),
|
||||
Some(&caps)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_light_default_casts_shadows() {
|
||||
let light = LightDesc::for_kind(AuthoringLightKind::Point);
|
||||
|
||||
@ -138,13 +138,6 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
}
|
||||
}
|
||||
|
||||
let lights: Vec<(Entity, crate::LightDesc)> = world
|
||||
.query_filtered::<(Entity, &crate::LightDesc, Option<&InspectorOrder>), With<LevelObject>>()
|
||||
.iter(world)
|
||||
.filter(|(_, _, order)| inspector_component_active(*order, COMPONENT_LIGHT_DESC))
|
||||
.map(|(entity, light, _)| (entity, light.clone()))
|
||||
.collect();
|
||||
|
||||
let visibility_targets: Vec<(Entity, EditorVisibility)> = world
|
||||
.query_filtered::<(Entity, &EditorVisibility), With<LevelObject>>()
|
||||
.iter(world)
|
||||
@ -155,6 +148,22 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
.get_resource::<settings::ProjectSettings>()
|
||||
.map(|s| s.rendering.clone())
|
||||
.unwrap_or_default();
|
||||
let profile = world
|
||||
.get_resource::<settings::ActiveCameraRenderProfile>()
|
||||
.cloned();
|
||||
let caps = world
|
||||
.get_resource::<settings::RenderingCapabilities>()
|
||||
.cloned();
|
||||
|
||||
let lights: Vec<(Entity, crate::LightDesc)> = world
|
||||
.query_filtered::<(Entity, &crate::LightDesc, Option<&InspectorOrder>), With<LevelObject>>()
|
||||
.iter(world)
|
||||
.filter(|(_, light, order)| {
|
||||
inspector_component_active(*order, COMPONENT_LIGHT_DESC)
|
||||
&& !lights::local_light_disabled_in_solari(light, profile.as_ref(), caps.as_ref())
|
||||
})
|
||||
.map(|(entity, light, _)| (entity, light.clone()))
|
||||
.collect();
|
||||
|
||||
let mut state: SystemState<(
|
||||
Commands,
|
||||
@ -245,6 +254,65 @@ mod tests {
|
||||
assert!(app.world().get::<PointLight>(entity).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_hydration_skips_point_light_when_solari_is_active() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_plugins(AssetPlugin::default());
|
||||
app.add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default());
|
||||
app.init_asset::<Mesh>();
|
||||
app.world_mut()
|
||||
.insert_resource(settings::ActiveCameraRenderProfile {
|
||||
gi_path: settings::GiPath::SolariDeferred,
|
||||
..Default::default()
|
||||
});
|
||||
app.world_mut()
|
||||
.insert_resource(settings::RenderingCapabilities {
|
||||
rt_supported: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((LevelObject, LightDesc::for_kind(AuthoringLightKind::Point)))
|
||||
.id();
|
||||
|
||||
flush_level_object_hydration(app.world_mut());
|
||||
|
||||
assert!(app.world().get::<PointLight>(entity).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_hydration_keeps_directional_light_when_solari_is_active() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_plugins(AssetPlugin::default());
|
||||
app.add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default());
|
||||
app.init_asset::<Mesh>();
|
||||
app.world_mut()
|
||||
.insert_resource(settings::ActiveCameraRenderProfile {
|
||||
gi_path: settings::GiPath::SolariDeferred,
|
||||
..Default::default()
|
||||
});
|
||||
app.world_mut()
|
||||
.insert_resource(settings::RenderingCapabilities {
|
||||
rt_supported: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
LevelObject,
|
||||
LightDesc::for_kind(AuthoringLightKind::Directional),
|
||||
))
|
||||
.id();
|
||||
|
||||
flush_level_object_hydration(app.world_mut());
|
||||
|
||||
assert!(app.world().get::<DirectionalLight>(entity).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_hydration_skips_inactive_light_desc() {
|
||||
let mut app = App::new();
|
||||
|
||||
@ -29,7 +29,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
||||
| [0012](adr/0012-zero-tech-debt-editor.md) | Zero tech debt / no dual inspector |
|
||||
| [0013](adr/0013-rendering-tiers-and-post-process-volumes.md) | GI tiers, post-process volumes, editor model |
|
||||
| [0014](adr/0014-unified-viewport-model.md) | Unified viewport render target and clean game-view overlay |
|
||||
| [0015](adr/0015-viewport-camera-stack-ownership.md) | Viewport camera FX ownership, GI auto-forward for local shadows |
|
||||
| [0015](adr/0015-viewport-camera-stack-ownership.md) | Viewport camera FX ownership and Solari local-light authoring policy |
|
||||
| [0016](adr/0016-unified-rendering-contract.md) | Requested/effective render stack, Solari eligibility, emissive materials |
|
||||
| [0017](adr/0017-normalized-static-mesh-assets.md) | Normalized static mesh assets and renderer placement |
|
||||
| [0018](adr/0018-componentized-actor-inspector-and-materials.md) | Componentized actor inspector, imported asset refs, collider split, material overrides |
|
||||
|
||||
@ -12,9 +12,11 @@ Levels under `assets/levels/` need forward-compatible migrations as authoring co
|
||||
|
||||
- Track `schema_version` in scene files (wrapper or sidecar during transition).
|
||||
- Provide `scene_schema` module with `migrate_scene_text` and `validate_level_file`.
|
||||
- Route editor save/load through `scene::document::SceneDocument`, an internal Bevy-free seam that preserves DynamicScene RON while exposing stable actor/component document identities and patch operations for tools.
|
||||
- CI / `xtask validate-levels` loads all committed levels and fails on unknown versions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Breaking component changes require a migration step and version bump.
|
||||
- Editor save may wrap scenes with schema header in a follow-up change.
|
||||
- The current storage format remains DynamicScene RON; `SceneDocument` is not a BSN/JSN migration.
|
||||
|
||||
@ -16,12 +16,12 @@ All rendering configuration previously lived in `RenderingSettings` (`assets/pro
|
||||
|------|----------|
|
||||
| `GiMode::Auto` | Startup default: Solari deferred when RT GPU support is available; Forward PBR otherwise |
|
||||
| `GiMode::Forward` | Bevy Forward PBR with HDR/TAA/SSAO/atmosphere/shadows |
|
||||
| `GiMode::Solari` | Request Solari deferred; camera attaches Bevy Solari when RT support is available and no local shadow-light fallback is required |
|
||||
| `GiMode::Solari` | Request Solari deferred; camera attaches Bevy Solari when RT support is available |
|
||||
|
||||
Runtime exposes `RenderingCapabilities { rt_supported, active_gi_path }` and a resolved `ActiveCameraRenderProfile` each frame.
|
||||
ADR 0016 supersedes the ambiguous `active_gi_path` wording with explicit requested/effective GI fields and `EffectiveRenderStack` fallback reasons.
|
||||
|
||||
Bevy 0.18 Solari samples directional lights and emissive meshes, while point and spot lights require the normal PBR light pass that Solari cameras skip. The editor surfaces this limitation in Rendering diagnostics; point/spot light authoring uses Forward PBR. Requested Solari tags project geometry for raytracing and switches camera plus opaque-renderer components to Solari as soon as RT support is available; render-world readiness counters report whether BLAS/TLAS/bind-group setup and compatible lights are actually ready.
|
||||
Bevy 0.18 Solari samples directional lights and emissive meshes, while point and spot lights require the normal PBR light pass that Solari cameras skip. The editor surfaces this limitation directly on point/spot `LightDesc` components and runtime-disables them while Solari is active; point/spot light authoring uses Forward PBR. Requested Solari tags project geometry for raytracing and switches camera plus opaque-renderer components to Solari as soon as RT support is available; render-world readiness counters report whether BLAS/TLAS/bind-group setup and compatible lights are actually ready.
|
||||
|
||||
### WYSIWYG
|
||||
|
||||
|
||||
@ -11,8 +11,9 @@ components were still stripped in multiple places (`scene_view`, play session, a
|
||||
in `render_view`). That caused WYSIWYG gaps between edit and play, lost FX after resize, and
|
||||
interacted badly with `setup_project_camera_effects` filtering on `Without<Atmosphere>`.
|
||||
|
||||
Separately, `GiMode::Auto` could select Solari while shadow-casting point/spot lights were present.
|
||||
Solari deferred skips the forward PBR light pass (ADR 0013), so local light shadows disappeared.
|
||||
Separately, `GiMode::Auto` could select Solari while point/spot lights were present.
|
||||
Solari deferred skips the forward PBR light pass (ADR 0013), so local lights appeared editable
|
||||
while producing no lighting.
|
||||
|
||||
Scene directionals hydrated with hardcoded cascade bounds that did not match project shadow settings
|
||||
used by `ProjectSun`.
|
||||
@ -25,8 +26,9 @@ used by `ProjectSun`.
|
||||
`viewport = None` only. **`render_view::sync_project_render_view`** calls
|
||||
`clear_viewport_camera_stack` on owner handoff and `sync_viewport_camera_stack` on the active
|
||||
camera (full apply on owner change; incremental when the stack already exists).
|
||||
3. **`effective_viewport_gi_path`** forces `GiPath::Forward` when any `PointLight` or `SpotLight`
|
||||
with `shadows_enabled` exists in the world (auto-forward for local shadows).
|
||||
3. **`effective_viewport_gi_path`** does not switch paths for local lights. When Solari is active,
|
||||
point/spot `LightDesc` authoring remains in the scene but hydrates no runtime light component,
|
||||
and the inspector marks the component unsupported until authors switch to Directional or Forward.
|
||||
4. **Scene directionals** use `cascade_config_from_rendering` from project settings at hydration;
|
||||
`sync_scene_directional_shadow_cascades` updates cascades when settings change.
|
||||
5. **Hydrated lights** always insert `Visibility::Visible` with runtime light components.
|
||||
@ -34,7 +36,8 @@ used by `ProjectSun`.
|
||||
## Consequences
|
||||
|
||||
- Edit/play possession (F5/F8) and settings Apply no longer flash unlit frames from scattered FX strips.
|
||||
- Rendering diagnostics can report “Solari requested, Forward (local shadow lights)”.
|
||||
- Rendering diagnostics can report unsupported Solari-local light authoring without changing the
|
||||
selected GI path.
|
||||
- Thumbnail studio and multi-viewport remain out of scope; they do not use the viewport camera sync path.
|
||||
- `setup_project_camera_effects` no longer skips cameras that already have atmosphere; editor defers
|
||||
player FX via `GameRenderBootstrap` and relies on `sync_project_render_view` once HDR RTT exists.
|
||||
|
||||
@ -14,7 +14,7 @@ Bevy 0.18 Solari is still experimental. It needs raytracing-capable hardware, el
|
||||
`Mesh3d + MeshMaterial3d<StandardMaterial>` scene geometry, Solari-compatible mesh assets
|
||||
(TriangleList, POSITION/NORMAL/UV_0/TANGENT, U32 indices), a render-scene bind group, and a
|
||||
compatible directional or emissive light source. It also does not replace Forward PBR local
|
||||
point/spot shadow lighting.
|
||||
point/spot lighting.
|
||||
|
||||
## Decision
|
||||
|
||||
@ -24,14 +24,19 @@ point/spot shadow lighting.
|
||||
- `game_hot` owns runtime application of the stack. Camera systems call the shared resolver and
|
||||
mirror the result into `RenderingCapabilities` for renderer method selection and diagnostics.
|
||||
- Requested Solari and effective Solari are distinct. Requested Solari keeps eligible project
|
||||
geometry tagged for raytracing and attaches Bevy Solari as soon as RT support is available, unless
|
||||
local-shadow fallback is required.
|
||||
- Effective Solari forces an HDR camera target regardless of authored HDR settings, because Bevy
|
||||
Solari requires `STORAGE_BINDING` on the camera main texture and wgpu rejects storage usage on
|
||||
sRGB target formats.
|
||||
geometry tagged for raytracing and attaches Bevy Solari as soon as RT support is available.
|
||||
- Point/spot `LightDesc` components are runtime-disabled while Solari is active. They stay authored
|
||||
and editable enough to switch kind, but hydration strips/skips their Bevy `PointLight`/`SpotLight`
|
||||
components and the inspector explains the limitation on the component.
|
||||
- Effective Solari and atmosphere force an HDR camera target regardless of authored HDR settings.
|
||||
Bevy Solari requires `STORAGE_BINDING` on the camera main texture, and Bevy sky/atmosphere
|
||||
pipelines must not be carried onto an SDR swapchain render pass.
|
||||
- Runtime changes that alter the active render target or effective Solari/Forward path rebuild the
|
||||
active camera stack instead of incrementally patching it. This
|
||||
prevents stale pipelines from surviving texture-format transitions.
|
||||
- Hybrid Auto remains the default policy: use Solari for directional/emissive scenes, surface
|
||||
readiness diagnostics while it warms up, and fall back to Forward PBR when local point/spot shadow
|
||||
correctness or unavailable RT features require it.
|
||||
readiness diagnostics while it warms up, and fall back to Forward PBR only when RT features are
|
||||
unavailable.
|
||||
- Material authoring includes emissive color, intensity, and texture fields so scenes can create
|
||||
Solari-compatible emissive contributors without Rust changes.
|
||||
|
||||
@ -39,6 +44,8 @@ point/spot shadow lighting.
|
||||
|
||||
- Diagnostics must report requested GI, effective GI, and fallback reason from
|
||||
`EffectiveRenderStack` rather than deriving labels independently.
|
||||
- Project settings can disable user-authored HDR, but the active camera still receives `Hdr` when
|
||||
atmosphere or effective Solari requires an HDR target.
|
||||
- Solari mesh eligibility is based on hydrated project geometry under `LevelObject` ancestry with
|
||||
`RaytracingExcluded` helpers filtered out. Built-in primitive hydration generates tangents for
|
||||
Solari compatibility; imported/custom meshes remain visible in diagnostics when they lack
|
||||
|
||||
@ -74,7 +74,12 @@ Three camera roles coexist:
|
||||
|
||||
The viewport uses **render-to-texture**: the active camera renders to an offscreen HDR target sized to the dock panel (`panel_physical_size()`), then egui displays that texture. Cameras use `viewport = None` on image targets so the scissor always matches the texture; panel size only drives texture allocation, not a sub-viewport on the image.
|
||||
|
||||
**HDR invariant:** The atmosphere/HDR stack (`Atmosphere`, `Hdr`, etc.) must never render to the primary window swapchain (`Rgba8UnormSrgb`). When the HDR offscreen target is missing, `scene_view` / `play` deactivate cameras; `render_view` skips stack sync until a target exists.
|
||||
**HDR invariant:** Atmosphere, authored HDR, and effective Solari all require an `Hdr`
|
||||
camera target; they must never render to the primary window swapchain
|
||||
(`Rgba8UnormSrgb`). When the HDR offscreen target is missing, `scene_view` / `play`
|
||||
deactivate cameras; `render_view` skips stack sync until a target exists.
|
||||
Render-target changes and Solari/Forward fallback changes force a full camera-stack rebuild so
|
||||
stale sky or post-FX pipelines cannot survive across texture-format transitions.
|
||||
|
||||
**Viewport picking:** LMB selection maps the egui panel UV through `scene_view_ray` and raycasts with `MeshRayCast`. Object pick runs in `Last` after `transform_gizmo_bevy` so gizmo drags take priority. `GizmoOptions.viewport_rect` is synced from the viewport panel rect so gizmo hit-testing matches render-to-texture layout. Actor root icon and visualizer proxy hits resolve to their source entity, so clicking an icon or light/spawn/collider marker selects the authored or runtime object, not the helper. Actor icon hits are ordered ahead of ordinary mesh hits when both are under the cursor. In Edit mode, selecting the runtime Player redirects to an authored `PlayerSpawn` (`Player Start`), creating one if needed.
|
||||
|
||||
@ -139,8 +144,8 @@ On play enter/exit the editor snapshots **player sim state only** (transform, ve
|
||||
1. User edits entities with `LevelObject` + reflectable components from `shared`.
|
||||
2. `EditorOnly` entities (cameras, helpers) are filtered from hierarchy and save; visualizer proxies can be picked but resolve back to source entities.
|
||||
3. Player placement is stored as `PlayerSpawn`; the runtime `Player` is never serialized.
|
||||
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`.
|
||||
5. Hydration systems in `game` spawn meshes, colliders, lights, and static mesh renderer parts at runtime. `ActorId` and `ComponentInstanceId` are the persisted identities; raw Bevy `Entity` IDs are runtime-only.
|
||||
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through the Bevy-free `scene::document::SceneDocument` seam. The document layer preserves the current RON storage while exposing stable actor/component document concepts and patch vocabulary for tools.
|
||||
5. Hydration systems in `game` spawn meshes, colliders, lights, and static mesh renderer parts at runtime. `ActorId` and `ComponentInstanceId` are the persisted identities; raw Bevy `Entity` IDs are runtime-only and must not become tool-facing document IDs.
|
||||
|
||||
## Model import (glTF + FBX)
|
||||
|
||||
@ -177,7 +182,7 @@ Shared scene schema lives in `crates/scene` (stamp/migrate/validate on save, loa
|
||||
|
||||
## HDR / swapchain invariant
|
||||
|
||||
The unified viewport renders through an HDR offscreen target (`render_target.rs`). When the panel target is missing (egui reflow) or settings disable HDR, cameras must strip atmosphere/post-FX before targeting the swapchain. `render_view::sync_project_render_view` runs in **PostUpdate** after RTT target assignment and reapplies the project FX stack whenever the active panel target handle changes (startup, resize), not only on settings Apply. Project Settings uses a **draft buffer**; **Apply** commits after render targets resync.
|
||||
The unified viewport renders through an HDR offscreen target (`render_target.rs`). When the panel target is missing (egui reflow), cameras must strip atmosphere/post-FX before targeting the swapchain. User-authored HDR may be disabled, but atmosphere and effective Solari still force `Hdr` on the active viewport camera because Bevy sky/Solari pipelines are not safe to carry across SDR targets. `render_view::sync_project_render_view` runs in **PostUpdate** after RTT target assignment and reapplies the project FX stack whenever the active panel target handle changes (startup, resize), not only on settings Apply. Project Settings uses a **draft buffer**; **Apply** commits after render targets resync.
|
||||
|
||||
The workspace patches `bevy_render` under `third_party/bevy_render` so Linux `wgpu::SurfaceError::Timeout` during swapchain texture acquisition logs and skips the frame instead of panicking in `prepare_windows`. Keep this patch scoped to transient surface acquire timeouts and re-evaluate it during Bevy upgrades.
|
||||
|
||||
|
||||
@ -58,9 +58,9 @@ Viewport overlays: **GI badge** (Forward / Solari), **volume HUD** when inside a
|
||||
|------|----------|
|
||||
| Auto | Startup default: Solari when RT GPU support is available; Forward otherwise |
|
||||
| Forward | Bevy Forward PBR with HDR/TAA/SSAO/atmosphere/shadows |
|
||||
| Solari | Request Solari; the camera attaches Bevy Solari when RT support is available and no local shadow-light fallback is required |
|
||||
| Solari | Request Solari; the camera attaches Bevy Solari when RT support is available |
|
||||
|
||||
Solari in Bevy 0.18 samples directional lights and emissive meshes. Point and spot lights require the normal PBR light pass. The viewport resolves Hybrid Auto through **`EffectiveRenderStack`**: requested Solari keeps project meshes eligible for raytracing and attaches Bevy Solari when RT support is available. Effective GI falls back to **Forward** only when RT is unavailable or local shadow lights are present. Effective Solari forces an HDR camera target even if project HDR is off, because Bevy Solari writes to the main texture through a storage binding and sRGB textures are invalid for that use. Primitive hydration generates tangents so built-in boxes/spheres can enter Solari BLAS/TLAS; imported/custom meshes still need TriangleList geometry, POSITION/NORMAL/UV_0/TANGENT attributes, and U32 indices. The Rendering panel reports the exact fallback reason, Solari-compatible asset counts, bind-group state, and compatible light counters. Set `BEVY_FPS_FORCE_SOLARI=1` to force RT feature probing in dev (see README troubleshooting).
|
||||
Solari in Bevy 0.18 samples directional lights and emissive meshes. Point and spot lights require the normal PBR light pass, so `LightDesc` point/spot components are runtime-disabled while Solari is active. The component remains in the scene, the inspector shows an unsupported message, and hydration strips/skips the Bevy `PointLight` / `SpotLight` until authors switch the light to Directional or switch GI to Forward. The viewport resolves Hybrid Auto through **`EffectiveRenderStack`**: requested Solari keeps project meshes eligible for raytracing and attaches Bevy Solari when RT support is available. Effective GI falls back to **Forward** only when RT is unavailable. Effective Solari forces an HDR camera target even if project HDR is off, because Bevy Solari writes to the main texture through a storage binding and sRGB textures are invalid for that use. Primitive hydration generates tangents so built-in boxes/spheres can enter Solari BLAS/TLAS; imported/custom meshes still need TriangleList geometry, POSITION/NORMAL/UV_0/TANGENT attributes, and U32 indices. The Rendering panel reports the exact fallback reason, Solari-compatible asset counts, bind-group state, and compatible light counters. Set `BEVY_FPS_FORCE_SOLARI=1` to force RT feature probing in dev (see README troubleshooting).
|
||||
|
||||
## Emissive lighting
|
||||
|
||||
@ -94,12 +94,12 @@ Open `assets/levels/rendering_showcase.scn.ron` for fog, exposure, and vignette
|
||||
| Symptom | Check |
|
||||
|---------|-------|
|
||||
| Point/spot weak at max lumens | Outdoor exposure (~12–15 EV100 in Project Settings) needs much higher lumens than indoor (~7 EV100). Bevy’s reference scale is ~1M lumens (`VERY_LARGE_CINEMA_LIGHT`); inspector allows up to 2M. Narrow spot cones for brighter hotspots. For local lights, GI path must be **Forward** (Solari uses directional + emissive only). |
|
||||
| Point/spot shadows missing under GiMode Auto | Viewport auto-forwards to Forward when shadow-casting point/spot lights exist; check Rendering → Active Camera for “local shadow lights” |
|
||||
| Point/spot controls disabled under GiMode Auto | Auto selected Solari on this GPU. Point/spot `LightDesc` is disabled while Solari is active; switch the light to Directional, use emissive materials, or set GI to Forward. |
|
||||
| Scene sun shadow range wrong vs Project Settings | Scene directionals use `cascade_config_from_rendering`; Apply shadow cascade settings and confirm `sync_scene_directional_shadow_cascades` |
|
||||
| Auto exposure not active | **Rendering → Active Camera**: needs **Exposure: Auto**, HDR on, and GPU compute. Volume **Exposure EV100** override forces manual for that view. |
|
||||
| Viewport black in Lit | Rendering panel → **Runtime lights**, **Stack**, and **Solari raytracing scene**; Collider mode hides meshes |
|
||||
| Solari warming up | Solari raytracing scene readiness: tagged meshes, Solari-compatible mesh assets, render instances, bind group, compatible lights |
|
||||
| Lighting changes do nothing in Solari | Solari samples directional lights and emissive meshes; point/spot lights are Forward-only |
|
||||
| Lighting changes do nothing in Solari | Solari samples directional lights and emissive meshes; point/spot `LightDesc` is disabled while Solari is active |
|
||||
| GI badge shows Forward unexpectedly | Solari RT wgpu features unavailable; Rendering → Active Camera tab |
|
||||
| Volume has no effect | Camera inside AABB? Priority vs other volumes? Overrides enabled? |
|
||||
| Custom FX missing | RON path in volume matches `assets/post_fx/`; shader path in RON |
|
||||
|
||||
@ -69,7 +69,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
|
||||
| Rendering panel (Active / Project / Volumes) | Done | Replaces Diagnostics-only view |
|
||||
| Volume inspector inherit/override | Done | `post_process_volume_ui.rs` |
|
||||
| Viewport GI badge + volume HUD + gizmos | Done | `viewport_chrome`, `visualizers` |
|
||||
| Solari + forward fallback | Done | `bevy_solari`, `RenderingCapabilities` |
|
||||
| Solari + Forward fallback | Done | `bevy_solari`, RT fallback, point/spot LightDesc disabled while Solari is active |
|
||||
| Post FX + rendering profile assets | Done | `assets/post_fx/`, `assets/rendering_profiles/` |
|
||||
| Showcase level + docs | Done | `rendering_showcase.scn.ron`, [rendering.md](rendering.md) |
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user