//! Selectable editor-only billboards at actor roots. use std::collections::{HashMap, HashSet}; use bevy::asset::{load_internal_binary_asset, uuid_handle, RenderAssetUsages}; use bevy::camera::visibility::NoFrustumCulling; use bevy::image::{CompressedImageFormats, ImageSampler, ImageType}; use bevy::prelude::*; use shared::{ ActorKind, AuthoringLightKind, ColliderDesc, LevelObject, LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, RaytracingExcluded, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, }; use crate::camera::EditorCamera; use crate::infra::EditorOnly; use crate::state::scene_tools_active; use crate::ui::UiState; use crate::viewport::ViewportDisplayMode; use crate::visualizers::EditorVisualizationSettings; const ICON_TARGET_PIXELS: f32 = 30.0; const ICON_MIN_WORLD_SIZE: f32 = 0.2; const ICON_MAX_WORLD_SIZE: f32 = 3.0; const DEFAULT_PERSPECTIVE_FOV: f32 = std::f32::consts::FRAC_PI_4; const ICON_01_ACTOR_EMPTY: Handle = uuid_handle!("d87c7796-b219-4011-8305-4db55422f1c7"); const ICON_02_ACTOR_GENERIC: Handle = uuid_handle!("c9a1a84f-4dcd-4a2b-a5b6-c365dc7d6795"); const ICON_03_ACTOR_MISSING: Handle = uuid_handle!("cca7cdf9-0c7d-4546-9bb9-1176e49a635c"); const ICON_04_ACTOR_WARNING: Handle = uuid_handle!("7dba1d9c-0569-40d2-978e-66014377051d"); const ICON_05_RENDER_MESH: Handle = uuid_handle!("10c70a1d-3327-458a-92d2-db41f893432e"); const ICON_06_RENDER_DECAL: Handle = uuid_handle!("8c27ff62-0d94-4184-81cf-977e652a0496"); const ICON_07_RENDER_PARTICLE: Handle = uuid_handle!("0db519d6-0eca-4389-9393-2e0c38f73119"); const ICON_08_RENDER_BILLBOARD: Handle = uuid_handle!("2fcb8e83-8f51-4978-8b29-146e2c44b5e6"); const ICON_09_LIGHT_POINT: Handle = uuid_handle!("ba2cbf62-c547-4cfe-ba61-4081076da93b"); const ICON_10_LIGHT_SPOT: Handle = uuid_handle!("200ee881-9cac-41d1-89be-45581642968f"); const ICON_11_LIGHT_DIRECTIONAL: Handle = uuid_handle!("d9de4730-2463-41dc-87e9-ed99b013d74a"); const ICON_12_LIGHT_AREA: Handle = uuid_handle!("d43ea6a8-15da-4792-8d63-962211f94ad6"); const ICON_13_CAMERA_MAIN: Handle = uuid_handle!("1979cc52-7285-4bb3-88f5-bd82021c10fa"); const ICON_14_CAMERA_CAPTURE: Handle = uuid_handle!("f1fc47d3-eaf4-4aaf-becc-b51a64f0b070"); const ICON_15_AUDIO_SOURCE: Handle = uuid_handle!("2dd3091f-2dea-4626-8430-79fa44a53c68"); const ICON_16_AUDIO_LISTENER: Handle = uuid_handle!("9eb3177f-e3d5-45cb-bd7c-49eecb4eb8e3"); const ICON_17_PHYSICS_COLLIDER: Handle = uuid_handle!("69f02ada-e171-484b-9a46-af753971e9c1"); const ICON_18_PHYSICS_RIGIDBODY: Handle = uuid_handle!("c30ddfb8-a851-45ab-98a9-97add45f1b49"); const ICON_19_PHYSICS_TRIGGER: Handle = uuid_handle!("3cf22740-463a-41b0-a1d7-d0cccc910b70"); const ICON_20_PHYSICS_JOINT: Handle = uuid_handle!("cdb8ef15-d41c-4f34-bc98-4a61f4628508"); const ICON_21_GAME_SPAWN: Handle = uuid_handle!("56719033-f1db-4749-8637-9b004581ce81"); const ICON_22_GAME_TRIGGER: Handle = uuid_handle!("98e42034-1c34-40af-8955-12c12cc2e261"); const ICON_23_GAME_MARKER: Handle = uuid_handle!("7808784d-c117-41a9-84db-fae5d4edbf3a"); const ICON_24_GAME_INTERACTABLE: Handle = uuid_handle!("e61fc53a-5201-49a4-9451-88d7c804a5c5"); const ICON_25_NAV_WAYPOINT: Handle = uuid_handle!("26511234-47bc-4cdc-b24c-39bfec7e46da"); const ICON_26_NAV_LINK: Handle = uuid_handle!("6f4988e9-b571-45af-a3ad-777d80a3d9b6"); const ICON_27_NAV_REGION: Handle = uuid_handle!("02b7ae0d-b3d6-4e46-8be0-8ee590ea71cc"); const ICON_28_VOLUME_GENERIC: Handle = uuid_handle!("670cf8ce-c33e-4f86-ae7e-0311c8917629"); const ICON_29_VOLUME_POST_PROCESS: Handle = uuid_handle!("bc9064bd-b6a8-4b00-95d4-3763325bceb9"); const ICON_30_ENVIRONMENT_WIND: Handle = uuid_handle!("0d4220a6-7abc-42bc-a0d2-b69847061a92"); const ICON_31_ENVIRONMENT_SKY: Handle = uuid_handle!("e4f09ad3-380d-4086-9eb4-e60e69ec1765"); const ICON_32_DEBUG_NOTE: Handle = uuid_handle!("d093e657-6598-4097-8a57-9d445e3a80d7"); #[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ActorIconProxy { pub target: Entity, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum ActorIconImage { Empty, Generic, Missing, Warning, Mesh, LightPoint, LightSpot, LightDirectional, PhysicsCollider, GameSpawn, GameTrigger, GameMarker, VolumeGeneric, PostProcessVolume, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum ActorIconCategory { Actor, Mesh, Light, Physics, Gameplay, Volume, PrefabModel, Debug, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ActorIconSpec { image: ActorIconImage, category: ActorIconCategory, } #[derive(Default, Debug, Clone, Copy)] struct ActorIconComponents { kind: Option, light_kind: Option, has_primitive: bool, has_static_mesh_renderer: bool, has_model_ref: bool, has_prefab_ref: bool, has_prefab_instance: bool, has_player_spawn: bool, has_weapon_spawn: bool, has_trigger_volume: bool, has_post_process_volume: bool, has_team_spawn: bool, has_objective: bool, has_physics: bool, } #[derive(Resource)] struct ActorIconAssets { mesh: Handle, materials: HashMap>, } pub struct ActorIconsPlugin; impl Plugin for ActorIconsPlugin { fn build(&self, app: &mut App) { register_actor_icon_images(app); app.add_systems(Startup, init_actor_icon_assets) .add_systems(Update, sync_actor_icons.run_if(scene_tools_active)); } } fn icon_image_from_png(bytes: &[u8], path: String) -> Image { Image::from_buffer( bytes, ImageType::Extension("png"), CompressedImageFormats::NONE, true, ImageSampler::linear(), RenderAssetUsages::default(), ) .unwrap_or_else(|err| panic!("failed to load editor actor icon {path}: {err}")) } macro_rules! load_actor_icon { ($app:ident, $handle:expr, $path:literal) => { load_internal_binary_asset!( $app, $handle, concat!("../../assets/icons/", $path), icon_image_from_png ); }; } fn register_actor_icon_images(app: &mut App) { load_actor_icon!(app, ICON_01_ACTOR_EMPTY, "01_actor_empty.png"); load_actor_icon!(app, ICON_02_ACTOR_GENERIC, "02_actor_generic.png"); load_actor_icon!(app, ICON_03_ACTOR_MISSING, "03_actor_missing.png"); load_actor_icon!(app, ICON_04_ACTOR_WARNING, "04_actor_warning.png"); load_actor_icon!(app, ICON_05_RENDER_MESH, "05_render_mesh.png"); load_actor_icon!(app, ICON_06_RENDER_DECAL, "06_render_decal.png"); load_actor_icon!(app, ICON_07_RENDER_PARTICLE, "07_render_particle.png"); load_actor_icon!(app, ICON_08_RENDER_BILLBOARD, "08_render_billboard.png"); load_actor_icon!(app, ICON_09_LIGHT_POINT, "09_light_point.png"); load_actor_icon!(app, ICON_10_LIGHT_SPOT, "10_light_spot.png"); load_actor_icon!(app, ICON_11_LIGHT_DIRECTIONAL, "11_light_directional.png"); load_actor_icon!(app, ICON_12_LIGHT_AREA, "12_light_area.png"); load_actor_icon!(app, ICON_13_CAMERA_MAIN, "13_camera_main.png"); load_actor_icon!(app, ICON_14_CAMERA_CAPTURE, "14_camera_capture.png"); load_actor_icon!(app, ICON_15_AUDIO_SOURCE, "15_audio_source.png"); load_actor_icon!(app, ICON_16_AUDIO_LISTENER, "16_audio_listener.png"); load_actor_icon!(app, ICON_17_PHYSICS_COLLIDER, "17_physics_collider.png"); load_actor_icon!(app, ICON_18_PHYSICS_RIGIDBODY, "18_physics_rigidbody.png"); load_actor_icon!(app, ICON_19_PHYSICS_TRIGGER, "19_physics_trigger.png"); load_actor_icon!(app, ICON_20_PHYSICS_JOINT, "20_physics_joint.png"); load_actor_icon!(app, ICON_21_GAME_SPAWN, "21_game_spawn.png"); load_actor_icon!(app, ICON_22_GAME_TRIGGER, "22_game_trigger.png"); load_actor_icon!(app, ICON_23_GAME_MARKER, "23_game_marker.png"); load_actor_icon!(app, ICON_24_GAME_INTERACTABLE, "24_game_interactable.png"); load_actor_icon!(app, ICON_25_NAV_WAYPOINT, "25_nav_waypoint.png"); load_actor_icon!(app, ICON_26_NAV_LINK, "26_nav_link.png"); load_actor_icon!(app, ICON_27_NAV_REGION, "27_nav_region.png"); load_actor_icon!(app, ICON_28_VOLUME_GENERIC, "28_volume_generic.png"); load_actor_icon!( app, ICON_29_VOLUME_POST_PROCESS, "29_volume_post_process.png" ); load_actor_icon!(app, ICON_30_ENVIRONMENT_WIND, "30_environment_wind.png"); load_actor_icon!(app, ICON_31_ENVIRONMENT_SKY, "31_environment_sky.png"); load_actor_icon!(app, ICON_32_DEBUG_NOTE, "32_debug_note.png"); } fn init_actor_icon_assets( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, ) { let mesh = meshes.add(Rectangle::new(1.0, 1.0)); let mut actor_materials = HashMap::new(); for (image, texture) in [ (ActorIconImage::Empty, ICON_01_ACTOR_EMPTY), (ActorIconImage::Generic, ICON_02_ACTOR_GENERIC), (ActorIconImage::Missing, ICON_03_ACTOR_MISSING), (ActorIconImage::Warning, ICON_04_ACTOR_WARNING), (ActorIconImage::Mesh, ICON_05_RENDER_MESH), (ActorIconImage::LightPoint, ICON_09_LIGHT_POINT), (ActorIconImage::LightSpot, ICON_10_LIGHT_SPOT), (ActorIconImage::LightDirectional, ICON_11_LIGHT_DIRECTIONAL), (ActorIconImage::PhysicsCollider, ICON_17_PHYSICS_COLLIDER), (ActorIconImage::GameSpawn, ICON_21_GAME_SPAWN), (ActorIconImage::GameTrigger, ICON_22_GAME_TRIGGER), (ActorIconImage::GameMarker, ICON_23_GAME_MARKER), (ActorIconImage::VolumeGeneric, ICON_28_VOLUME_GENERIC), ( ActorIconImage::PostProcessVolume, ICON_29_VOLUME_POST_PROCESS, ), ] { actor_materials.insert( image, materials.add(StandardMaterial { base_color_texture: Some(texture.clone()), alpha_mode: AlphaMode::Blend, unlit: true, cull_mode: None, fog_enabled: false, depth_bias: -40.0, ..default() }), ); } commands.insert_resource(ActorIconAssets { mesh, materials: actor_materials, }); } #[allow(clippy::type_complexity, clippy::too_many_arguments)] fn sync_actor_icons( display: Res, settings: Res, ui_state: Res, assets: Res, mut commands: Commands, mut icons: Query<( Entity, &ActorIconProxy, &MeshMaterial3d, &mut Transform, )>, camera_query: Query<(&Camera, &GlobalTransform, Option<&Projection>), With>, actor_roots: Query< ( Entity, &GlobalTransform, Option<&ActorKind>, Option<&LightDesc>, Has, Option<&StaticMeshRenderer>, Has, Has, Has, Has, Has, Has, Has, Has, Has, ), (With, Without), >, physics: Query<(), Or<(With, With)>>, ) { let camera = active_editor_camera(&camera_query); let mut desired = HashMap::new(); if !display.clean_game_view && settings.enabled && settings.show_actor_icons { for ( target, global, kind, light, has_primitive, static_mesh_renderer, has_model_ref, has_prefab_ref, has_prefab_instance, has_player_spawn, has_weapon_spawn, has_trigger_volume, has_post_process_volume, has_team_spawn, has_objective, ) in &actor_roots { let components = ActorIconComponents { kind: kind.copied(), light_kind: light.map(|light| light.kind), has_primitive, has_static_mesh_renderer: static_mesh_renderer .is_some_and(has_resolved_static_mesh_slot), has_model_ref, has_prefab_ref, has_prefab_instance, has_player_spawn, has_weapon_spawn, has_trigger_volume, has_post_process_volume, has_team_spawn, has_objective, has_physics: physics.contains(target), }; let spec = icon_for_actor_components(components); if !icon_category_enabled(&settings, spec.category) { continue; } let transform = desired_icon_transform(global, camera, ui_state.viewport_rect.height()); desired.insert(target, (spec.image, transform)); } } let mut existing_targets = HashSet::new(); for (entity, proxy, material, mut transform) in &mut icons { let Some((image, desired_transform)) = desired.get(&proxy.target) else { commands.entity(entity).despawn(); continue; }; let expected_material = assets.materials.get(image); if expected_material.is_none_or(|expected| material.0 != *expected) { commands.entity(entity).despawn(); continue; } existing_targets.insert(proxy.target); *transform = *desired_transform; } for ( target, global, kind, light, has_primitive, static_mesh_renderer, has_model_ref, has_prefab_ref, has_prefab_instance, has_player_spawn, has_weapon_spawn, has_trigger_volume, has_post_process_volume, has_team_spawn, has_objective, ) in &actor_roots { if existing_targets.contains(&target) { continue; } let components = ActorIconComponents { kind: kind.copied(), light_kind: light.map(|light| light.kind), has_primitive, has_static_mesh_renderer: static_mesh_renderer .is_some_and(has_resolved_static_mesh_slot), has_model_ref, has_prefab_ref, has_prefab_instance, has_player_spawn, has_weapon_spawn, has_trigger_volume, has_post_process_volume, has_team_spawn, has_objective, has_physics: physics.contains(target), }; let spec = icon_for_actor_components(components); if !desired.contains_key(&target) { continue; } let Some(material) = assets.materials.get(&spec.image).cloned() else { continue; }; let transform = desired_icon_transform(global, camera, ui_state.viewport_rect.height()); commands.spawn(( EditorOnly, RaytracingExcluded, NoFrustumCulling, Name::new("Actor Root Icon"), ActorIconProxy { target }, Mesh3d(assets.mesh.clone()), MeshMaterial3d(material), transform, Visibility::Visible, ChildOf(target), )); } } fn active_editor_camera<'a>( camera_query: &'a Query<(&Camera, &GlobalTransform, Option<&Projection>), With>, ) -> Option<(&'a Camera, &'a GlobalTransform, Option<&'a Projection>)> { camera_query .iter() .find(|(camera, _, _)| camera.is_active) .or_else(|| camera_query.iter().next()) } fn desired_icon_transform( parent_global: &GlobalTransform, camera: Option<(&Camera, &GlobalTransform, Option<&Projection>)>, viewport_height: f32, ) -> Transform { let translation = parent_global.translation(); let Some((_, camera_global, projection)) = camera else { return Transform::from_translation(Vec3::ZERO).with_scale(Vec3::splat(0.5)); }; let size = icon_world_size( translation, camera_global, projection, viewport_height.max(1.0), ); let desired_global = Transform { translation, rotation: camera_global.rotation(), scale: Vec3::splat(size), }; let local = parent_global.affine().inverse() * GlobalTransform::from(desired_global).affine(); Transform::from_matrix(Mat4::from(local)) } fn icon_world_size( target: Vec3, camera_global: &GlobalTransform, projection: Option<&Projection>, viewport_height: f32, ) -> f32 { let camera_position = camera_global.translation(); let pixels_to_view = ICON_TARGET_PIXELS / viewport_height; let world_height = match projection { Some(Projection::Perspective(projection)) => { let distance = camera_position.distance(target).max(0.1); 2.0 * distance * (projection.fov * 0.5).tan() } Some(Projection::Orthographic(projection)) => projection.area.height().abs().max(0.1), _ => { let distance = camera_position.distance(target).max(0.1); 2.0 * distance * (DEFAULT_PERSPECTIVE_FOV * 0.5).tan() } }; (world_height * pixels_to_view).clamp(ICON_MIN_WORLD_SIZE, ICON_MAX_WORLD_SIZE) } fn icon_category_enabled( settings: &EditorVisualizationSettings, category: ActorIconCategory, ) -> bool { match category { ActorIconCategory::Actor => settings.show_actor_icon_actor, ActorIconCategory::Mesh => settings.show_actor_icon_mesh, ActorIconCategory::Light => settings.show_actor_icon_light, ActorIconCategory::Physics => settings.show_actor_icon_physics, ActorIconCategory::Gameplay => settings.show_actor_icon_gameplay, ActorIconCategory::Volume => settings.show_actor_icon_volume, ActorIconCategory::PrefabModel => settings.show_actor_icon_prefab_model, ActorIconCategory::Debug => settings.show_actor_icon_debug, } } fn has_resolved_static_mesh_slot(renderer: &StaticMeshRenderer) -> bool { renderer.slots.iter().any(|slot| slot.mesh.is_resolved()) } fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec { let warning = ActorIconSpec { image: ActorIconImage::Warning, category: ActorIconCategory::Debug, }; let Some(kind) = components.kind else { return ActorIconSpec { image: ActorIconImage::Missing, category: ActorIconCategory::Debug, }; }; match kind { ActorKind::StaticMesh => { if components.has_primitive || components.has_static_mesh_renderer { ActorIconSpec { image: ActorIconImage::Mesh, category: ActorIconCategory::Mesh, } } else { warning } } ActorKind::ImportedModel => { if components.has_model_ref { ActorIconSpec { image: ActorIconImage::Mesh, category: ActorIconCategory::PrefabModel, } } else { warning } } ActorKind::Light => match components.light_kind { Some(AuthoringLightKind::Point) => ActorIconSpec { image: ActorIconImage::LightPoint, category: ActorIconCategory::Light, }, Some(AuthoringLightKind::Spot) => ActorIconSpec { image: ActorIconImage::LightSpot, category: ActorIconCategory::Light, }, Some(AuthoringLightKind::Directional) => ActorIconSpec { image: ActorIconImage::LightDirectional, category: ActorIconCategory::Light, }, None => warning, }, ActorKind::PrefabAnchor => { if components.has_prefab_ref || components.has_prefab_instance { ActorIconSpec { image: ActorIconImage::Generic, category: ActorIconCategory::PrefabModel, } } else { warning } } ActorKind::PlayerSpawn => { if components.has_player_spawn { ActorIconSpec { image: ActorIconImage::GameSpawn, category: ActorIconCategory::Gameplay, } } else { warning } } ActorKind::WeaponSpawn => { if components.has_weapon_spawn { ActorIconSpec { image: ActorIconImage::GameSpawn, category: ActorIconCategory::Gameplay, } } else { warning } } ActorKind::TriggerVolume => { if components.has_trigger_volume { ActorIconSpec { image: ActorIconImage::GameTrigger, category: ActorIconCategory::Gameplay, } } else { warning } } ActorKind::PostProcessVolume => { if components.has_post_process_volume { ActorIconSpec { image: ActorIconImage::PostProcessVolume, category: ActorIconCategory::Volume, } } else { warning } } ActorKind::TeamSpawn => { if components.has_team_spawn { ActorIconSpec { image: ActorIconImage::GameSpawn, category: ActorIconCategory::Gameplay, } } else { warning } } ActorKind::Objective => { if components.has_objective { ActorIconSpec { image: ActorIconImage::GameMarker, category: ActorIconCategory::Gameplay, } } else { warning } } ActorKind::Empty => { if components.has_physics { ActorIconSpec { image: ActorIconImage::PhysicsCollider, category: ActorIconCategory::Physics, } } else { ActorIconSpec { image: ActorIconImage::Empty, category: ActorIconCategory::Actor, } } } } } #[cfg(test)] mod tests { use super::*; #[test] fn resolves_directional_light_icon() { let spec = icon_for_actor_components(ActorIconComponents { kind: Some(ActorKind::Light), light_kind: Some(AuthoringLightKind::Directional), ..default() }); assert_eq!(spec.image, ActorIconImage::LightDirectional); assert_eq!(spec.category, ActorIconCategory::Light); } #[test] fn unresolved_static_mesh_uses_warning_icon() { let spec = icon_for_actor_components(ActorIconComponents { kind: Some(ActorKind::StaticMesh), ..default() }); assert_eq!(spec.image, ActorIconImage::Warning); assert_eq!(spec.category, ActorIconCategory::Debug); } #[test] fn empty_actor_with_collider_uses_physics_category() { let spec = icon_for_actor_components(ActorIconComponents { kind: Some(ActorKind::Empty), has_physics: true, ..default() }); assert_eq!(spec.image, ActorIconImage::PhysicsCollider); assert_eq!(spec.category, ActorIconCategory::Physics); } }