529 lines
17 KiB
Rust
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);
|
|
}
|
|
}
|