896 lines
31 KiB
Rust
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);
|
|
}
|
|
}
|