use std::cell::RefCell;
use std::rc::Rc;
use crate::picking::{entity_under_cursor, request_surface_pick};
use crate::transfer::{
app_value_from, attach, attachments_from, control_value_from, envelope_with,
};
use crate::wire::{
APP_KEY, Attachments, CANVAS_KEY, FromWorker, MESSAGE_KEY, SelectedEntity, ToWorker,
ValueProtocol, WorkerProtocol,
};
use nightshade::prelude::*;
use nightshade::render::wgpu::create_wgpu_renderer;
use serde::Serialize;
use serde_json::Value;
use wasm_bindgen::prelude::*;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::spawn_local;
use web_sys::{DedicatedWorkerGlobalScope, MessageEvent, OffscreenCanvas};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PickMode {
CursorRay,
Gpu,
Manual,
}
#[derive(Clone, Copy, Debug)]
pub struct OffscreenConfig {
pub camera_controllers: bool,
pub pick_mode: PickMode,
}
impl Default for OffscreenConfig {
fn default() -> Self {
Self {
camera_controllers: true,
pick_mode: PickMode::CursorRay,
}
}
}
#[derive(Default)]
pub struct HostSelection(pub Option<Entity>);
pub struct WorkerOutbox<Outgoing> {
pub messages: Vec<Outgoing>,
pub attachments: Vec<(Outgoing, Attachments)>,
}
impl<Outgoing> Default for WorkerOutbox<Outgoing> {
fn default() -> Self {
Self {
messages: Vec::new(),
attachments: Vec::new(),
}
}
}
impl<Outgoing> WorkerOutbox<Outgoing> {
pub fn send(&mut self, message: Outgoing) {
self.messages.push(message);
}
pub fn send_with_attachments(&mut self, message: Outgoing, attachments: Attachments) {
self.attachments.push((message, attachments));
}
}
thread_local! {
static HOST_CANVAS: RefCell<Option<OffscreenCanvas>> = const { RefCell::new(None) };
static LOG_GUARDS: RefCell<Vec<Box<dyn std::any::Any>>> = const { RefCell::new(Vec::new()) };
}
pub fn host_canvas() -> Option<OffscreenCanvas> {
HOST_CANVAS.with(|canvas| canvas.borrow().clone())
}
type MessageFn<P> =
Box<dyn FnMut(&mut World, <P as WorkerProtocol>::Incoming, Attachments) + 'static>;
type PostFrameFn = Box<dyn FnMut(&mut World) + 'static>;
struct HostHandlers<P: WorkerProtocol> {
on_message: MessageFn<P>,
post_frame: Vec<PostFrameFn>,
}
pub struct WorkerHostPlugin<P: WorkerProtocol> {
config: OffscreenConfig,
handlers: RefCell<Option<HostHandlers<P>>>,
}
impl<P: WorkerProtocol + 'static> WorkerHostPlugin<P> {
pub fn new(
config: OffscreenConfig,
on_message: impl FnMut(&mut World, P::Incoming, Attachments) + 'static,
) -> Self {
Self {
config,
handlers: RefCell::new(Some(HostHandlers {
on_message: Box::new(on_message),
post_frame: Vec::new(),
})),
}
}
pub fn new_scoped<T: Send + Sync + 'static>(
config: OffscreenConfig,
mut on_message: impl FnMut(&mut T, &mut World, P::Incoming, Attachments) + 'static,
) -> Self {
Self::new(config, move |world, message, attachments| {
game_scope(world, |state: &mut T, world| {
on_message(state, world, message, attachments)
});
})
}
pub fn with_post_frame(self, hook: impl FnMut(&mut World) + 'static) -> Self {
if let Some(handlers) = self.handlers.borrow_mut().as_mut() {
handlers.post_frame.push(Box::new(hook));
}
self
}
pub fn with_scoped_post_frame<T: Send + Sync + 'static>(
self,
mut hook: impl FnMut(&mut T, &mut World) + 'static,
) -> Self {
self.with_post_frame(move |world| {
game_scope(world, |state: &mut T, world| hook(state, world));
})
}
}
impl<P: WorkerProtocol + 'static> Plugin for WorkerHostPlugin<P> {
fn build(&self, app: &mut App) {
let handlers = self
.handlers
.borrow_mut()
.take()
.expect("WorkerHostPlugin can only be added once");
app.insert_resource(WorkerOutbox::<P::Outgoing>::default());
app.insert_resource(HostSelection::default());
if self.config.camera_controllers {
app.add_system(Stage::First, camera_controllers_system);
}
let config = self.config;
app.set_runner(move |app| {
run_worker::<P>(app, config, handlers);
Ok(())
});
}
}
struct Host<P: WorkerProtocol> {
world: World,
renderer: WgpuRenderer,
state: Box<dyn State>,
config: OffscreenConfig,
handlers: HostHandlers<P>,
}
struct PendingHost<P: WorkerProtocol> {
world: World,
state: Box<dyn State>,
config: OffscreenConfig,
handlers: HostHandlers<P>,
}
type HostSlot<P> = Rc<RefCell<Option<Host<P>>>>;
type Pending<P> = Rc<RefCell<Option<PendingHost<P>>>>;
type PendingMessages = Rc<RefCell<Vec<JsValue>>>;
fn run_worker<P: WorkerProtocol + 'static>(
mut app: App,
config: OffscreenConfig,
handlers: HostHandlers<P>,
) {
console_error_panic_hook::set_once();
let log_guards = app.take_log_guards();
LOG_GUARDS.with(|slot| *slot.borrow_mut() = log_guards);
let (world, state) = app.into_parts();
let pending: Pending<P> = Rc::new(RefCell::new(Some(PendingHost {
world,
state: Box::new(state),
config,
handlers,
})));
let host: HostSlot<P> = Rc::new(RefCell::new(None));
let queued: PendingMessages = Rc::new(RefCell::new(Vec::new()));
let scope: DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
let handler_scope = scope.clone();
let onmessage = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
handle_data(&handler_scope, &host, &queued, &pending, event.data());
});
scope.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
onmessage.forget();
}
fn handle_data<P: WorkerProtocol + 'static>(
scope: &DedicatedWorkerGlobalScope,
host: &HostSlot<P>,
queued: &PendingMessages,
pending: &Pending<P>,
data: JsValue,
) {
let control = control_from(&data);
if host.borrow().is_none() && !matches!(control, Some(ToWorker::Init { .. })) {
queued.borrow_mut().push(data);
return;
}
match control {
Some(ToWorker::Init { width, height }) => {
let Some(canvas) = canvas_from(&data) else {
return;
};
let Some(parts) = pending.borrow_mut().take() else {
return;
};
let scope = scope.clone();
let host = host.clone();
let queued = queued.clone();
let pending = pending.clone();
spawn_local(async move {
let ready = create_host(canvas, width, height, parts).await;
*host.borrow_mut() = Some(ready);
let backlog = std::mem::take(&mut *queued.borrow_mut());
for data in backlog {
handle_data(&scope, &host, &queued, &pending, data);
}
post_control(&FromWorker::Ready {
adapter: "WebGPU".to_string(),
});
start_render_loop(host);
});
}
Some(ToWorker::Resize { width, height }) => {
if let Some(host) = host.borrow_mut().as_mut() {
let physical_width = (width as u32).max(1);
let physical_height = (height as u32).max(1);
resize_offscreen(
&mut host.world,
&mut host.renderer,
physical_width,
physical_height,
);
host.world.resources.window.active_viewport_rect =
Some(nightshade::render::config::ViewportRect {
x: 0.0,
y: 0.0,
width: physical_width as f32,
height: physical_height as f32,
});
}
}
Some(message) => {
if let Some(host) = host.borrow_mut().as_mut() {
apply_control(host, message);
}
}
None => {
if let Some(host) = host.borrow_mut().as_mut()
&& let Some(message) = app_message_from::<P>(&data)
{
let attachments = attachments_from(&data);
(host.handlers.on_message)(&mut host.world, message, attachments);
}
}
}
}
fn apply_control<P: WorkerProtocol>(host: &mut Host<P>, message: ToWorker) {
let world = &mut host.world;
match message {
ToWorker::PointerMove { x, y } => input_inject_cursor_moved(world, Vec2::new(x, y)),
ToWorker::PointerMotion { dx, dy } => {
input_inject_mouse_motion(world, Vec2::new(dx, dy));
}
ToWorker::PointerButton { button, pressed } => {
let state = if pressed {
KeyState::Pressed
} else {
KeyState::Released
};
input_inject_mouse_button(world, mouse_button(button), state);
}
ToWorker::Wheel { delta } => {
input_inject_mouse_wheel(world, Vec2::new(0.0, -delta / 100.0))
}
ToWorker::Touch { id, phase, x, y } => {
input_inject_touch(world, id, touch_phase(phase), Vec2::new(x, y));
}
ToWorker::Key {
code,
pressed,
text,
} => {
if let Some(key_code) = key_code_from_dom(&code) {
let state = if pressed {
KeyState::Pressed
} else {
KeyState::Released
};
input_inject_keyboard(world, key_code, state, text.as_deref());
world.resources.input.events.push(AppEvent::Keyboard {
key: key_code,
state,
});
}
}
ToWorker::Pick { x, y } => {
let position = Vec2::new(x.max(0.0), y.max(0.0));
input_inject_cursor_moved(world, position);
match host.config.pick_mode {
PickMode::CursorRay => {
let entity = entity_under_cursor(world);
if let Some(selection) = world.ecs.resource_mut::<HostSelection>() {
selection.0 = entity;
}
world.resources.selection.active_entity = entity;
world.resources.selection.entities = entity.into_iter().collect();
let detail = entity.map(|entity| SelectedEntity {
id: entity.id,
name: world
.get::<nightshade::ecs::primitives::Name>(entity)
.map(|name| name.0.clone())
.unwrap_or_default(),
});
post_control(&FromWorker::Selected { detail });
}
PickMode::Gpu => request_surface_pick(world, position),
PickMode::Manual => {}
}
}
ToWorker::Init { .. } | ToWorker::Resize { .. } => {}
}
}
async fn create_host<P: WorkerProtocol>(
canvas: OffscreenCanvas,
width: f32,
height: f32,
parts: PendingHost<P>,
) -> Host<P> {
let physical_width = (width as u32).max(1);
let physical_height = (height as u32).max(1);
HOST_CANVAS.with(|slot| *slot.borrow_mut() = Some(canvas.clone()));
let surface_target = wgpu::SurfaceTarget::OffscreenCanvas(canvas);
let mut renderer = create_wgpu_renderer(surface_target, physical_width, physical_height)
.await
.expect("failed to create renderer from offscreen canvas");
let PendingHost {
mut world,
mut state,
config,
handlers,
} = parts;
initialize_offscreen(
&mut world,
state.as_mut(),
&mut renderer,
(physical_width, physical_height),
1.0,
);
world.resources.window.active_viewport_rect = Some(nightshade::render::config::ViewportRect {
x: 0.0,
y: 0.0,
width: physical_width as f32,
height: physical_height as f32,
});
Host {
world,
renderer,
state,
config,
handlers,
}
}
fn start_render_loop<P: WorkerProtocol + 'static>(host: HostSlot<P>) {
let last_push = Rc::new(RefCell::new(0.0_f64));
spawn_animation_frame_loop(move || {
if let Some(host) = host.borrow_mut().as_mut() {
tick_offscreen(&mut host.world, host.state.as_mut(), &mut host.renderer);
for hook in &mut host.handlers.post_frame {
hook(&mut host.world);
}
drain_outbox::<P>(&mut host.world);
let scope: DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
if let Some(performance) = scope.performance() {
let now = performance.now();
let mut last = last_push.borrow_mut();
if now - *last > 500.0 {
*last = now;
let entity_count = host.world.ecs.worlds[CORE]
.query_entities(
nightshade::ecs::world::LOCAL_TRANSFORM
| nightshade::ecs::world::GLOBAL_TRANSFORM,
)
.count() as u32;
post_control(&FromWorker::Stats {
fps: host.world.resources.window.timing.frames_per_second,
entity_count,
});
}
}
}
});
}
fn drain_outbox<P: WorkerProtocol>(world: &mut World) {
let (messages, attachments) = {
let Some(outbox) = world.ecs.resource_mut::<WorkerOutbox<P::Outgoing>>() else {
return;
};
(
std::mem::take(&mut outbox.messages),
std::mem::take(&mut outbox.attachments),
)
};
for message in &messages {
post_app_message(message);
}
for (message, attachments) in &attachments {
post_app_message_with_attachments(message, attachments);
}
}
pub fn post_app_message<Message: Serialize>(message: &Message) {
let scope: DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
if let Some(envelope) = envelope_with(APP_KEY, message) {
drop(scope.post_message(&envelope));
}
}
pub fn post_app_message_with_attachments<Message: Serialize>(
message: &Message,
attachments: &[(String, Vec<u8>)],
) {
let scope: DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
let Some(envelope) = envelope_with(APP_KEY, message) else {
return;
};
let transfer = js_sys::Array::new();
if !attach(&envelope, &transfer, attachments) {
return;
}
drop(scope.post_message_with_transfer(&envelope, &transfer));
}
pub fn post_control(message: &FromWorker) {
let scope: DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
if let Some(envelope) = envelope_with(MESSAGE_KEY, message) {
drop(scope.post_message(&envelope));
}
}
pub fn post_custom<Message: Serialize>(message: &Message) {
post_app_message(message);
}
pub fn run_offscreen<Scene, Setup, Tick, OnCustom>(
config: OffscreenConfig,
scene: Scene,
setup: Setup,
tick: Tick,
mut on_custom: OnCustom,
) where
Scene: Send + Sync + 'static,
Setup: FnOnce(&mut Scene, &mut World) + Send + 'static,
Tick: FnMut(&mut Scene, &mut World) + Send + 'static,
OnCustom: FnMut(&mut Scene, &mut World, Option<Entity>, Value) + 'static,
{
let host = WorkerHostPlugin::<ValueProtocol>::new(config, move |world, value, _attachments| {
let selected = world
.ecs
.resource::<HostSelection>()
.and_then(|selection| selection.0);
game_scope(world, |scene: &mut Scene, world| {
on_custom(scene, world, selected, value);
});
});
let mut setup = Some(setup);
let mut app = App::new().add_plugin(host);
app.insert_resource(scene);
app.add_system(
Stage::Startup,
move |scene: &mut Scene, world: &mut World| {
if let Some(setup) = setup.take() {
setup(scene, world);
}
},
);
app.add_system(Stage::Update, tick);
app.run().expect("offscreen worker failed to start");
}
fn control_from(data: &JsValue) -> Option<ToWorker> {
serde_wasm_bindgen::from_value::<ToWorker>(control_value_from(data)?).ok()
}
fn app_message_from<P: WorkerProtocol>(data: &JsValue) -> Option<P::Incoming> {
serde_wasm_bindgen::from_value::<P::Incoming>(app_value_from(data)?).ok()
}
fn mouse_button(button: u8) -> MouseButton {
match button {
1 => MouseButton::Middle,
2 => MouseButton::Right,
_ => MouseButton::Left,
}
}
fn touch_phase(phase: crate::wire::TouchPhase) -> TouchPhase {
match phase {
crate::wire::TouchPhase::Started => TouchPhase::Started,
crate::wire::TouchPhase::Moved => TouchPhase::Moved,
crate::wire::TouchPhase::Ended => TouchPhase::Ended,
crate::wire::TouchPhase::Cancelled => TouchPhase::Cancelled,
}
}
fn canvas_from(data: &JsValue) -> Option<OffscreenCanvas> {
js_sys::Reflect::get(data, &JsValue::from_str(CANVAS_KEY))
.ok()
.and_then(|value| value.dyn_into::<OffscreenCanvas>().ok())
}
pub fn key_code_from_dom(code: &str) -> Option<KeyCode> {
Some(match code {
"KeyA" => KeyCode::KeyA,
"KeyB" => KeyCode::KeyB,
"KeyC" => KeyCode::KeyC,
"KeyD" => KeyCode::KeyD,
"KeyE" => KeyCode::KeyE,
"KeyF" => KeyCode::KeyF,
"KeyG" => KeyCode::KeyG,
"KeyH" => KeyCode::KeyH,
"KeyI" => KeyCode::KeyI,
"KeyJ" => KeyCode::KeyJ,
"KeyK" => KeyCode::KeyK,
"KeyL" => KeyCode::KeyL,
"KeyM" => KeyCode::KeyM,
"KeyN" => KeyCode::KeyN,
"KeyO" => KeyCode::KeyO,
"KeyP" => KeyCode::KeyP,
"KeyQ" => KeyCode::KeyQ,
"KeyR" => KeyCode::KeyR,
"KeyS" => KeyCode::KeyS,
"KeyT" => KeyCode::KeyT,
"KeyU" => KeyCode::KeyU,
"KeyV" => KeyCode::KeyV,
"KeyW" => KeyCode::KeyW,
"KeyX" => KeyCode::KeyX,
"KeyY" => KeyCode::KeyY,
"KeyZ" => KeyCode::KeyZ,
"Digit0" => KeyCode::Digit0,
"Digit1" => KeyCode::Digit1,
"Digit2" => KeyCode::Digit2,
"Digit3" => KeyCode::Digit3,
"Digit4" => KeyCode::Digit4,
"Digit5" => KeyCode::Digit5,
"Digit6" => KeyCode::Digit6,
"Digit7" => KeyCode::Digit7,
"Digit8" => KeyCode::Digit8,
"Digit9" => KeyCode::Digit9,
"Escape" => KeyCode::Escape,
"Enter" => KeyCode::Enter,
"NumpadEnter" => KeyCode::NumpadEnter,
"Tab" => KeyCode::Tab,
"Space" => KeyCode::Space,
"Delete" => KeyCode::Delete,
"Backspace" => KeyCode::Backspace,
"Home" => KeyCode::Home,
"End" => KeyCode::End,
"ArrowLeft" => KeyCode::ArrowLeft,
"ArrowRight" => KeyCode::ArrowRight,
"ArrowUp" => KeyCode::ArrowUp,
"ArrowDown" => KeyCode::ArrowDown,
"ShiftLeft" => KeyCode::ShiftLeft,
"ShiftRight" => KeyCode::ShiftRight,
"ControlLeft" => KeyCode::ControlLeft,
"ControlRight" => KeyCode::ControlRight,
"AltLeft" => KeyCode::AltLeft,
"AltRight" => KeyCode::AltRight,
"F1" => KeyCode::F1,
"F2" => KeyCode::F2,
"F3" => KeyCode::F3,
"F4" => KeyCode::F4,
"F5" => KeyCode::F5,
"F6" => KeyCode::F6,
"F7" => KeyCode::F7,
"F8" => KeyCode::F8,
"F9" => KeyCode::F9,
"F10" => KeyCode::F10,
"F11" => KeyCode::F11,
"F12" => KeyCode::F12,
"Comma" => KeyCode::Comma,
"Period" => KeyCode::Period,
"Minus" => KeyCode::Minus,
"Equal" => KeyCode::Equal,
_ => return None,
})
}