diff --git a/Cargo.lock b/Cargo.lock index eb171a6..6546e7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4376,6 +4376,7 @@ dependencies = [ "bevy", "crossbeam-channel", "half", + "js-sys", "lyon", "objc2 0.6.3", "objc2-app-kit 0.3.2", @@ -4383,9 +4384,25 @@ dependencies = [ "thiserror 2.0.17", "tracing", "tracing-subscriber", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "windows 0.58.0", ] +[[package]] +name = "processing_wasm" +version = "0.1.0" +dependencies = [ + "bevy", + "console_error_panic_hook", + "js-sys", + "processing_render", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "profiling" version = "1.0.17" @@ -5691,6 +5708,7 @@ dependencies = [ "smallvec", "static_assertions", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", "wgpu-core", "wgpu-hal", diff --git a/Cargo.toml b/Cargo.toml index 5557513..64a7d16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,13 @@ glfw = { version = "0.60.0", features = ["wayland"] } [[example]] name = "rectangle" path = "examples/rectangle.rs" + +[[example]] +name = "background_image" +path = "examples/background_image.rs" + +[profile.wasm-release] +inherits = "release" +opt-level = "z" +lto = "fat" +codegen-units = 1 diff --git a/README.md b/README.md index d95d851..ae0fc95 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,43 @@ libprocessing is an experimental native library with the goal of supporting the You'll need to install the Rust toolchain to work on this project. Most users will want to install Rust via [`rustup`](https://rustup.rs/), which helps manage Rust toolchain versions. +### Build commands + +This project uses [just](https://github.com/casey/just) as a command runner. Run `just` to see available commands. + +## Building for web + +The `processing_wasm` crate provides WebAssembly bindings that expose a JavaScript API mirroring the C FFI. + +### Requirements + +Install [wasm-pack](https://rustwasm.github.io/wasm-pack/): + +```bash +cargo install wasm-pack +``` + +You'll also need the wasm32 target: + +```bash +rustup target add wasm32-unknown-unknown +``` + +### Build + +```bash +just wasm-build +``` + +This outputs the package to `target/wasm/`. + +### Run the example + +```bash +just wasm-serve +``` + + ## Contributing We want your help building this library! You can see a list of outstanding tasks in our [issues](https://github.com/processing/libprocessing). However, while we're still in the early phases, consider checking in with us first in the `#devs-chat` channel on [Discord](https://discord.gg/h99u95nU7q) to coordinate our efforts. diff --git a/crates/processing_render/Cargo.toml b/crates/processing_render/Cargo.toml index 14727ee..fd56f6a 100644 --- a/crates/processing_render/Cargo.toml +++ b/crates/processing_render/Cargo.toml @@ -21,4 +21,10 @@ objc2 = { version = "0.6", default-features = false } objc2-app-kit = { version = "0.3", features = ["NSWindow", "NSView"] } [target.'cfg(target_os = "windows")'.dependencies] -windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_LibraryLoader"] } \ No newline at end of file +windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_LibraryLoader"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = ["Window", "Document", "HtmlCanvasElement"] } \ No newline at end of file diff --git a/crates/processing_render/src/image.rs b/crates/processing_render/src/image.rs index 1d6b25b..e0c2335 100644 --- a/crates/processing_render/src/image.rs +++ b/crates/processing_render/src/image.rs @@ -107,6 +107,57 @@ pub fn create( .expect("Failed to run new PImage system") } +pub fn load_start(world: &mut World, path: PathBuf) -> Handle { + world.get_asset_server().load(path) +} + +pub fn is_loaded(world: &World, handle: &Handle) -> bool { + matches!( + world.get_asset_server().load_state(handle), + LoadState::Loaded + ) +} + +#[cfg(target_arch = "wasm32")] +pub fn from_handle(world: &mut World, handle: Handle) -> Result { + fn from_handle_inner(In(handle): In>, world: &mut World) -> Result { + let images = world.resource::>(); + let image = images.get(&handle).ok_or(ProcessingError::ImageNotFound)?; + + let size = image.texture_descriptor.size; + let texture_format = image.texture_descriptor.format; + let pixel_size = match texture_format { + TextureFormat::Rgba8Unorm | TextureFormat::Rgba8UnormSrgb => 4usize, + TextureFormat::Rgba16Float => 8, + TextureFormat::Rgba32Float => 16, + _ => panic!("Unsupported texture format for readback"), + }; + let readback_buffer_size = size.width * size.height * pixel_size as u32; + + let render_device = world.resource::(); + let readback_buffer = render_device.create_buffer(&BufferDescriptor { + label: Some("PImage Readback Buffer"), + size: readback_buffer_size as u64, + usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + Ok(world + .spawn(PImage { + handle: handle.clone(), + readback_buffer, + pixel_size, + texture_format, + size, + }) + .id()) + } + + world + .run_system_cached_with(from_handle_inner, handle) + .expect("Failed to run from_handle system") +} + pub fn load(world: &mut World, path: PathBuf) -> Result { fn load_inner(In(path): In, world: &mut World) -> Result { let handle = world.get_asset_server().load(path); diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index e4de6e3..16ad051 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -2,7 +2,10 @@ pub mod error; pub mod image; pub mod render; -use std::{cell::RefCell, ffi::c_void, num::NonZero, path::PathBuf, ptr::NonNull, sync::OnceLock}; +use std::{cell::RefCell, num::NonZero, path::PathBuf, ptr::NonNull, sync::OnceLock}; + +#[cfg(any(target_os = "linux", target_arch = "wasm32"))] +use std::ffi::c_void; use bevy::{ app::{App, AppExit}, @@ -267,6 +270,22 @@ pub fn surface_create( ) }; + #[cfg(target_arch = "wasm32")] + let (raw_window_handle, raw_display_handle) = { + use raw_window_handle::{WebCanvasWindowHandle, WebDisplayHandle}; + + // window_handle is a pointer to HtmlCanvasElement DOM obj + let canvas_ptr = window_handle as *mut c_void; + let canvas = NonNull::new(canvas_ptr).ok_or(error::ProcessingError::InvalidWindowHandle)?; + + let window = WebCanvasWindowHandle::new(canvas); + let display = WebDisplayHandle::new(); + ( + RawWindowHandle::WebCanvas(window), + RawDisplayHandle::Web(display), + ) + }; + let glfw_window = GlfwWindow { window_handle: raw_window_handle, display_handle: raw_display_handle, @@ -324,6 +343,34 @@ pub fn surface_create( Ok(entity_id) } +/// Create a WebGPU surface from a canvas element ID +#[cfg(target_arch = "wasm32")] +pub fn surface_create_from_canvas(canvas_id: &str, width: u32, height: u32) -> Result { + use wasm_bindgen::JsCast; + use web_sys::HtmlCanvasElement; + + // find the canvas elelment + let web_window = web_sys::window().ok_or(error::ProcessingError::InvalidWindowHandle)?; + let document = web_window + .document() + .ok_or(error::ProcessingError::InvalidWindowHandle)?; + let canvas = document + .get_element_by_id(canvas_id) + .ok_or(error::ProcessingError::InvalidWindowHandle)? + .dyn_into::() + .map_err(|_| error::ProcessingError::InvalidWindowHandle)?; + + // box and leak the canvas to ensure the pointer remains valid + // TODO: this is maybe gross, let's find a better way to manage the lifetime + let canvas_box = Box::new(canvas); + let canvas_ptr = Box::into_raw(canvas_box) as u64; + + // TODO: not sure if this is right to force here + let scale_factor = 1.0; + + surface_create(canvas_ptr, 0, width, height, scale_factor) +} + pub fn surface_destroy(window_entity: Entity) -> Result<()> { app_mut(|app| { if app.world_mut().get::(window_entity).is_some() { @@ -350,10 +397,42 @@ pub fn surface_resize(window_entity: Entity, width: u32, height: u32) -> Result< }) } -/// Initialize the app, if not already initialized. Must be called from the main thread and cannot -/// be called concurrently from multiple threads. -pub fn init() -> Result<()> { - setup_tracing()?; +fn create_app() -> App { + let mut app = App::new(); + + #[cfg(not(target_arch = "wasm32"))] + let plugins = DefaultPlugins + .build() + .disable::() + .disable::() + .disable::() + .set(WindowPlugin { + primary_window: None, + exit_condition: bevy::window::ExitCondition::DontExit, + ..default() + }); + + #[cfg(target_arch = "wasm32")] + let plugins = DefaultPlugins + .build() + .disable::() + .disable::() + .disable::() + .set(WindowPlugin { + primary_window: None, + exit_condition: bevy::window::ExitCondition::DontExit, + ..default() + }); + + app.add_plugins(plugins); + app.init_resource::(); + app.add_systems(First, (clear_transient_meshes, activate_cameras)) + .add_systems(Update, flush_draw_commands.before(AssetEventSystems)); + + app +} + +fn is_already_init() -> Result { let is_init = IS_INIT.get().is_some(); let thread_has_app = APP.with(|app_cell| app_cell.borrow().is_some()); if is_init && !thread_has_app { @@ -361,42 +440,63 @@ pub fn init() -> Result<()> { } if is_init && thread_has_app { debug!("App already initialized"); - return Ok(()); + return Ok(true); } + Ok(false) +} +fn set_app(app: App) { APP.with(|app_cell| { - if app_cell.borrow().is_none() { - IS_INIT.get_or_init(|| ()); - let mut app = App::new(); - - app.add_plugins( - DefaultPlugins - .build() - .disable::() - .disable::() - .disable::() - .set(WindowPlugin { - primary_window: None, - exit_condition: bevy::window::ExitCondition::DontExit, - ..default() - }), - ); - - // resources - app.init_resource::(); - - // rendering - app.add_systems(First, (clear_transient_meshes, activate_cameras)) - .add_systems(Update, flush_draw_commands.before(AssetEventSystems)); - - // this does not mean, as one might imagine, that the app is "done", but rather is part - // of bevy's plugin lifecycle prior to "starting" the app. we are manually driving the app - // so we don't need to call `app.run()` - app.finish(); - app.cleanup(); - *app_cell.borrow_mut() = Some(app); - } + IS_INIT.get_or_init(|| ()); + *app_cell.borrow_mut() = Some(app); }); +} + +/// Initialize the app, if not already initialized. Must be called from the main thread and cannot +/// be called concurrently from multiple threads. +#[cfg(not(target_arch = "wasm32"))] +pub fn init() -> Result<()> { + setup_tracing()?; + if is_already_init()? { + return Ok(()); + } + + let mut app = create_app(); + app.finish(); + app.cleanup(); + set_app(app); + + Ok(()) +} + +/// Initialize the app asynchronously +#[cfg(target_arch = "wasm32")] +pub async fn init() -> Result<()> { + use bevy::app::PluginsState; + + setup_tracing()?; + if is_already_init()? { + return Ok(()); + } + + let mut app = create_app(); + + // we need to avoid blocking the main thread while waiting for plugins to initialize + while app.plugins_state() == PluginsState::Adding { + // yield to event loop + wasm_bindgen_futures::JsFuture::from(js_sys::Promise::new(&mut |resolve, _| { + web_sys::window() + .unwrap() + .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 0) + .unwrap(); + })) + .await + .unwrap(); + } + + app.finish(); + app.cleanup(); + set_app(app); Ok(()) } @@ -496,8 +596,12 @@ pub fn background_color(window_entity: Entity, color: Color) -> Result<()> { } fn setup_tracing() -> Result<()> { - let subscriber = tracing_subscriber::FmtSubscriber::new(); - tracing::subscriber::set_global_default(subscriber)?; + // TODO: figure out wasm compatible tracing subscriber + #[cfg(not(target_arch = "wasm32"))] + { + let subscriber = tracing_subscriber::FmtSubscriber::new(); + tracing::subscriber::set_global_default(subscriber)?; + } Ok(()) } @@ -522,12 +626,47 @@ pub fn image_create( app_mut(|app| Ok(image::create(app.world_mut(), size, data, texture_format))) } -/// Load an image from disk. +#[cfg(not(target_arch = "wasm32"))] pub fn image_load(path: &str) -> Result { let path = PathBuf::from(path); app_mut(|app| image::load(app.world_mut(), path)) } +#[cfg(target_arch = "wasm32")] +pub async fn image_load(path: &str) -> Result { + use bevy::prelude::{Handle, Image}; + + let path = PathBuf::from(path); + + let handle: Handle = app_mut(|app| Ok(image::load_start(app.world_mut(), path)))?; + + // poll until loaded, yielding to event loop + loop { + let is_loaded = app_mut(|app| Ok(image::is_loaded(app.world(), &handle)))?; + if is_loaded { + break; + } + + // yield to let fetch complete + wasm_bindgen_futures::JsFuture::from(js_sys::Promise::new(&mut |resolve, _| { + web_sys::window() + .unwrap() + .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 0) + .unwrap(); + })) + .await + .unwrap(); + + // run an update to process asset events + app_mut(|app| { + app.update(); + Ok(()) + })?; + } + + app_mut(|app| image::from_handle(app.world_mut(), handle)) +} + /// Resize an existing image to new size. pub fn image_resize(entity: Entity, new_size: Extent3d) -> Result<()> { app_mut(|app| image::resize(app.world_mut(), entity, new_size)) diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index a5689d6..4f95c09 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -275,7 +275,7 @@ fn add_stroke(ctx: &mut RenderContext, tessellate: impl FnOnce(&mut Mesh, Color, fn flush_batch(ctx: &mut RenderContext) { if let Some(mesh) = ctx.batch.current_mesh.take() { // we defensively apply a small z-offset based on draw_index to preserve painter's algorithm - let z_offset = ctx.batch.draw_index as f32 * 0.001; + let z_offset = -(ctx.batch.draw_index as f32 * 0.001); spawn_mesh(ctx, mesh, z_offset); ctx.batch.draw_index += 1; } diff --git a/crates/processing_wasm/Cargo.toml b/crates/processing_wasm/Cargo.toml new file mode 100644 index 0000000..501a7cd --- /dev/null +++ b/crates/processing_wasm/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "processing_wasm" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true + +[dependencies] +processing_render = { workspace = true } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +console_error_panic_hook = "0.1" + +[dependencies.web-sys] +version = "0.3" +features = [ + "Window", + "Document", + "Element", + "HtmlCanvasElement", + "console", +] + +[dependencies.bevy] +git = "https://github.com/bevyengine/bevy" +branch = "main" +default-features = false +features = ["bevy_render", "bevy_color", "webgpu", "web"] diff --git a/crates/processing_wasm/examples/assets b/crates/processing_wasm/examples/assets new file mode 120000 index 0000000..2978ef3 --- /dev/null +++ b/crates/processing_wasm/examples/assets @@ -0,0 +1 @@ +../../../assets \ No newline at end of file diff --git a/crates/processing_wasm/examples/index.html b/crates/processing_wasm/examples/index.html new file mode 100644 index 0000000..20bbd70 --- /dev/null +++ b/crates/processing_wasm/examples/index.html @@ -0,0 +1,59 @@ + + + + + Processing + + + + + + + diff --git a/crates/processing_wasm/src/lib.rs b/crates/processing_wasm/src/lib.rs new file mode 100644 index 0000000..f5359c7 --- /dev/null +++ b/crates/processing_wasm/src/lib.rs @@ -0,0 +1,187 @@ +use bevy::prelude::Entity; +use processing_render::{ + begin_draw, end_draw, exit, flush, image_create, image_destroy, image_load, image_load_pixels, + image_resize, init, record_command, render::command::DrawCommand, surface_create_from_canvas, + surface_destroy, surface_resize, +}; +use wasm_bindgen::prelude::*; + +fn check(result: Result) -> Result { + result.map_err(|e| JsValue::from_str(&e.to_string())) +} + +#[wasm_bindgen(start)] +fn wasm_start() { + console_error_panic_hook::set_once(); +} + +#[wasm_bindgen(js_name = "init")] +pub async fn js_init() -> Result<(), JsValue> { + check(init().await) +} + +#[wasm_bindgen(js_name = "createSurface")] +pub fn js_surface_create(canvas_id: &str, width: u32, height: u32) -> Result { + check(surface_create_from_canvas(canvas_id, width, height).map(|e| e.to_bits())) +} + +#[wasm_bindgen(js_name = "destroySurface")] +pub fn js_surface_destroy(surface_id: u64) -> Result<(), JsValue> { + check(surface_destroy(Entity::from_bits(surface_id))) +} + +#[wasm_bindgen(js_name = "resizeSurface")] +pub fn js_surface_resize(surface_id: u64, width: u32, height: u32) -> Result<(), JsValue> { + check(surface_resize(Entity::from_bits(surface_id), width, height)) +} + +#[wasm_bindgen(js_name = "background")] +pub fn js_background_color(surface_id: u64, r: f32, g: f32, b: f32, a: f32) -> Result<(), JsValue> { + let color = bevy::color::Color::srgba(r, g, b, a); + check(record_command( + Entity::from_bits(surface_id), + DrawCommand::BackgroundColor(color), + )) +} + +#[wasm_bindgen(js_name = "backgroundImage")] +pub fn js_background_image(surface_id: u64, image_id: u64) -> Result<(), JsValue> { + check(record_command( + Entity::from_bits(surface_id), + DrawCommand::BackgroundImage(Entity::from_bits(image_id)), + )) +} + +#[wasm_bindgen(js_name = "beginDraw")] +pub fn js_begin_draw(surface_id: u64) -> Result<(), JsValue> { + check(begin_draw(Entity::from_bits(surface_id))) +} + +#[wasm_bindgen(js_name = "flush")] +pub fn js_flush(surface_id: u64) -> Result<(), JsValue> { + check(flush(Entity::from_bits(surface_id))) +} + +#[wasm_bindgen(js_name = "endDraw")] +pub fn js_end_draw(surface_id: u64) -> Result<(), JsValue> { + check(end_draw(Entity::from_bits(surface_id))) +} + +#[wasm_bindgen(js_name = "exit")] +pub fn js_exit(exit_code: u8) -> Result<(), JsValue> { + check(exit(exit_code)) +} + +#[wasm_bindgen(js_name = "fill")] +pub fn js_fill(surface_id: u64, r: f32, g: f32, b: f32, a: f32) -> Result<(), JsValue> { + let color = bevy::color::Color::srgba(r, g, b, a); + check(record_command( + Entity::from_bits(surface_id), + DrawCommand::Fill(color), + )) +} + +#[wasm_bindgen(js_name = "stroke")] +pub fn js_stroke(surface_id: u64, r: f32, g: f32, b: f32, a: f32) -> Result<(), JsValue> { + let color = bevy::color::Color::srgba(r, g, b, a); + check(record_command( + Entity::from_bits(surface_id), + DrawCommand::StrokeColor(color), + )) +} + +#[wasm_bindgen(js_name = "strokeWeight")] +pub fn js_stroke_weight(surface_id: u64, weight: f32) -> Result<(), JsValue> { + check(record_command( + Entity::from_bits(surface_id), + DrawCommand::StrokeWeight(weight), + )) +} + +#[wasm_bindgen(js_name = "noFill")] +pub fn js_no_fill(surface_id: u64) -> Result<(), JsValue> { + check(record_command( + Entity::from_bits(surface_id), + DrawCommand::NoFill, + )) +} + +#[wasm_bindgen(js_name = "noStroke")] +pub fn js_no_stroke(surface_id: u64) -> Result<(), JsValue> { + check(record_command( + Entity::from_bits(surface_id), + DrawCommand::NoStroke, + )) +} + +#[wasm_bindgen(js_name = "rect")] +pub fn js_rect( + surface_id: u64, + x: f32, + y: f32, + w: f32, + h: f32, + tl: f32, + tr: f32, + br: f32, + bl: f32, +) -> Result<(), JsValue> { + check(record_command( + Entity::from_bits(surface_id), + DrawCommand::Rect { + x, + y, + w, + h, + radii: [tl, tr, br, bl], + }, + )) +} + +#[wasm_bindgen(js_name = "createImage")] +pub fn js_image_create(width: u32, height: u32, data: &[u8]) -> Result { + use bevy::render::render_resource::{Extent3d, TextureFormat}; + + let size = Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + check(image_create(size, data.to_vec(), TextureFormat::Rgba8UnormSrgb).map(|e| e.to_bits())) +} + +#[wasm_bindgen(js_name = "loadImage")] +pub async fn js_image_load(url: &str) -> Result { + check(image_load(url).await.map(|e| e.to_bits())) +} + +#[wasm_bindgen(js_name = "resizeImage")] +pub fn js_image_resize(image_id: u64, new_width: u32, new_height: u32) -> Result<(), JsValue> { + use bevy::render::render_resource::Extent3d; + + let new_size = Extent3d { + width: new_width, + height: new_height, + depth_or_array_layers: 1, + }; + check(image_resize(Entity::from_bits(image_id), new_size)) +} + +#[wasm_bindgen(js_name = "loadPixels")] +pub fn js_image_load_pixels(image_id: u64) -> Result, JsValue> { + let colors = check(image_load_pixels(Entity::from_bits(image_id)))?; + + let mut result = Vec::with_capacity(colors.len() * 4); + for color in colors { + result.push(color.red); + result.push(color.green); + result.push(color.blue); + result.push(color.alpha); + } + Ok(result) +} + +#[wasm_bindgen(js_name = "destroyImage")] +pub fn js_image_destroy(image_id: u64) -> Result<(), JsValue> { + check(image_destroy(Entity::from_bits(image_id))) +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..8a4dee8 --- /dev/null +++ b/justfile @@ -0,0 +1,12 @@ +default: + @just --list + +wasm-build: + wasm-pack build crates/processing_wasm --target web --out-dir ../../target/wasm + +wasm-release: + wasm-pack build crates/processing_wasm --target web --out-dir ../../target/wasm --release + -wasm-opt -Oz target/wasm/processing_wasm_bg.wasm -o target/wasm/processing_wasm_bg.wasm + +wasm-serve: wasm-build + python3 -m http.server 8000