Blacksite/crates/editor/src/viewport/actor_icons.rs
Rbanh ed21bcc52d
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add chunked terrain authoring foundation
2026-07-12 18:43:42 -04:00

896 lines
31 KiB
Rust

//! Selectable editor-only billboards at actor roots.
use std::collections::{HashMap, HashSet};
use bevy::asset::{
load_internal_asset, load_internal_binary_asset, uuid_handle, RenderAssetUsages,
};
use bevy::camera::visibility::NoFrustumCulling;
use bevy::image::{CompressedImageFormats, ImageSampler, ImageType};
use bevy::pbr::{Material, MaterialPlugin};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, BlendState, CompareFunction, RenderPipelineDescriptor, ShaderType,
};
use bevy::shader::ShaderRef;
use shared::{
ActorKind, AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, LevelObject,
LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc,
PrefabInstance, PrefabRef, Primitive, RaytracingExcluded, SkinnedMeshRenderer,
StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
};
use crate::camera::EditorCamera;
use crate::infra::EditorOnly;
use crate::state::{EditorMode, PlayPossession};
use crate::ui::UiState;
use crate::viewport::ViewportDisplayMode;
use crate::visualizers::EditorVisualizationSettings;
const ICON_MIN_WORLD_SIZE: f32 = 0.001;
const ICON_MAX_WORLD_SIZE: f32 = 10_000.0;
const ICON_BRIGHTNESS: f32 = 1.6;
const DEFAULT_PERSPECTIVE_FOV: f32 = std::f32::consts::FRAC_PI_4;
const ACTOR_ICON_OVERLAY_SHADER: Handle<Shader> =
uuid_handle!("2edfe544-3ed1-4e13-a880-f56b09804b52");
const ICON_01_ACTOR_EMPTY: Handle<Image> = uuid_handle!("d87c7796-b219-4011-8305-4db55422f1c7");
const ICON_02_ACTOR_GENERIC: Handle<Image> = uuid_handle!("c9a1a84f-4dcd-4a2b-a5b6-c365dc7d6795");
const ICON_03_ACTOR_MISSING: Handle<Image> = uuid_handle!("cca7cdf9-0c7d-4546-9bb9-1176e49a635c");
const ICON_04_ACTOR_WARNING: Handle<Image> = uuid_handle!("7dba1d9c-0569-40d2-978e-66014377051d");
const ICON_05_RENDER_MESH: Handle<Image> = uuid_handle!("10c70a1d-3327-458a-92d2-db41f893432e");
const ICON_06_RENDER_DECAL: Handle<Image> = uuid_handle!("8c27ff62-0d94-4184-81cf-977e652a0496");
const ICON_07_RENDER_PARTICLE: Handle<Image> = uuid_handle!("0db519d6-0eca-4389-9393-2e0c38f73119");
const ICON_08_RENDER_BILLBOARD: Handle<Image> =
uuid_handle!("2fcb8e83-8f51-4978-8b29-146e2c44b5e6");
const ICON_09_LIGHT_POINT: Handle<Image> = uuid_handle!("ba2cbf62-c547-4cfe-ba61-4081076da93b");
const ICON_10_LIGHT_SPOT: Handle<Image> = uuid_handle!("200ee881-9cac-41d1-89be-45581642968f");
const ICON_11_LIGHT_DIRECTIONAL: Handle<Image> =
uuid_handle!("d9de4730-2463-41dc-87e9-ed99b013d74a");
const ICON_12_LIGHT_AREA: Handle<Image> = uuid_handle!("d43ea6a8-15da-4792-8d63-962211f94ad6");
const ICON_13_CAMERA_MAIN: Handle<Image> = uuid_handle!("1979cc52-7285-4bb3-88f5-bd82021c10fa");
const ICON_14_CAMERA_CAPTURE: Handle<Image> = uuid_handle!("f1fc47d3-eaf4-4aaf-becc-b51a64f0b070");
const ICON_15_AUDIO_SOURCE: Handle<Image> = uuid_handle!("2dd3091f-2dea-4626-8430-79fa44a53c68");
const ICON_16_AUDIO_LISTENER: Handle<Image> = uuid_handle!("9eb3177f-e3d5-45cb-bd7c-49eecb4eb8e3");
const ICON_17_PHYSICS_COLLIDER: Handle<Image> =
uuid_handle!("69f02ada-e171-484b-9a46-af753971e9c1");
const ICON_18_PHYSICS_RIGIDBODY: Handle<Image> =
uuid_handle!("c30ddfb8-a851-45ab-98a9-97add45f1b49");
const ICON_19_PHYSICS_TRIGGER: Handle<Image> = uuid_handle!("3cf22740-463a-41b0-a1d7-d0cccc910b70");
const ICON_20_PHYSICS_JOINT: Handle<Image> = uuid_handle!("cdb8ef15-d41c-4f34-bc98-4a61f4628508");
const ICON_21_GAME_SPAWN: Handle<Image> = uuid_handle!("56719033-f1db-4749-8637-9b004581ce81");
const ICON_22_GAME_TRIGGER: Handle<Image> = uuid_handle!("98e42034-1c34-40af-8955-12c12cc2e261");
const ICON_23_GAME_MARKER: Handle<Image> = uuid_handle!("7808784d-c117-41a9-84db-fae5d4edbf3a");
const ICON_24_GAME_INTERACTABLE: Handle<Image> =
uuid_handle!("e61fc53a-5201-49a4-9451-88d7c804a5c5");
const ICON_25_NAV_WAYPOINT: Handle<Image> = uuid_handle!("26511234-47bc-4cdc-b24c-39bfec7e46da");
const ICON_26_NAV_LINK: Handle<Image> = uuid_handle!("6f4988e9-b571-45af-a3ad-777d80a3d9b6");
const ICON_27_NAV_REGION: Handle<Image> = uuid_handle!("02b7ae0d-b3d6-4e46-8be0-8ee590ea71cc");
const ICON_28_VOLUME_GENERIC: Handle<Image> = uuid_handle!("670cf8ce-c33e-4f86-ae7e-0311c8917629");
const ICON_29_VOLUME_POST_PROCESS: Handle<Image> =
uuid_handle!("bc9064bd-b6a8-4b00-95d4-3763325bceb9");
const ICON_30_ENVIRONMENT_WIND: Handle<Image> =
uuid_handle!("0d4220a6-7abc-42bc-a0d2-b69847061a92");
const ICON_31_ENVIRONMENT_SKY: Handle<Image> = uuid_handle!("e4f09ad3-380d-4086-9eb4-e60e69ec1765");
const ICON_32_DEBUG_NOTE: Handle<Image> = uuid_handle!("d093e657-6598-4097-8a57-9d445e3a80d7");
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ActorIconProxy {
pub target: Entity,
}
#[derive(Resource, Debug, Clone)]
pub struct ActorIconSettings {
pub size_pixels: f32,
}
impl ActorIconSettings {
pub const DEFAULT_SIZE_PIXELS: f32 = 34.0;
pub const MIN_SIZE_PIXELS: f32 = 16.0;
pub const MAX_SIZE_PIXELS: f32 = 96.0;
pub fn clamped_size_pixels(&self) -> f32 {
self.size_pixels
.clamp(Self::MIN_SIZE_PIXELS, Self::MAX_SIZE_PIXELS)
}
}
impl Default for ActorIconSettings {
fn default() -> Self {
Self {
size_pixels: Self::DEFAULT_SIZE_PIXELS,
}
}
}
#[derive(Clone, Copy, ShaderType)]
struct ActorIconOverlayUniform {
brightness: f32,
}
#[derive(Asset, TypePath, AsBindGroup, Clone)]
struct ActorIconOverlayMaterial {
#[texture(0)]
#[sampler(1)]
texture: Handle<Image>,
#[uniform(2)]
uniform: ActorIconOverlayUniform,
}
impl Material for ActorIconOverlayMaterial {
fn fragment_shader() -> ShaderRef {
ACTOR_ICON_OVERLAY_SHADER.into()
}
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Blend
}
fn enable_prepass() -> bool {
false
}
fn enable_shadows() -> bool {
false
}
fn specialize(
_pipeline: &bevy::pbr::MaterialPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &bevy::mesh::MeshVertexBufferLayoutRef,
_key: bevy::pbr::MaterialPipelineKey<Self>,
) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
descriptor.primitive.cull_mode = None;
if let Some(depth) = &mut descriptor.depth_stencil {
depth.depth_write_enabled = Some(false);
depth.depth_compare = Some(CompareFunction::Always);
}
if let Some(fragment) = &mut descriptor.fragment {
if let Some(target) = fragment
.targets
.first_mut()
.and_then(|target| target.as_mut())
{
target.blend = Some(BlendState::ALPHA_BLENDING);
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ActorIconImage {
Empty,
Generic,
Missing,
Warning,
Mesh,
LightPoint,
LightSpot,
LightDirectional,
AudioSource,
AudioListener,
PhysicsCollider,
GameSpawn,
GameTrigger,
GameMarker,
VolumeGeneric,
PostProcessVolume,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ActorIconCategory {
Actor,
Mesh,
Light,
Audio,
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<ActorKind>,
light_kind: Option<AuthoringLightKind>,
has_primitive: bool,
has_static_mesh_renderer: bool,
has_skinned_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_audio_source: bool,
has_audio_listener: bool,
has_physics: bool,
}
#[derive(Resource)]
struct ActorIconAssets {
mesh: Handle<Mesh>,
materials: HashMap<ActorIconImage, Handle<ActorIconOverlayMaterial>>,
}
pub struct ActorIconsPlugin;
impl Plugin for ActorIconsPlugin {
fn build(&self, app: &mut App) {
register_actor_icon_images(app);
load_internal_asset!(
app,
ACTOR_ICON_OVERLAY_SHADER,
"../../assets/shaders/actor_icon_overlay.wgsl",
Shader::from_wgsl
);
app.init_resource::<ActorIconSettings>()
.add_plugins(MaterialPlugin::<ActorIconOverlayMaterial>::default())
.add_systems(Startup, init_actor_icon_assets)
.add_systems(Update, sync_actor_icons);
}
}
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<Assets<Mesh>>,
mut materials: ResMut<Assets<ActorIconOverlayMaterial>>,
) {
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::AudioSource, ICON_15_AUDIO_SOURCE),
(ActorIconImage::AudioListener, ICON_16_AUDIO_LISTENER),
(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(ActorIconOverlayMaterial {
texture: texture.clone(),
uniform: ActorIconOverlayUniform {
brightness: ICON_BRIGHTNESS,
},
}),
);
}
commands.insert_resource(ActorIconAssets {
mesh,
materials: actor_materials,
});
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn sync_actor_icons(
mode: Res<State<EditorMode>>,
possession: Res<PlayPossession>,
display: Res<ViewportDisplayMode>,
settings: Res<EditorVisualizationSettings>,
icon_settings: Res<ActorIconSettings>,
ui_state: Res<UiState>,
assets: Res<ActorIconAssets>,
mut commands: Commands,
mut icons: Query<(
Entity,
&ActorIconProxy,
&MeshMaterial3d<ActorIconOverlayMaterial>,
&mut Transform,
)>,
camera_query: Query<(&Camera, &GlobalTransform, Option<&Projection>), With<EditorCamera>>,
actor_roots: Query<
(
Entity,
&GlobalTransform,
Option<&ActorKind>,
Option<&LightDesc>,
Has<Primitive>,
Option<&StaticMeshRenderer>,
Has<ModelRef>,
Has<PrefabRef>,
Has<PrefabInstance>,
Has<PlayerSpawn>,
Has<WeaponSpawn>,
Has<TriggerVolume>,
Has<PostProcessVolumeDesc>,
Has<TeamSpawn>,
Has<ObjectiveMarker>,
),
(With<LevelObject>, Without<EditorOnly>),
>,
skinned_meshes: Query<(), With<SkinnedMeshRenderer>>,
audio: Query<(Has<AudioSourceDesc>, Has<AudioListenerDesc>)>,
physics: Query<(), Or<(With<PhysicsBody>, With<ColliderDesc>)>>,
) {
let camera = active_editor_camera(&camera_query);
let mut desired = HashMap::new();
let tools_active = *mode.get() == EditorMode::Editing
|| (*mode.get() == EditorMode::Playing && *possession == PlayPossession::Ejected);
if tools_active && !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 (has_audio_source, has_audio_listener) = audio.get(target).unwrap_or_default();
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_skinned_mesh_renderer: skinned_meshes.contains(target),
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_audio_source,
has_audio_listener,
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(),
icon_settings.clamped_size_pixels(),
);
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 (has_audio_source, has_audio_listener) = audio.get(target).unwrap_or_default();
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_skinned_mesh_renderer: skinned_meshes.contains(target),
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_audio_source,
has_audio_listener,
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(),
icon_settings.clamped_size_pixels(),
);
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<EditorCamera>>,
) -> 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,
target_pixels: 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),
target_pixels,
);
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,
target_pixels: f32,
) -> f32 {
let pixels_to_view = target_pixels / viewport_height;
let world_height = match projection {
Some(Projection::Perspective(projection)) => {
let target_view = camera_global.affine().inverse().transform_point3(target);
let depth = (-target_view.z).abs().max(0.1);
2.0 * depth * (projection.fov * 0.5).tan()
}
Some(Projection::Orthographic(projection)) => projection.area.height().abs().max(0.1),
_ => {
let target_view = camera_global.affine().inverse().transform_point3(target);
let depth = (-target_view.z).abs().max(0.1);
2.0 * depth * (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::Audio => settings.show_actor_icon_audio,
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::Brush => ActorIconSpec {
image: ActorIconImage::Mesh,
category: ActorIconCategory::Mesh,
},
ActorKind::Terrain => ActorIconSpec {
image: ActorIconImage::Mesh,
category: ActorIconCategory::Mesh,
},
ActorKind::StaticMesh => {
if components.has_primitive || components.has_static_mesh_renderer {
ActorIconSpec {
image: ActorIconImage::Mesh,
category: ActorIconCategory::Mesh,
}
} else {
warning
}
}
ActorKind::SkinnedMesh => {
if components.has_skinned_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::AudioSource => {
if components.has_audio_source {
ActorIconSpec {
image: ActorIconImage::AudioSource,
category: ActorIconCategory::Audio,
}
} else {
warning
}
}
ActorKind::AudioListener => {
if components.has_audio_listener {
ActorIconSpec {
image: ActorIconImage::AudioListener,
category: ActorIconCategory::Audio,
}
} else {
warning
}
}
ActorKind::Navigation => ActorIconSpec {
image: ActorIconImage::GameMarker,
category: ActorIconCategory::Volume,
},
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);
}
#[test]
fn audio_source_and_listener_use_dedicated_icons() {
let source = icon_for_actor_components(ActorIconComponents {
kind: Some(ActorKind::AudioSource),
has_audio_source: true,
..default()
});
let listener = icon_for_actor_components(ActorIconComponents {
kind: Some(ActorKind::AudioListener),
has_audio_listener: true,
..default()
});
assert_eq!(source.image, ActorIconImage::AudioSource);
assert_eq!(source.category, ActorIconCategory::Audio);
assert_eq!(listener.image, ActorIconImage::AudioListener);
assert_eq!(listener.category, ActorIconCategory::Audio);
}
#[test]
fn audio_actor_without_matching_descriptor_uses_warning_icon() {
let spec = icon_for_actor_components(ActorIconComponents {
kind: Some(ActorKind::AudioSource),
..default()
});
assert_eq!(spec.image, ActorIconImage::Warning);
assert_eq!(spec.category, ActorIconCategory::Debug);
}
#[test]
fn audio_icon_category_respects_its_viewport_toggle() {
let mut settings = EditorVisualizationSettings::default();
assert!(icon_category_enabled(&settings, ActorIconCategory::Audio));
settings.show_actor_icon_audio = false;
assert!(!icon_category_enabled(&settings, ActorIconCategory::Audio));
}
#[test]
fn perspective_icon_size_uses_view_depth() {
let camera = GlobalTransform::IDENTITY;
let projection = Projection::Perspective(PerspectiveProjection {
fov: DEFAULT_PERSPECTIVE_FOV,
..default()
});
let centered = icon_world_size(
Vec3::new(0.0, 0.0, -12.0),
&camera,
Some(&projection),
900.0,
ActorIconSettings::DEFAULT_SIZE_PIXELS,
);
let offset = icon_world_size(
Vec3::new(8.0, 0.0, -12.0),
&camera,
Some(&projection),
900.0,
ActorIconSettings::DEFAULT_SIZE_PIXELS,
);
assert_eq!(centered, offset);
}
}