Single-object viewer POC (#14)

This commit implements a Sketchfab-style single-object viewer as a minimal
proof-of-concept.

### `@storyteller/studio-web`
This is a new package to house web components for interfacing with the
`@storyteller/studio` WASM library. It currently includes a single component,
`<sts-viewer />`, which is exported from `@storyteller/studio-web/viewer`.

### `react-frontend`
This is a new application intended as a proof-of-concept and testbed for React
interop with `@storyteller/studio-web`.

Web components are currently a little bit fiddly to use in a React application.
`react-frontend/src/app/studio-viewer.tsx` demonstrates how a React-idiomatic
component can be written as a thin wrapper around a web component, and
`react-frontend/src/app/app.tsx` demonstrates that wrapper component in use.
This commit is contained in:
Danny McGee
2023-12-07 15:46:32 -05:00
committed by GitHub
parent 155272a5ba
commit cba70a62ae
70 changed files with 71793 additions and 1157 deletions
+25 -1
View File
@@ -7,11 +7,18 @@ use bevy_mod_picking::debug::DebugPickingMode;
use crate::{
camera_controller::{CameraController, CameraMotionSystem},
input::{Action, InputAction, Lifecycle},
MainCamera,
};
#[derive(Resource, Deref, DerefMut)]
pub struct DebugMode(bool);
#[derive(Component, Clone, Copy, Debug)]
pub struct DebugPoint {
pub point: Vec3,
pub color: Color,
}
pub struct DebugPlugin;
impl Plugin for DebugPlugin {
fn build(&self, app: &mut App) {
@@ -31,7 +38,7 @@ impl Plugin for DebugPlugin {
toggle_debug_mode,
update_debug_picking_mode.run_if(resource_changed::<DebugMode>()),
update_gizmo_line_thickness,
draw_world_axes
(draw_world_axes, draw_debug_points)
.after(CameraMotionSystem)
.run_if(is_debug_mode),
),
@@ -84,6 +91,23 @@ fn draw_world_axes(mut gizmos: Gizmos, q: Query<&CameraController>) {
draw_axis(Vec3::Z, Color::BLUE);
}
fn draw_debug_points(
mut gizmos: Gizmos,
q_debug_points: Query<&DebugPoint>,
q_cam_xform: Query<&GlobalTransform, With<MainCamera>>,
) {
let Ok(cam_xform) = q_cam_xform.get_single() else {
return;
};
for &DebugPoint { point, color } in q_debug_points.iter() {
let dist = cam_xform.translation().distance(point);
gizmos.cuboid(
Transform::from_translation(point).with_scale(Vec3::splat(dist / 300.)),
color,
);
}
}
#[rustfmt::skip]
#[allow(dead_code)]
pub fn write_mat3(w: &mut impl fmt::Write, m: &Mat3) -> fmt::Result {
+16
View File
@@ -0,0 +1,16 @@
use bevy::prelude::*;
#[cfg(feature = "wasm")]
mod wasm;
pub struct InspectorPlugin;
impl Plugin for InspectorPlugin {
fn build(&self, app: &mut App) {
#[cfg(feature = "wasm")]
app.add_systems(
PostUpdate,
wasm::notify_entity_spawn_changes.map(bevy::utils::error),
);
}
}
+256
View File
@@ -0,0 +1,256 @@
use std::collections::VecDeque;
use bevy::{prelude::*, utils::HashSet, window::PrimaryWindow};
use serde::Serialize;
use wasm_bindgen::prelude::*;
use crate::{
scene::SceneElement,
wasm::{self, AsJson},
};
#[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<SceneElement>>,
q_others: Query<(Entity, &Transform, &GlobalTransform), Without<SceneElement>>,
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<SceneElement>>,
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())
}
+28 -2
View File
@@ -2,9 +2,12 @@ use bevy::prelude::*;
use bevy_mod_outline::{
AutoGenerateOutlineNormalsPlugin, OutlineBundle, OutlinePlugin, OutlineVolume,
};
use bevy_mod_picking::{prelude::Pointer, selection::Select};
use bevy_mod_picking::{prelude::Pointer, selection::Select, PickableBundle};
use crate::input::{self, Action, InputAction, Lifecycle};
use crate::{
input::{self, Action, InputAction, Lifecycle},
scene::{SceneElement, SceneSpawnerSystem, SceneState},
};
pub struct SelectionPlugin;
impl Plugin for SelectionPlugin {
@@ -14,6 +17,12 @@ impl Plugin for SelectionPlugin {
.add_systems(
PostUpdate,
draw_selection_outline.run_if(resource_changed_or_removed::<Selection>()),
)
.add_systems(
OnEnter(SceneState::Active),
(apply_deferred, tag_scene_elements)
.chain()
.after(SceneSpawnerSystem),
);
#[cfg(feature = "wasm")]
@@ -35,6 +44,23 @@ pub struct Selected;
#[derive(Resource, Deref, DerefMut, Debug)]
pub struct Selection(Entity);
fn tag_scene_elements(
mut cmd: Commands,
q_scene_elements: Query<(Entity, &Children), (With<SceneElement>, Without<Selectable>)>,
q_primitives: Query<&Handle<Mesh>>,
) {
for (ent, children) in q_scene_elements.iter() {
cmd.entity(ent).insert(Selectable);
for &ent_child in children
.iter()
.filter(|child| q_primitives.contains(**child))
{
cmd.entity(ent_child).insert(PickableBundle::default());
}
}
}
fn update_selection(
mut cmd: Commands,
// FIXME: These events aren't aware of our input actions, so we receive a
+7 -7
View File
@@ -88,7 +88,7 @@ mod wasm {
use bevy::prelude::*;
use wasm_bindgen::prelude::*;
use crate::wasm::{self, WasmMessageBuffer};
use crate::wasm::{StaticBuffer, WasmMessageBuffer};
use super::{TransformMode, TransformSpace, TransformType};
@@ -111,22 +111,22 @@ mod wasm {
#[wasm_bindgen(js_name = setTransformType)]
pub fn set_transform_type(flags: u8) {
wasm::write_message!(TRANSFORM_TYPE_BUF, flags.into());
TRANSFORM_TYPE_BUF.write_message(flags.into());
}
#[wasm_bindgen(js_name = setTransformSpace)]
pub fn set_transform_space(space: TransformSpace) {
wasm::write_message!(TRANSFORM_SPACE_BUF, space);
TRANSFORM_SPACE_BUF.write_message(space);
}
pub(super) fn update_transform_mode(mut xform_mode: ResMut<TransformMode>) {
wasm::read_buffer!(TRANSFORM_TYPE_BUF, |ttype| {
if let Some(ttype) = TRANSFORM_TYPE_BUF.take_message() {
info!("Setting transform type: {ttype:?}");
xform_mode.ttype = ttype;
});
wasm::read_buffer!(TRANSFORM_SPACE_BUF, |space| {
}
if let Some(space) = TRANSFORM_SPACE_BUF.take_message() {
info!("Setting transform space: {space:?}");
xform_mode.space = space;
});
}
}
}
+89 -12
View File
@@ -2,27 +2,36 @@
use bevy::prelude::*;
use bevy_mod_picking::DefaultPickingPlugins;
use camera_controller::CameraControllerPlugin;
use debug::DebugPlugin;
use input::StudioInputPlugin;
use interaction::InteractionPlugin;
use math::InterpolationPlugin;
use scene::ScenePlugin;
use skybox::SkyboxPlugin;
use ui::UiLayerPlugin;
use viewport::ViewportSize;
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;
mod camera_controller;
mod debug;
mod design_system;
mod input;
mod inspector;
mod interaction;
mod math;
mod scene;
mod skybox;
mod ui;
mod viewport;
mod world_grid;
use crate::{
camera_controller::CameraControllerPlugin,
debug::DebugPlugin,
input::StudioInputPlugin,
inspector::InspectorPlugin,
interaction::InteractionPlugin,
math::InterpolationPlugin,
scene::{LoadSceneEvent, ScenePlugin},
skybox::{SkyboxId, SkyboxPlugin},
ui::UiLayerPlugin,
viewport::ViewportSize,
world_grid::WorldGridPlugin,
};
#[cfg(feature = "wasm")]
mod wasm;
@@ -30,7 +39,7 @@ mod wasm;
#[derive(Component)]
pub(crate) struct MainCamera;
#[wasm_bindgen]
#[cfg_attr(feature = "wasm", wasm_bindgen)]
pub fn init(viewport_size: ViewportSize) {
#[cfg(feature = "wasm")]
console_error_panic_hook::set_once();
@@ -62,12 +71,80 @@ pub fn init(viewport_size: ViewportSize) {
DefaultPickingPlugins,
DebugPlugin,
InteractionPlugin,
InspectorPlugin,
StudioInputPlugin,
InterpolationPlugin,
CameraControllerPlugin,
ScenePlugin,
SkyboxPlugin,
UiLayerPlugin,
))
.run();
WorldGridPlugin,
));
#[cfg(feature = "wasm")]
wasm::initialize();
app.run();
}
#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = startViewer))]
/// Start the application as a read-only viewer for a single library object.
///
/// @param object_id ID of the library object to load.
/// @param skybox_id ID of the Skybox to use
/// @param canvas_selector A CSS selector for the canvas that will host the Bevy
/// application. This should be unique (i.e. `querySelectorAll` should not
/// return more than one element), so using an ID selector is a good idea.
/// @param canvas_alt `alt` text for the HTML canvas element.
/// @param viewport_size The size of the canvas in CSS `px` units.
pub fn start_viewer(
object_id: &str,
skybox_id: &str,
canvas_selector: &str,
canvas_alt: &str,
viewport_size: ViewportSize,
) {
#[cfg(feature = "wasm")]
console_error_panic_hook::set_once();
let object_id = object_id.to_string();
let skybox_id = skybox_id.to_string();
let mut app = App::new();
app.add_plugins((
#[cfg(feature = "wasm")]
viewport::wasm::ViewportPlugin,
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
resolution: viewport_size.into(),
title: canvas_alt.into(),
canvas: Some(canvas_selector.into()),
..default()
}),
..default()
}),
DefaultPickingPlugins,
DebugPlugin,
InteractionPlugin,
InspectorPlugin,
StudioInputPlugin,
InterpolationPlugin,
CameraControllerPlugin,
ScenePlugin,
SkyboxPlugin,
UiLayerPlugin,
WorldGridPlugin,
))
.add_systems(
Startup,
move |mut cmd: Commands, mut ew: EventWriter<LoadSceneEvent>| {
cmd.insert_resource(SkyboxId(skybox_id.clone()));
ew.send(LoadSceneEvent(object_id.clone()));
},
);
#[cfg(feature = "wasm")]
wasm::initialize();
app.run();
}
+50 -1
View File
@@ -1,7 +1,7 @@
use bevy::{
math::vec3,
prelude::*,
render::{mesh::Indices, render_resource::PrimitiveTopology},
render::{mesh::Indices, primitives::Aabb, render_resource::PrimitiveTopology},
};
pub struct Cone {
@@ -111,3 +111,52 @@ impl From<Plane2Sided> for Mesh {
mesh
}
}
pub trait Extrema {
type Output: Sized;
fn extrema(&self) -> Self::Output;
}
impl Extrema for Aabb {
type Output = [Vec3; 8];
fn extrema(&self) -> Self::Output {
let min = Vec3::from(self.min());
let max = Vec3::from(self.max());
[
min,
vec3(min.x, min.y, max.z),
vec3(min.x, max.y, min.z),
vec3(max.x, min.y, min.z),
vec3(min.x, max.y, max.z),
vec3(max.x, min.y, max.z),
vec3(max.x, max.y, min.z),
max,
]
}
}
pub trait Combinable {
type IterItem<'a>;
type Output;
fn combine<'a>(elements: impl Iterator<Item = Self::IterItem<'a>>) -> Self::Output;
}
impl Combinable for Aabb {
type IterItem<'a> = (&'a Self, &'a GlobalTransform);
type Output = Option<Self>;
fn combine<'a>(elements: impl Iterator<Item = Self::IterItem<'a>>) -> Self::Output {
let points = elements.flat_map(|(aabb, world_xform)| {
let min = world_xform.transform_point(aabb.min().into());
let max = world_xform.transform_point(aabb.max().into());
[min, max]
});
Aabb::enclosing(points)
}
}
+352 -493
View File
@@ -4,80 +4,114 @@ use bevy::{
fxaa::Fxaa,
tonemapping::Tonemapping,
},
gltf::{self, Gltf},
math::vec3,
pbr::{NotShadowCaster, ShadowFilteringMethod},
gltf::Gltf,
math::{vec2, vec3},
pbr::ShadowFilteringMethod,
prelude::*,
reflect::TypePath,
render::render_resource::{AsBindGroup, ShaderRef},
utils::HashSet,
render::primitives::Aabb,
scene::InstanceId,
};
use bevy_mod_picking::PickableBundle;
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::wasm_bindgen;
use crate::{
camera_controller::{CameraController, CameraControllerBundle, CameraMotionSystem},
interaction::Selectable,
skybox::SkyboxId,
camera_controller::{CameraController, CameraControllerBundle},
debug::DebugPoint,
math::{Combinable, Extrema},
MainCamera,
};
pub struct ScenePlugin;
#[derive(SystemSet, Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct SceneSpawnerSystem;
impl Plugin for ScenePlugin {
fn build(&self, app: &mut App) {
app.insert_resource(ClearColor(Color::hsl(0.0, 0.0, 0.25)))
.insert_resource(Msaa::Off)
.add_plugins(MaterialPlugin::<ProceduralGridMaterial>::default())
.add_state::<SceneState>()
.add_event::<LoadSceneEvent>()
.add_systems(Startup, initialize)
.add_systems(
PreUpdate,
spawn_new_gltf_scene.run_if(
resource_exists::<NewGltf>()
.and_then(new_gltf_loaded)
.and_then(run_once()),
),
Update,
(
init_scene_loading,
tick_scene_loading.run_if(in_state(SceneState::Loading)),
tick_scene_spawning.run_if(in_state(SceneState::Spawning)),
)
.in_set(SceneSpawnerSystem),
)
.add_systems(
Update,
mark_scene_objects.run_if(
resource_exists::<NewGltf>()
.and_then(new_gltf_loaded)
.and_then(gltf_scene_spawned),
),
OnEnter(SceneState::Loading),
despawn_previous_scene.in_set(SceneSpawnerSystem),
)
.add_systems(PostUpdate, move_plane_with_camera.after(CameraMotionSystem));
.add_systems(
OnEnter(SceneState::Spawning),
spawn_next_scene.in_set(SceneSpawnerSystem),
)
.add_systems(
OnEnter(SceneState::Active),
(
tag_scene_elements,
spawn_main_scene_light,
frame_scene_in_view,
)
.in_set(SceneSpawnerSystem),
);
#[cfg(feature = "wasm")]
app.add_systems(
PostUpdate,
wasm::notify_entity_spawn_changes.map(bevy::utils::error),
Update,
(
wasm::dispatch_scene_request,
wasm::notify_state_changes
.map(bevy::utils::error)
.run_if(resource_changed::<State<SceneState>>()),
),
);
}
}
#[derive(Component)]
struct WorldGridPlane;
#[cfg_attr(feature = "wasm", wasm_bindgen)]
#[derive(States, Clone, Copy, Default, Debug, PartialEq, Eq, Hash)]
pub enum SceneState {
#[default]
None,
Loading,
Spawning,
Active,
}
#[derive(Resource, Deref, DerefMut)]
struct NewGltf(pub Handle<Gltf>);
struct PendingScene(pub Handle<Gltf>);
#[derive(Resource, Deref)]
pub struct ActiveScene {
#[deref]
pub instance: InstanceId,
pub source: Handle<Gltf>,
}
#[derive(Event, Deref)]
pub struct LoadSceneEvent(pub String);
// TODOL This could be converted into an enum that indicates what "kind" of
// element this is (e.g. StaticMesh, SkinnedMesh, Bone, Light, Camera).
// We don't have an immediate need for that just yet, but likely will
// soon.
/// A tag that identifies its entity as a user-facing scene element like a prop,
/// a light, or a camera.
#[derive(Component)]
pub struct SceneElement;
#[derive(Component)]
struct Inspectable;
fn initialize(
mut cmd: Commands,
assets: Res<AssetServer>,
mut ra_meshes: ResMut<Assets<Mesh>>,
mut ra_mat_proc_grid: ResMut<Assets<ProceduralGridMaterial>>,
) {
// Skybox
cmd.insert_resource(SkyboxId("test_scene".into()));
// glTF scene
cmd.insert_resource(NewGltf(assets.load("gltf/sample-room.gltf")));
pub struct SceneRoot;
fn initialize(mut cmd: Commands) {
// Camera, graphics settings
let focus = vec3(0.35011, 0.5, -0.99576);
let focus = Vec3::ZERO;
let camera_xform = Transform::from_xyz(-2.5, 4.5, 5.0).looking_at(focus, Vec3::Y);
cmd.spawn((
Name::new("Main Camera"),
@@ -104,495 +138,320 @@ fn initialize(
},
Fxaa::default(),
));
// Main light
// 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(),
intensity: 200.,
radius: 0.1,
shadows_enabled: true,
..default()
},
transform: Transform::from_xyz(0.35263, 2.4348, 0.19601),
..default()
},
));
// Monitor glow
cmd.spawn((
Name::new("Monitor Glow"),
Selectable,
Inspectable,
PointLightBundle {
point_light: PointLight {
color: Color::WHITE,
intensity: 6.,
radius: 0.25,
shadows_enabled: false,
..default()
},
transform: Transform::from_xyz(0.42615, 1.0706, -2.0705),
..default()
},
));
// Plane
let plane = ra_meshes.add(Mesh::from(shape::Plane {
size: 200.0,
subdivisions: 0,
}));
let proc_grid = ra_mat_proc_grid.add(ProceduralGridMaterial {});
cmd.spawn((
Name::new("World Grid"),
SpatialBundle::from_transform(Transform::IDENTITY),
WorldGridPlane,
))
.with_children(|parent| {
parent.spawn((
MaterialMeshBundle {
mesh: plane.clone(),
material: proc_grid.clone(),
transform: Transform::IDENTITY,
..default()
},
NotShadowCaster,
));
parent.spawn((
MaterialMeshBundle {
mesh: plane,
material: proc_grid,
transform: Transform::IDENTITY.looking_to(Vec3::Z, Vec3::NEG_Y),
..default()
},
NotShadowCaster,
));
});
}
#[derive(Default)]
struct LoadState(Handle<Gltf>, bool);
fn init_scene_loading(
mut cmd: Commands,
assets: Res<AssetServer>,
mut er: EventReader<LoadSceneEvent>,
mut st_next: ResMut<NextState<SceneState>>,
) {
if let Some(id) = er.read().last() {
let name = &**id;
cmd.insert_resource(PendingScene(assets.load(format!("gltf/{name}.gltf"))));
st_next.set(SceneState::Loading);
}
}
fn new_gltf_loaded(
r_gltf: Res<NewGltf>,
fn despawn_previous_scene(mut cmd: Commands, q_scene_root: Query<Entity, With<SceneRoot>>) {
if let Ok(scene_root) = q_scene_root.get_single() {
cmd.entity(scene_root).despawn_recursive();
}
}
fn tick_scene_loading(
r_pending_scene: Res<PendingScene>,
ra_gltfs: Res<Assets<Gltf>>,
mut er_scene_events: EventReader<AssetEvent<Scene>>,
mut l_loaded: Local<LoadState>,
) -> bool {
if l_loaded.0.path() != r_gltf.path() {
*l_loaded = LoadState(r_gltf.clone(), false);
} else if l_loaded.1 {
return true;
}
l_loaded.1 = er_scene_events.read().any(|event| match event {
AssetEvent::LoadedWithDependencies { id } => {
let gltf = ra_gltfs.get(&**r_gltf).unwrap();
let Some(default_scene) = &gltf.default_scene else {
return false;
};
*id == default_scene.id()
}
_ => false,
});
l_loaded.1
}
fn spawn_new_gltf_scene(mut cmd: Commands, ra_gltfs: Res<Assets<Gltf>>, r_gltf: Res<NewGltf>) {
let gltf = ra_gltfs.get(&**r_gltf).unwrap();
let Some(scene) = &gltf.default_scene else {
return;
};
cmd.spawn((
Name::new("Scene"),
Inspectable,
SceneBundle {
scene: scene.clone(),
..default()
},
));
}
fn gltf_scene_spawned(
r_gltf: Res<NewGltf>,
ra_gltfs: Res<Assets<Gltf>>,
q_scenes: Query<&Handle<Scene>, (With<Name>, With<Children>)>,
) -> bool {
let gltf = ra_gltfs.get(&**r_gltf).unwrap();
let Some(gltf_scene_path) = gltf.default_scene.as_ref().and_then(|handle| handle.path()) else {
warn!("No default scene found for glTF: {:#?}", gltf);
return false;
};
q_scenes.iter().any(|handle| {
let Some(path) = handle.path() else {
return false;
};
path == gltf_scene_path
})
}
fn mark_scene_objects(
mut cmd: Commands,
r_gltf: Res<NewGltf>,
ra_gltfs: Res<Assets<Gltf>>,
ra_gltf_nodes: Res<Assets<gltf::GltfNode>>,
ra_gltf_meshes: Res<Assets<gltf::GltfMesh>>,
q_parents: Query<(Entity, &Name, &Children), Without<Selectable>>,
q_primitives: Query<&Handle<Mesh>>,
mut st_next: ResMut<NextState<SceneState>>,
) {
info!("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
.get(&**name)
.cloned()
.or_else(|| {
gltf.named_nodes.get(&**name).and_then(|node_handle| {
ra_gltf_nodes
.get(node_handle)
.and_then(|node| node.mesh.clone())
})
})
.and_then(|handle| ra_gltf_meshes.get(&handle))
else {
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
.iter()
.filter_map(|prim| prim.mesh.path())
.collect::<HashSet<_>>();
// Collect all of the mesh handles in this entity's children
let prims_from_ecs = children
.iter()
.filter_map(|ent_child| {
q_primitives
.get(*ent_child)
.ok()
.and_then(|handle| handle.path())
})
.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);
for &ent_child in children.iter() {
if q_primitives.contains(ent_child) {
cmd.entity(ent_child).insert(PickableBundle::default());
}
for event in er_scene_events.read() {
if let AssetEvent::LoadedWithDependencies { id } = event {
let gltf = ra_gltfs.get(&**r_pending_scene).unwrap();
if gltf.default_scene.is_some() && gltf.default_scene.as_ref().unwrap().id() == *id {
st_next.set(SceneState::Spawning);
break;
}
}
}
cmd.remove_resource::<NewGltf>();
}
fn move_plane_with_camera(
mut plane: Query<(&WorldGridPlane, &mut Transform)>,
camera: Query<&CameraController, Changed<CameraController>>,
fn spawn_next_scene(
mut cmd: Commands,
ra_gltfs: Res<Assets<Gltf>>,
r_pending_scene: Res<PendingScene>,
mut r_scene_spawner: ResMut<SceneSpawner>,
) {
if let Ok(&CameraController { focus, .. }) = camera.get_single() {
let (_, mut xform) = plane.single_mut();
let gltf = ra_gltfs.get(&**r_pending_scene).unwrap();
let scene = gltf
.default_scene
.as_ref()
.expect("Expected glTF to include a default scene");
xform.translation.x = focus.x;
xform.translation.z = focus.z;
let parent = cmd
.spawn((
Name::new("Scene"),
SceneRoot,
SceneElement,
SpatialBundle::default(),
))
.id();
let instance = r_scene_spawner.spawn_as_child(scene.clone(), parent);
cmd.remove_resource::<PendingScene>();
cmd.insert_resource(ActiveScene {
instance,
source: r_pending_scene.clone(),
});
}
fn tick_scene_spawning(
r_scene_spawner: Res<SceneSpawner>,
r_active_scene: Res<ActiveScene>,
mut st_next: ResMut<NextState<SceneState>>,
) {
// TODO: The instance is considered "ready" even if shaders still need to be
// compiled, so this event isn't really sufficient for controlling a
// loading screen.
if r_scene_spawner.instance_is_ready(**r_active_scene) {
st_next.set(SceneState::Active);
}
}
#[derive(Asset, AsBindGroup, TypePath, Clone)]
struct ProceduralGridMaterial {}
fn tag_scene_elements(
mut cmd: Commands,
r_scene_spawner: Res<SceneSpawner>,
r_active_scene: Res<ActiveScene>,
ra_gltfs: Res<Assets<Gltf>>,
// A "glTF mesh" is actually a container for one or more "glTF primitives",
// which is basically a bundle of vertices paired with the material used to
// shade them. It's those "sub-meshes" that carry the actual mesh handles,
// but we don't necessarily want to expose those to users a la carte,
// because they often consist of stuff like a single eyeball, or just the
// leafy parts of a plant. Here we only want to tag the elements that a user
// might want to individually address when arranging or animating a scene.
q_named_non_primitives: Query<&Name, Without<Handle<Mesh>>>,
) {
let gltf = ra_gltfs.get(&r_active_scene.source).unwrap();
impl Material for ProceduralGridMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/procedural_grid.wgsl".into()
}
for ent in r_scene_spawner.iter_instance_entities(**r_active_scene) {
let Ok(name) = q_named_non_primitives.get(ent) else {
continue;
};
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Blend
if gltf.named_nodes.contains_key(&**name) {
cmd.entity(ent).insert(SceneElement);
}
}
}
fn spawn_main_scene_light(
mut cmd: Commands,
r_scene_spawner: Res<SceneSpawner>,
r_active_scene: Res<ActiveScene>,
q_aabbs: Query<(&Aabb, &GlobalTransform)>,
q_scene_root: Query<Entity, With<SceneRoot>>,
) {
let Ok(scene_root) = q_scene_root.get_single() else {
return;
};
let Some(aabb) = Aabb::combine(
r_scene_spawner
.iter_instance_entities(**r_active_scene)
.filter_map(|ent| q_aabbs.get(ent).ok()),
) else {
return;
};
let light_offset_base = vec3(0.5, 1., 1.).normalize();
let size = f32::max(
f32::max(aabb.half_extents.x, aabb.half_extents.y),
aabb.half_extents.z,
) * 2.;
let light_pos = Vec3::from(aabb.center) + light_offset_base * size * 2.;
cmd.spawn(DebugPoint {
point: light_pos,
color: Color::WHITE,
});
cmd.entity(scene_root).with_children(|cmd| {
cmd.spawn((
Name::new("Light"),
SceneElement,
SpotLightBundle {
spot_light: SpotLight {
color: Color::WHITE,
// TODO: This doesn't scale super well to very large objects
intensity: 200. * size,
radius: 0.1,
shadows_enabled: true,
shadow_normal_bias: 0.2 * size,
outer_angle: f32::to_radians(150.),
inner_angle: f32::to_radians(45.),
..default()
},
transform: Transform::from_translation(light_pos)
.looking_at(aabb.center.into(), Vec3::Y),
..default()
},
));
});
}
fn frame_scene_in_view(
mut cmd: Commands,
r_scene_spawner: Res<SceneSpawner>,
r_active_scene: Res<ActiveScene>,
q_aabbs: Query<(&Aabb, &GlobalTransform)>,
mut q_camera: Query<(&mut CameraController, &mut Transform, &Projection)>,
) {
let Ok((mut cam, mut cam_xform, cam_proj)) = q_camera.get_single_mut() else {
return;
};
let Some(aabb) = Aabb::combine(
r_scene_spawner
.iter_instance_entities(**r_active_scene)
.filter_map(|ent| q_aabbs.get(ent).ok()),
) else {
return;
};
let world2cam = cam_xform.compute_matrix().inverse();
let plane_origin = Vec3::from(aabb.center);
let plane_normal = cam_xform.forward();
let Some((_depth, bounding_rect)) = aabb
.extrema()
.iter()
.copied()
.map(|mut p| {
cmd.spawn(DebugPoint {
point: p,
color: Color::CYAN,
});
// Inflate the combined AABB to give the model some breathing room
p *= 1.5;
// Project each corner onto the camera plane
let v = p - plane_origin;
let proj = p - v.project_onto_normalized(plane_normal);
cmd.spawn(DebugPoint {
point: proj,
color: Color::FUCHSIA,
});
// Transform into the camera's local space
world2cam.transform_point(proj)
})
// Fold the camera-space plane-projected points into a bounding rect and view depth
.fold(None, |accum: Option<(f32, Rect)>, p: Vec3| match accum {
Some((depth, rect)) => Some((depth, rect.union_point(vec2(p.x, p.y)))),
None => {
let depth = p.z.abs();
let p = vec2(p.x, p.y);
Some((depth, Rect::from_corners(p, p)))
}
})
else {
return;
};
let pproj = match cam_proj {
Projection::Perspective(p) => p,
_ => panic!("Expected a perspective projection"),
};
// Formula for the target camera distance
// (c/o https://stackoverflow.com/questions/2866350/move-camera-to-fit-3d-scene):
//
// d = (s/2) / tan(a/2)
// where:
// d = distance
// s = size of the largest camera-aligned axis in world coords
// a = field-of-view angle for the same axis
let Vec2 {
x: width,
y: height,
} = bounding_rect.size();
let s = f32::max(width, height);
let a = if s == height {
pproj.fov
} else {
f32::atan(pproj.aspect_ratio * f32::tan(pproj.fov / 2.))
};
// TODO:
// According to "I eat babies" at the Ars Technica forums
// (https://arstechnica.com/civis/threads/if-you-have-aspect-ratio-and-vertical-fov-can-you-calc-horizontal-fov.37447/):
//
// tan(fov_v / 2) = y / z
// tan(fov_h / 2) = x / z
//
// Theoretically, this would let us skip some costly tan/atan
// computations, but I must be missing something, because just
// substituting (s / depth) for the divisor below would make the
// whole equation independent of the FOV, which is obviously
// wrong.
//
// Unfortunately I'm embarrassingly bad at algebra, but there
// must be some room for simplification here.
let radius = (s / 2.) / f32::tan(a / 2.);
cam.focus = aabb.center.into();
cam.radius = radius;
cam_xform.translation = cam.focus + cam_xform.back() * radius;
}
#[cfg(feature = "wasm")]
mod wasm {
use std::collections::VecDeque;
use bevy::{prelude::*, utils::HashSet, window::PrimaryWindow};
use serde::Serialize;
use bevy::{prelude::*, window::PrimaryWindow};
use wasm_bindgen::prelude::*;
use crate::wasm::{self, AsJson};
use super::{LoadSceneEvent, SceneState};
use crate::wasm::{self, StaticBuffer, WasmMessageBuffer};
use super::Inspectable;
static OBJECT_LOAD_REQUEST: WasmMessageBuffer<String> = WasmMessageBuffer::new();
#[wasm_bindgen(js_name = loadObject)]
pub fn load_object(id: &str) {
OBJECT_LOAD_REQUEST.write_message(id.into());
}
// FIXME: Is it an object or a scene? Inconsistent terminology is
// unnecessarily confusing
pub(super) fn dispatch_scene_request(mut ew_load_scene: EventWriter<LoadSceneEvent>) {
if let Some(id) = OBJECT_LOAD_REQUEST.take_message() {
ew_load_scene.send(LoadSceneEvent(id));
}
}
#[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[];
const SCENE_STATE_EVENT: &str = r#"
export interface SceneStateEvent extends CustomEvent {
type: "scene-state";
detail: SceneState;
}
declare global {
export interface HTMLElementEventMap {
"entity-spawn": EntitySpawnEvent;
"entity-despawn": EntityDespawnEvent;
"entity-multi-spawn": EntityMultiSpawnEvent;
"scene-state": SceneStateEvent;
}
}
"#;
#[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>>,
pub(super) fn notify_state_changes(
r_state: Res<State<SceneState>>,
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);
}
if let Ok(window) = q_window.get_single() {
wasm::dispatch_to_js(window, "scene-state", &(**r_state as u32))?;
}
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())
}
}
+26
View File
@@ -22,6 +22,9 @@ impl Plugin for SkyboxPlugin {
.run_if(skybox_assets_loaded),
),
);
#[cfg(feature = "wasm")]
app.add_systems(Update, wasm::set_skybox_from_frontend);
}
}
@@ -112,3 +115,26 @@ impl SkyboxPaths {
}
}
}
#[cfg(feature = "wasm")]
mod wasm {
use bevy::prelude::*;
use wasm_bindgen::prelude::*;
use crate::wasm::{StaticBuffer, WasmMessageBuffer};
use super::SkyboxId;
static SKYBOX_REQUEST: WasmMessageBuffer<String> = WasmMessageBuffer::new();
#[wasm_bindgen(js_name = setSkybox)]
pub fn set_skybox(id: &str) {
SKYBOX_REQUEST.write_message(id.to_string());
}
pub(super) fn set_skybox_from_frontend(mut cmd: Commands) {
if let Some(id) = SKYBOX_REQUEST.take_message() {
cmd.insert_resource(SkyboxId(id));
}
}
}
+4 -4
View File
@@ -50,7 +50,7 @@ pub mod wasm {
use bevy::prelude::*;
use wasm_bindgen::prelude::*;
use crate::wasm::{self, WasmMessageBuffer};
use crate::wasm::{StaticBuffer, WasmMessageBuffer};
use super::ViewportSize;
@@ -58,7 +58,7 @@ pub mod wasm {
#[wasm_bindgen]
pub fn resize(viewport_size: ViewportSize) {
wasm::write_message!(RESIZE_BUF, viewport_size);
RESIZE_BUF.write_message(viewport_size);
}
pub struct ViewportPlugin;
@@ -85,8 +85,8 @@ pub mod wasm {
}
fn dispatch_resize_events(mut events: EventWriter<ViewportResizeEvent>) {
wasm::read_buffer!(RESIZE_BUF, |resize| {
if let Some(resize) = RESIZE_BUF.take_message() {
events.send(ViewportResizeEvent(resize));
});
}
}
}
+6
View File
@@ -132,3 +132,9 @@ where
}
}
}
impl AsJsValue for u32 {
fn as_js(&self) -> Result<JsValue, String> {
Ok(JsValue::from_f64(*self as f64))
}
}
+18 -77
View File
@@ -1,90 +1,31 @@
use std::{cell::RefCell, sync::OnceLock};
use bevy::window::Window;
use parking_lot::ReentrantMutex;
use web_sys::{CustomEvent, CustomEventInit, Element};
use wasm_bindgen::prelude::wasm_bindgen;
mod conv;
mod util;
pub use conv::*;
pub(crate) use util::*;
pub(crate) type WasmMessageBuffer<T> = OnceLock<ReentrantMutex<RefCell<Option<T>>>>;
static INITIALIZED: OnceLock<ReentrantMutex<RefCell<bool>>> = OnceLock::new();
pub(crate) fn dispatch_to_js(
window: &Window,
event_type: &str,
// NOTE: We use dynamic dispatch here to keep the wasm binary small(-ish)
value: &dyn AsJsValue,
) -> Result<(), String> {
let canvas = try_get_canvas(window)?;
/// The Studio application must only be initialized once (via `init` or
/// `startViewer`). Multiple calls to either of those functions will cause the
/// application to panic. This function can be used to query whether the app has
/// already been initialized.
#[wasm_bindgen(js_name = isInitialized)]
pub fn is_initialized() -> bool {
let wrapper = INITIALIZED.get_or_init(|| ReentrantMutex::new(RefCell::new(false)));
let guard = wrapper.lock();
let result = *guard.borrow();
let detail = value.as_js()?;
let event = CustomEvent::new_with_event_init_dict(
event_type,
CustomEventInit::new()
.bubbles(true)
.cancelable(true)
.detail(&detail),
)
.map_err(|err| format!("{err:?}"))?;
canvas
.dispatch_event(&event)
.map_err(|err| format!("{err:?}"))?;
Ok(())
result
}
fn try_get_canvas(window: &Window) -> Result<Element, String> {
let canvas_selector = window
.canvas
.as_ref()
.ok_or("Canvas selector not set on Bevy Window".to_string())?;
let window = web_sys::window().ok_or("web_sys::window() returned None".to_string())?;
let document = window
.document()
.ok_or("window.document() returned None".to_string())?;
match document.query_selector(canvas_selector) {
Ok(Some(canvas)) => Ok(canvas),
Ok(None) => Err(format!(
"Failed to find element with selector '{canvas_selector}'"
)),
Err(err) => Err(format!("JavaScript threw an Error: {err:?}")),
}
pub(crate) fn initialize() {
let wrapper = INITIALIZED.get_or_init(|| ReentrantMutex::new(RefCell::new(false)));
let guard = wrapper.lock();
*guard.borrow_mut() = true;
}
macro_rules! get_buffer {
($buffer:ident) => {
$buffer.get_or_init(|| {
::parking_lot::ReentrantMutex::new(::std::cell::RefCell::new(
::core::option::Option::None,
))
})
};
}
macro_rules! write_message {
($buffer:ident, $expr:expr) => {{
let buffer = crate::wasm::get_buffer!($buffer);
if let Some(lock) = buffer.try_lock() {
*lock.borrow_mut() = Some($expr);
}
}};
}
macro_rules! read_buffer {
($buffer:ident, |$msg:ident| $stmt:stmt) => {{
let buffer = crate::wasm::get_buffer!($buffer);
if let Some(lock) = buffer.try_lock() {
if let Some($msg) = lock.borrow_mut().take() {
$stmt
}
}
}};
}
pub(crate) use get_buffer;
pub(crate) use read_buffer;
pub(crate) use write_message;
+85
View File
@@ -0,0 +1,85 @@
use std::{cell::RefCell, sync::OnceLock};
use bevy::window::Window;
use parking_lot::ReentrantMutex;
use web_sys::{CustomEvent, CustomEventInit, Element};
use super::AsJsValue;
pub(crate) type WasmMessageBuffer<T> = OnceLock<ReentrantMutex<RefCell<Option<T>>>>;
pub(crate) trait StaticBuffer<T> {
type Inner;
fn inner(&self) -> &Self::Inner;
fn write_message(&self, value: T);
fn take_message(&self) -> Option<T>;
}
impl<T> StaticBuffer<T> for WasmMessageBuffer<T> {
type Inner = ReentrantMutex<RefCell<Option<T>>>;
#[inline]
fn inner(&self) -> &Self::Inner {
self.get_or_init(|| ReentrantMutex::new(RefCell::new(None)))
}
#[inline]
fn write_message(&self, value: T) {
let mutex = self.inner();
if let Some(guard) = mutex.try_lock() {
*guard.borrow_mut() = Some(value);
}
}
#[inline]
fn take_message(&self) -> Option<T> {
let mutex = self.inner();
mutex.try_lock().and_then(|guard| guard.borrow_mut().take())
}
}
pub(crate) fn dispatch_to_js(
window: &Window,
event_type: &str,
// NOTE: We use dynamic dispatch here to keep the wasm binary small(-ish)
value: &dyn AsJsValue,
) -> Result<(), String> {
let canvas = try_get_canvas(window)?;
let detail = value.as_js()?;
let event = CustomEvent::new_with_event_init_dict(
event_type,
CustomEventInit::new()
.bubbles(true)
.cancelable(true)
.detail(&detail),
)
.map_err(|err| format!("{err:?}"))?;
canvas
.dispatch_event(&event)
.map_err(|err| format!("{err:?}"))?;
Ok(())
}
fn try_get_canvas(window: &Window) -> Result<Element, String> {
let canvas_selector = window
.canvas
.as_ref()
.ok_or("Canvas selector not set on Bevy Window".to_string())?;
let window = web_sys::window().ok_or("web_sys::window() returned None".to_string())?;
let document = window
.document()
.ok_or("window.document() returned None".to_string())?;
match document.query_selector(canvas_selector) {
Ok(Some(canvas)) => Ok(canvas),
Ok(None) => Err(format!(
"Failed to find element with selector '{canvas_selector}'"
)),
Err(err) => Err(format!("JavaScript threw an Error: {err:?}")),
}
}
+83
View File
@@ -0,0 +1,83 @@
use bevy::{
pbr::NotShadowCaster,
prelude::*,
render::render_resource::{AsBindGroup, ShaderRef},
};
use crate::camera_controller::{CameraController, CameraMotionSystem};
pub struct WorldGridPlugin;
impl Plugin for WorldGridPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(MaterialPlugin::<ProceduralGridMaterial>::default())
.add_systems(Startup, spawn_world_grid)
.add_systems(PostUpdate, move_plane_with_camera.after(CameraMotionSystem));
}
}
#[derive(Component)]
struct WorldGridPlane;
#[derive(Asset, AsBindGroup, TypePath, Clone)]
struct ProceduralGridMaterial {}
impl Material for ProceduralGridMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/procedural_grid.wgsl".into()
}
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Blend
}
}
fn spawn_world_grid(
mut cmd: Commands,
mut ra_meshes: ResMut<Assets<Mesh>>,
mut ra_mat_proc_grid: ResMut<Assets<ProceduralGridMaterial>>,
) {
let plane = ra_meshes.add(Mesh::from(shape::Plane {
size: 200.0,
subdivisions: 0,
}));
let proc_grid = ra_mat_proc_grid.add(ProceduralGridMaterial {});
cmd.spawn((
Name::new("World Grid"),
SpatialBundle::from_transform(Transform::IDENTITY),
WorldGridPlane,
))
.with_children(|parent| {
parent.spawn((
MaterialMeshBundle {
mesh: plane.clone(),
material: proc_grid.clone(),
transform: Transform::IDENTITY,
..default()
},
NotShadowCaster,
));
parent.spawn((
MaterialMeshBundle {
mesh: plane,
material: proc_grid,
transform: Transform::IDENTITY.looking_to(Vec3::Z, Vec3::NEG_Y),
..default()
},
NotShadowCaster,
));
});
}
fn move_plane_with_camera(
mut plane: Query<(&WorldGridPlane, &mut Transform)>,
camera: Query<&CameraController, Changed<CameraController>>,
) {
if let Ok(&CameraController { focus, .. }) = camera.get_single() {
let (_, mut xform) = plane.single_mut();
xform.translation.x = focus.x;
xform.translation.z = focus.z;
}
}