mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-28 08:58:07 +00:00
Scene Inspector (#11)
This PR adds the bones of a user-facing scene inspector. ### Work Done To facilitate this work, the Bevy/frontend interface has been expanded to accommodate two-way communications: - The Bevy app can now dispatch JavaScript `CustomEvent`s via `wasm::dispatch_to_js(...)` - The frontend application can receive those events by listening for the given event type on the `window` object In its current state, the inspection system works by attaching the `Inspectable` marker component to any entities which should be exposed to the user. A Bevy system runs on `Update` to check for newly spawned or despawned entities with that component, and emits events to notify the frontend. The frontend listens for those events and updates its model of the scene accordingly, which is then displayed with a simple tree view. ### Future Enhancements There are many improvements that must be made to make this system actually useful, but this PR is being expedited to focus on migrating to Bevy 0.12. A shortlist of planned updates: - Identify and expose the "type" of entity (e.g. lights, cameras, meshes, skeletons, etc.) via a field on the `Inspectable` component - Highlight the currently selected entity in the frontend hierarchy - Ask the Bevy app to update the selection when the user selects an entry in the hierarchy - Add a separate UI panel to inspect and modify the selected entity's components via the frontend
This commit is contained in:
@@ -39,6 +39,9 @@ impl Plugin for ScenePlugin {
|
||||
),
|
||||
)
|
||||
.add_systems(PostUpdate, move_plane_with_camera.after(CameraMotionSystem));
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
app.add_systems(PostUpdate, wasm::notify_entity_spawn_changes.pipe(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +51,9 @@ struct WorldGridPlane;
|
||||
#[derive(Resource, Deref, DerefMut)]
|
||||
struct NewGltf(pub Handle<Gltf>);
|
||||
|
||||
#[derive(Component)]
|
||||
struct Inspectable;
|
||||
|
||||
fn initialize(
|
||||
mut cmd: Commands,
|
||||
assets: Res<AssetServer>,
|
||||
@@ -61,6 +67,7 @@ fn initialize(
|
||||
cmd.insert_resource(NewGltf(assets.load("gltf/sample-room.gltf")));
|
||||
cmd.spawn((
|
||||
Name::new("Scene"),
|
||||
Inspectable,
|
||||
SceneBundle {
|
||||
scene: assets.load("gltf/sample-room.gltf#Scene0"),
|
||||
..default()
|
||||
@@ -99,6 +106,8 @@ fn initialize(
|
||||
// TODO: get Blender to export enough metadata to automate light placements
|
||||
cmd.spawn((
|
||||
Name::new("Ceiling Light"),
|
||||
Selectable,
|
||||
Inspectable,
|
||||
PointLightBundle {
|
||||
point_light: PointLight {
|
||||
color: Color::hex("FFE5B4").unwrap(),
|
||||
@@ -115,6 +124,8 @@ fn initialize(
|
||||
// Monitor glow
|
||||
cmd.spawn((
|
||||
Name::new("Monitor Glow"),
|
||||
Selectable,
|
||||
Inspectable,
|
||||
PointLightBundle {
|
||||
point_light: PointLight {
|
||||
color: Color::WHITE,
|
||||
@@ -202,6 +213,10 @@ fn mark_scene_objects(
|
||||
let gltf = ra_gltfs.get(&r_gltf).unwrap();
|
||||
|
||||
for (ent_parent, name, children) in q_parents.iter() {
|
||||
if gltf.named_nodes.contains_key(&**name) {
|
||||
cmd.entity(ent_parent).insert(Inspectable);
|
||||
}
|
||||
|
||||
// See if this name matches any of the meshes or nodes in our glTF scene
|
||||
let Some(gltf_mesh) = gltf
|
||||
.named_meshes
|
||||
@@ -219,6 +234,9 @@ fn mark_scene_objects(
|
||||
continue;
|
||||
};
|
||||
|
||||
// FIXME: These hashsets don't actually contain anything. Ever. -_-
|
||||
// This has "worked" this whole time because {} == {}
|
||||
|
||||
// Collect all of the mesh handles in the glTF mesh
|
||||
let prims_from_gltf = gltf_mesh
|
||||
.primitives
|
||||
@@ -237,6 +255,7 @@ fn mark_scene_objects(
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
|
||||
// If the sets match, this is the entity that represents the glTF mesh
|
||||
if prims_from_gltf == prims_from_ecs {
|
||||
cmd.entity(ent_parent).insert(Selectable);
|
||||
@@ -277,3 +296,262 @@ impl Material for ProceduralGridMaterial {
|
||||
AlphaMode::Blend
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
mod wasm {
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy::{prelude::*, utils::HashSet, window::PrimaryWindow};
|
||||
use serde::Serialize;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use crate::wasm::{self, AsJson};
|
||||
|
||||
use super::Inspectable;
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const TYPE_DECLARATIONS: &str = r#"
|
||||
export interface SceneObject {
|
||||
/** The entity ID. */
|
||||
id: string;
|
||||
/** A user-friendly name for displaying in the UI */
|
||||
name: string;
|
||||
/** The IDs of this object's children in the hierarchy */
|
||||
children: string[];
|
||||
/** The ID of this object's parent in the hierarchy, if it has one */
|
||||
parent: string | null;
|
||||
/** The local-space transform */
|
||||
transform: Transform;
|
||||
/** The world-space transform */
|
||||
globalTransform: GlobalTransform;
|
||||
}
|
||||
|
||||
/**
|
||||
* An event indicating that a new entity has spawned.
|
||||
*/
|
||||
export interface EntitySpawnEvent extends CustomEvent {
|
||||
type: "entity-spawn";
|
||||
detail: SceneObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* An event indicating that an entity has been de-spawned.
|
||||
*/
|
||||
export interface EntityDespawnEvent extends CustomEvent {
|
||||
type: "entity-despawn";
|
||||
/** The entity ID */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An event indicating that multiple entities have spawned.
|
||||
*/
|
||||
export interface EntityMultiSpawnEvent extends CustomEvent {
|
||||
type: "entity-multi-spawn";
|
||||
detail: SceneObject[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface HTMLElementEventMap {
|
||||
"entity-spawn": EntitySpawnEvent;
|
||||
"entity-despawn": EntityDespawnEvent;
|
||||
"entity-multi-spawn": EntityMultiSpawnEvent;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct SceneObject {
|
||||
id: String,
|
||||
name: String,
|
||||
children: Vec<String>,
|
||||
parent: Option<String>,
|
||||
transform: serde_json::Value,
|
||||
#[serde(rename(serialize = "globalTransform"))]
|
||||
global_transform: serde_json::Value,
|
||||
}
|
||||
|
||||
struct SceneObjectBuilder<'a> {
|
||||
id: Entity,
|
||||
name: Option<String>,
|
||||
children: Option<Vec<Entity>>,
|
||||
parent: Option<Entity>,
|
||||
transform: Option<&'a Transform>,
|
||||
global_transform: Option<&'a GlobalTransform>,
|
||||
}
|
||||
|
||||
impl<'a> SceneObjectBuilder<'a> {
|
||||
fn new(id: Entity) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: None,
|
||||
children: None,
|
||||
parent: None,
|
||||
transform: None,
|
||||
global_transform: None,
|
||||
}
|
||||
}
|
||||
fn finish(self) -> SceneObject {
|
||||
SceneObject {
|
||||
id: format!("{:?}", self.id),
|
||||
name: self.name.unwrap(),
|
||||
children: self
|
||||
.children
|
||||
.map(|children| children.iter().map(|ent| format!("{ent:?}")).collect())
|
||||
.unwrap_or_default(),
|
||||
parent: self.parent.map(|ent| format!("{ent:?}")),
|
||||
transform: self.transform.unwrap().as_json(),
|
||||
global_transform: self.global_transform.unwrap().as_json(),
|
||||
}
|
||||
}
|
||||
fn with_name(mut self, name: String) -> Self {
|
||||
self.name = Some(name);
|
||||
self
|
||||
}
|
||||
fn with_children(mut self, children: Vec<Entity>) -> Self {
|
||||
self.children = Some(children);
|
||||
self
|
||||
}
|
||||
fn with_parent(mut self, parent: Entity) -> Self {
|
||||
self.parent = Some(parent);
|
||||
self
|
||||
}
|
||||
fn with_transform(mut self, transform: &'a Transform) -> Self {
|
||||
self.transform = Some(transform);
|
||||
self
|
||||
}
|
||||
fn with_global_transform(mut self, global_transform: &'a GlobalTransform) -> Self {
|
||||
self.global_transform = Some(global_transform);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> SceneObject {
|
||||
fn build(id: Entity) -> SceneObjectBuilder<'a> {
|
||||
SceneObjectBuilder::new(id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn notify_entity_spawn_changes(
|
||||
mut l_notified: Local<HashSet<Entity>>,
|
||||
// Note: We use locals for these data structures purely to reuse
|
||||
// existing allocations across multiple invocations of this system
|
||||
mut l_new_objects: Local<Vec<SceneObject>>,
|
||||
mut l_despawned_entities: Local<Vec<Entity>>,
|
||||
mut l_missing_parents: Local<VecDeque<Entity>>,
|
||||
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||
q_inspectables: Query<(Entity, &Transform, &GlobalTransform), With<Inspectable>>,
|
||||
q_others: Query<(Entity, &Transform, &GlobalTransform), Without<Inspectable>>,
|
||||
q_names: Query<&Name>,
|
||||
q_parents: Query<&Children>,
|
||||
q_children: Query<&Parent>,
|
||||
) -> Result<(), String> {
|
||||
l_missing_parents.clear();
|
||||
|
||||
l_new_objects.clear();
|
||||
l_new_objects.extend(
|
||||
q_inspectables
|
||||
.iter()
|
||||
.filter_map(|(ent, xform, world_xform)| {
|
||||
build_scene_object(
|
||||
&mut l_notified,
|
||||
&mut l_missing_parents,
|
||||
ent,
|
||||
xform,
|
||||
world_xform,
|
||||
&q_inspectables,
|
||||
&q_names,
|
||||
&q_parents,
|
||||
&q_children,
|
||||
)
|
||||
}),
|
||||
);
|
||||
|
||||
while let Some(parent) = l_missing_parents.pop_front() {
|
||||
let Ok((ent, xform, world_xform)) = q_others.get(parent) else {
|
||||
error!("Failed to find missing parent: {parent:?}");
|
||||
l_notified.insert(parent);
|
||||
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(object) = build_scene_object(
|
||||
&mut l_notified,
|
||||
&mut l_missing_parents,
|
||||
ent,
|
||||
xform,
|
||||
world_xform,
|
||||
&q_inspectables,
|
||||
&q_names,
|
||||
&q_parents,
|
||||
&q_children,
|
||||
) {
|
||||
l_new_objects.push(object);
|
||||
}
|
||||
}
|
||||
|
||||
l_despawned_entities.clear();
|
||||
l_despawned_entities.extend(
|
||||
l_notified
|
||||
.iter()
|
||||
.filter(|ent| !q_inspectables.contains(**ent) && !q_others.contains(**ent))
|
||||
.cloned(),
|
||||
);
|
||||
|
||||
let win = q_window.single();
|
||||
|
||||
for ent in l_despawned_entities.iter() {
|
||||
// TODO: Make this take an array like the multi-spawn event
|
||||
wasm::dispatch_to_js(win, "entity-despawn", ent)?;
|
||||
l_notified.remove(ent);
|
||||
}
|
||||
|
||||
if !l_new_objects.is_empty() {
|
||||
// TODO: Remove the single-spawn event
|
||||
wasm::dispatch_to_js(win, "entity-multi-spawn", &*l_new_objects)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_scene_object(
|
||||
notified: &mut HashSet<Entity>,
|
||||
missing_parents: &mut VecDeque<Entity>,
|
||||
ent: Entity,
|
||||
xform: &Transform,
|
||||
world_xform: &GlobalTransform,
|
||||
q_inspectables: &Query<(Entity, &Transform, &GlobalTransform), With<Inspectable>>,
|
||||
q_names: &Query<&Name>,
|
||||
q_parents: &Query<&Children>,
|
||||
q_children: &Query<&Parent>,
|
||||
) -> Option<SceneObject> {
|
||||
if notified.contains(&ent) {
|
||||
return None;
|
||||
}
|
||||
|
||||
notified.insert(ent);
|
||||
|
||||
let mut result = SceneObject::build(ent)
|
||||
.with_transform(xform)
|
||||
.with_global_transform(world_xform);
|
||||
|
||||
if let Ok(children) = q_parents.get(ent) {
|
||||
result = result.with_children(children.iter().cloned().collect());
|
||||
}
|
||||
if let Ok(parent) = q_children.get(ent) {
|
||||
let parent_ent = **parent;
|
||||
result = result.with_parent(parent_ent);
|
||||
|
||||
if !notified.contains(&parent_ent) && !q_inspectables.contains(parent_ent) {
|
||||
missing_parents.push_back(parent_ent);
|
||||
}
|
||||
}
|
||||
|
||||
result = result.with_name(match q_names.get(ent) {
|
||||
Ok(name) => name.into(),
|
||||
Err(_) => format!("{ent:?}"),
|
||||
});
|
||||
|
||||
Some(result.finish())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user