Blacksite/crates/editor/src/viewport/selection_outline.rs
Rbanh 0553a85220
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Build production-ready editor authoring workflows
2026-07-11 12:41:04 -04:00

529 lines
17 KiB
Rust

//! Editor selection treatment: active/secondary AABB shells, x-ray, and corner brackets.
use avian3d::prelude::ColliderConstructor;
use bevy::asset::{load_internal_asset, uuid_handle};
use bevy::camera::visibility::RenderLayers;
use bevy::pbr::{Material, MaterialPlugin};
use bevy::prelude::*;
use bevy::render::render_resource::{
AsBindGroup, BlendState, CompareFunction, Face, RenderPipelineDescriptor, ShaderType,
};
use bevy::shader::ShaderRef;
use shared::{
ColliderDesc, ColliderShapeDesc, LevelObject, PhysicsBody, Primitive, RaytracingExcluded,
};
use crate::infra::EditorOnly;
use crate::state::scene_tools_active;
use crate::ui::UiState;
use crate::viewport::ViewportDisplayMode;
const SELECTION_OUTLINE_SHADER: Handle<Shader> =
uuid_handle!("c4e8f1a2-9b3d-4e7c-a1f5-6d2b8e0c4a91");
const PRIMARY_VISIBLE_COLOR: Color = Color::srgba(1.0, 0.72, 0.28, 0.56);
const PRIMARY_OCCLUDED_COLOR: Color = Color::srgba(1.0, 0.66, 0.22, 0.28);
const SECONDARY_VISIBLE_COLOR: Color = Color::srgba(0.31, 0.75, 0.88, 0.42);
const SECONDARY_OCCLUDED_COLOR: Color = Color::srgba(0.31, 0.68, 0.82, 0.2);
const BOUNDS_PADDING: f32 = 1.06;
#[derive(Clone, Copy, ShaderType)]
struct OutlineUniform {
color: LinearRgba,
rim_power: f32,
rim_mix: f32,
pass_kind: u32,
}
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub struct SelectionOutlineVisibleMaterial {
#[uniform(0)]
uniform: OutlineUniform,
}
#[derive(Asset, TypePath, AsBindGroup, Clone)]
pub struct SelectionOutlineOccludedMaterial {
#[uniform(0)]
uniform: OutlineUniform,
}
fn outline_uniform(color: Color, pass_kind: u32) -> OutlineUniform {
OutlineUniform {
color: color.into(),
rim_power: 5.5,
rim_mix: 0.9,
pass_kind,
}
}
macro_rules! impl_outline_material {
($t:ty, $depth:expr) => {
impl Material for $t {
fn fragment_shader() -> ShaderRef {
SELECTION_OUTLINE_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 = Some(Face::Front);
if let Some(depth) = &mut descriptor.depth_stencil {
depth.depth_write_enabled = Some(false);
depth.depth_compare = Some($depth);
}
if let Some(fragment) = &mut descriptor.fragment {
if let Some(target) = fragment.targets.first_mut().and_then(|t| t.as_mut()) {
target.blend = Some(BlendState::ALPHA_BLENDING);
}
}
Ok(())
}
}
};
}
impl_outline_material!(SelectionOutlineVisibleMaterial, CompareFunction::LessEqual);
impl_outline_material!(SelectionOutlineOccludedMaterial, CompareFunction::Greater);
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum SelectionOutlinePass {
Visible,
Occluded,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum SelectionOutlineRole {
Primary,
Secondary,
}
#[derive(Component)]
pub(crate) struct SelectionOutlineShell {
root: Entity,
pass: SelectionOutlinePass,
role: SelectionOutlineRole,
}
#[derive(Resource)]
struct SelectionOutlineMaterials {
primary_occluded: Handle<SelectionOutlineOccludedMaterial>,
primary_visible: Handle<SelectionOutlineVisibleMaterial>,
secondary_occluded: Handle<SelectionOutlineOccludedMaterial>,
secondary_visible: Handle<SelectionOutlineVisibleMaterial>,
}
#[derive(Resource)]
struct SelectionOutlineUnitBox(Handle<Mesh>);
#[derive(Clone, Copy, Debug)]
struct SelectionBounds {
center: Vec3,
half_extents: Vec3,
}
pub struct SelectionOutlinePlugin;
impl Plugin for SelectionOutlinePlugin {
fn build(&self, app: &mut App) {
load_internal_asset!(
app,
SELECTION_OUTLINE_SHADER,
"../../assets/shaders/selection_outline.wgsl",
Shader::from_wgsl
);
app.add_plugins((
MaterialPlugin::<SelectionOutlineVisibleMaterial>::default(),
MaterialPlugin::<SelectionOutlineOccludedMaterial>::default(),
))
.add_systems(
Startup,
(
init_selection_outline_assets,
init_selection_outline_materials,
)
.chain(),
)
.add_systems(
Update,
(
sync_selection_outline_meshes,
draw_selection_corner_brackets,
)
.chain()
.run_if(scene_tools_active),
);
}
}
fn init_selection_outline_assets(mut commands: Commands, mut meshes: ResMut<Assets<Mesh>>) {
commands.insert_resource(SelectionOutlineUnitBox(
meshes.add(Mesh::from(Cuboid::new(1.0, 1.0, 1.0))),
));
}
fn init_selection_outline_materials(
mut visible: ResMut<Assets<SelectionOutlineVisibleMaterial>>,
mut occluded: ResMut<Assets<SelectionOutlineOccludedMaterial>>,
mut commands: Commands,
) {
commands.insert_resource(SelectionOutlineMaterials {
primary_visible: visible.add(SelectionOutlineVisibleMaterial {
uniform: outline_uniform(PRIMARY_VISIBLE_COLOR, 0),
}),
primary_occluded: occluded.add(SelectionOutlineOccludedMaterial {
uniform: outline_uniform(PRIMARY_OCCLUDED_COLOR, 1),
}),
secondary_visible: visible.add(SelectionOutlineVisibleMaterial {
uniform: outline_uniform(SECONDARY_VISIBLE_COLOR, 0),
}),
secondary_occluded: occluded.add(SelectionOutlineOccludedMaterial {
uniform: outline_uniform(SECONDARY_OCCLUDED_COLOR, 1),
}),
});
}
#[allow(clippy::too_many_arguments)]
fn sync_selection_outline_meshes(
ui_state: Res<UiState>,
display: Res<ViewportDisplayMode>,
materials: Res<SelectionOutlineMaterials>,
unit_box: Res<SelectionOutlineUnitBox>,
mut commands: Commands,
mut shells: Query<(Entity, &SelectionOutlineShell, &mut Transform)>,
children: Query<&Children>,
mesh_entities: Query<(Entity, &Mesh3d), With<Mesh3d>>,
globals: Query<&GlobalTransform>,
mesh_assets: Res<Assets<Mesh>>,
primitives: Query<&Primitive>,
physics: Query<&PhysicsBody>,
colliders: Query<&ColliderDesc>,
level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>,
) {
let selected: Vec<Entity> = ui_state.selected_entities.iter().collect();
let mut desired: std::collections::HashMap<
(Entity, SelectionOutlinePass, SelectionOutlineRole),
SelectionBounds,
> = std::collections::HashMap::new();
if !display.clean_game_view {
for (index, &root) in selected.iter().enumerate() {
if !level_objects.contains(root) {
continue;
}
let Some(bounds) = selection_bounds(
root,
&children,
&mesh_entities,
&globals,
&mesh_assets,
&primitives,
&physics,
&colliders,
) else {
continue;
};
if bounds.half_extents.max_element() <= f32::EPSILON {
continue;
}
let role = if index == 0 {
SelectionOutlineRole::Primary
} else {
SelectionOutlineRole::Secondary
};
desired.insert((root, SelectionOutlinePass::Occluded, role), bounds);
desired.insert((root, SelectionOutlinePass::Visible, role), bounds);
}
}
let mut existing = std::collections::HashSet::new();
for (shell_entity, shell, mut transform) in &mut shells {
let key = (shell.root, shell.pass, shell.role);
if let Some(bounds) = desired.get(&key) {
transform.translation = bounds.center;
transform.scale = bounds.half_extents * 2.0;
existing.insert(key);
} else {
commands.entity(shell_entity).despawn();
}
}
for ((root, pass, role), bounds) in desired {
if existing.contains(&(root, pass, role)) {
continue;
}
let mut entity_commands = commands.spawn((
EditorOnly,
RaytracingExcluded,
Name::new("Selection Outline"),
SelectionOutlineShell { root, pass, role },
Mesh3d(unit_box.0.clone()),
Transform {
translation: bounds.center,
scale: bounds.half_extents * 2.0,
..default()
},
RenderLayers::default(),
ChildOf(root),
));
match (role, pass) {
(SelectionOutlineRole::Primary, SelectionOutlinePass::Occluded) => {
entity_commands.insert(MeshMaterial3d(materials.primary_occluded.clone()));
}
(SelectionOutlineRole::Primary, SelectionOutlinePass::Visible) => {
entity_commands.insert(MeshMaterial3d(materials.primary_visible.clone()));
}
(SelectionOutlineRole::Secondary, SelectionOutlinePass::Occluded) => {
entity_commands.insert(MeshMaterial3d(materials.secondary_occluded.clone()));
}
(SelectionOutlineRole::Secondary, SelectionOutlinePass::Visible) => {
entity_commands.insert(MeshMaterial3d(materials.secondary_visible.clone()));
}
}
}
}
fn draw_selection_corner_brackets(
display: Res<ViewportDisplayMode>,
mut gizmos: Gizmos,
shells: Query<(&SelectionOutlineShell, &GlobalTransform)>,
) {
if display.clean_game_view {
return;
}
for (shell, global) in &shells {
if shell.pass != SelectionOutlinePass::Visible {
continue;
}
let (scale, rotation, center) = global.to_scale_rotation_translation();
let half = scale.abs() * 0.5;
if half.max_element() <= f32::EPSILON {
continue;
}
let color = match shell.role {
SelectionOutlineRole::Primary => Color::srgba(1.0, 0.76, 0.34, 0.98),
SelectionOutlineRole::Secondary => Color::srgba(0.38, 0.78, 0.9, 0.78),
};
draw_corner_brackets(&mut gizmos, center, rotation, half, color);
}
}
fn draw_corner_brackets(
gizmos: &mut Gizmos,
center: Vec3,
rotation: Quat,
half: Vec3,
color: Color,
) {
let lengths = (half * 0.28).clamp(Vec3::splat(0.06), Vec3::splat(0.55));
for signs in [
Vec3::new(-1.0, -1.0, -1.0),
Vec3::new(1.0, -1.0, -1.0),
Vec3::new(-1.0, 1.0, -1.0),
Vec3::new(1.0, 1.0, -1.0),
Vec3::new(-1.0, -1.0, 1.0),
Vec3::new(1.0, -1.0, 1.0),
Vec3::new(-1.0, 1.0, 1.0),
Vec3::new(1.0, 1.0, 1.0),
] {
let corner = center + rotation * (half * signs);
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
let length = lengths.dot(axis);
let direction = rotation * (axis * -signs.dot(axis));
gizmos.line(corner, corner + direction * length, color);
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "selection bounds combine distinct authored geometry and physics sources"
)]
fn selection_bounds(
root: Entity,
children: &Query<&Children>,
mesh_entities: &Query<(Entity, &Mesh3d), With<Mesh3d>>,
globals: &Query<&GlobalTransform>,
mesh_assets: &Assets<Mesh>,
primitives: &Query<&Primitive>,
physics: &Query<&PhysicsBody>,
colliders: &Query<&ColliderDesc>,
) -> Option<SelectionBounds> {
if let Ok(primitive) = primitives.get(root) {
return Some(SelectionBounds {
center: Vec3::ZERO,
half_extents: primitive.size * 0.5 * BOUNDS_PADDING,
});
}
if let Ok(body) = physics.get(root) {
if let Some(bounds) = bounds_from_collider(&body.collider) {
return Some(bounds);
}
}
if let Ok(collider) = colliders.get(root) {
if let Some(bounds) = bounds_from_collider_desc(collider) {
return Some(bounds);
}
}
union_mesh_bounds(root, children, mesh_entities, globals, mesh_assets)
}
fn bounds_from_collider(collider: &ColliderConstructor) -> Option<SelectionBounds> {
match collider {
ColliderConstructor::Cuboid {
x_length,
y_length,
z_length,
} => Some(SelectionBounds {
center: Vec3::ZERO,
half_extents: Vec3::new(*x_length, *y_length, *z_length) * 0.5 * BOUNDS_PADDING,
}),
ColliderConstructor::Sphere { radius } => Some(SelectionBounds {
center: Vec3::ZERO,
half_extents: Vec3::splat(*radius) * BOUNDS_PADDING,
}),
_ => None,
}
}
fn bounds_from_collider_desc(collider: &ColliderDesc) -> Option<SelectionBounds> {
if !collider.enabled {
return None;
}
match &collider.shape {
ColliderShapeDesc::Cuboid {
x_length,
y_length,
z_length,
} => Some(SelectionBounds {
center: Vec3::ZERO,
half_extents: Vec3::new(*x_length, *y_length, *z_length) * 0.5 * BOUNDS_PADDING,
}),
ColliderShapeDesc::Sphere { radius } => Some(SelectionBounds {
center: Vec3::ZERO,
half_extents: Vec3::splat(*radius) * BOUNDS_PADDING,
}),
ColliderShapeDesc::Capsule { radius, height } => Some(SelectionBounds {
center: Vec3::ZERO,
half_extents: Vec3::new(*radius, *height * 0.5, *radius) * BOUNDS_PADDING,
}),
ColliderShapeDesc::StaticMesh { .. } => None,
}
}
fn union_mesh_bounds(
root: Entity,
children: &Query<&Children>,
mesh_entities: &Query<(Entity, &Mesh3d), With<Mesh3d>>,
globals: &Query<&GlobalTransform>,
mesh_assets: &Assets<Mesh>,
) -> Option<SelectionBounds> {
let root_global = globals.get(root).ok()?;
let root_to_world = root_global.affine();
let world_to_root = root_to_world.inverse();
let mut min = Vec3::splat(f32::INFINITY);
let mut max = Vec3::splat(f32::NEG_INFINITY);
let mut any = false;
for entity in collect_mesh_entities(root, children, mesh_entities) {
let Ok((_, mesh3d)) = mesh_entities.get(entity) else {
continue;
};
let Some(mesh) = mesh_assets.get(&mesh3d.0) else {
continue;
};
let Some(aabb) = mesh.final_aabb else {
continue;
};
let entity_global = globals.get(entity).ok()?;
let entity_to_world = entity_global.affine();
let mesh_min = Vec3::from(aabb.min);
let mesh_max = Vec3::from(aabb.max);
for corner in corner_iter(mesh_min, mesh_max) {
let world = entity_to_world.transform_point3(corner);
let local = world_to_root.transform_point3(world);
min = min.min(local);
max = max.max(local);
any = true;
}
}
if !any {
return None;
}
let center = (min + max) * 0.5;
let half_extents = (max - min) * 0.5 * BOUNDS_PADDING;
Some(SelectionBounds {
center,
half_extents,
})
}
fn corner_iter(min: Vec3, max: Vec3) -> impl Iterator<Item = Vec3> {
[
Vec3::new(min.x, min.y, min.z),
Vec3::new(max.x, min.y, min.z),
Vec3::new(min.x, max.y, min.z),
Vec3::new(max.x, max.y, min.z),
Vec3::new(min.x, min.y, max.z),
Vec3::new(max.x, min.y, max.z),
Vec3::new(min.x, max.y, max.z),
Vec3::new(max.x, max.y, max.z),
]
.into_iter()
}
fn collect_mesh_entities(
root: Entity,
children: &Query<&Children>,
meshes: &Query<(Entity, &Mesh3d), With<Mesh3d>>,
) -> Vec<Entity> {
let mut found = Vec::new();
if meshes.get(root).is_ok() {
found.push(root);
}
collect_mesh_descendants(root, children, meshes, &mut found);
found
}
fn collect_mesh_descendants(
entity: Entity,
children: &Query<&Children>,
meshes: &Query<(Entity, &Mesh3d), With<Mesh3d>>,
found: &mut Vec<Entity>,
) {
let Ok(kids) = children.get(entity) else {
return;
};
for child in kids.iter() {
if meshes.get(child).is_ok() {
found.push(child);
}
collect_mesh_descendants(child, children, meshes, found);
}
}