diff --git a/Cargo.lock b/Cargo.lock index d44e999..0d102e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -204,6 +213,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.7.1" @@ -215,6 +230,10 @@ name = "cc" version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "504bdec147f2cc13c8b57ed9401fd8a147cc66b67ad5cb241394244f2c947549" +dependencies = [ + "jobserver", + "libc", +] [[package]] name = "cfg-if" @@ -287,6 +306,25 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -364,6 +402,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "equivalent" version = "1.0.1" @@ -448,6 +492,19 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "979f00864edc7516466d6b3157706e06c032f22715700ddd878228a91d02bc56" +dependencies = [ + "cfg-if", + "libc", + "log", + "rustversion", + "windows", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -475,6 +532,12 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "haku" version = "0.1.0" @@ -497,6 +560,7 @@ dependencies = [ "dlmalloc", "haku", "log", + "paste", ] [[package]] @@ -626,7 +690,7 @@ dependencies = [ "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows-core", + "windows-core 0.52.0", ] [[package]] @@ -648,6 +712,17 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10" +dependencies = [ + "bytemuck", + "byteorder-lite", + "num-traits", +] + [[package]] name = "indenter" version = "0.3.3" @@ -670,6 +745,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.69" @@ -708,6 +792,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebp-sys" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829b6b604f31ed6d2bccbac841fe0788de93dbd87e4eb1ba2c4adfe8c012a838" +dependencies = [ + "cc", + "glob", +] + [[package]] name = "lock_api" version = "0.4.12" @@ -724,6 +818,28 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "matchit" version = "0.7.3" @@ -842,6 +958,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -943,6 +1065,26 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.3" @@ -952,6 +1094,50 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + [[package]] name = "rkgk" version = "0.1.0" @@ -965,8 +1151,10 @@ dependencies = [ "derive_more", "eyre", "haku", + "indexmap", "rand", "rand_chacha", + "rayon", "rusqlite", "serde", "serde_json", @@ -975,8 +1163,14 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "tracy-client", + "webp", ] +[[package]] +name = "rkgk-image-ops" +version = "0.1.0" + [[package]] name = "rusqlite" version = "0.32.1" @@ -1018,6 +1212,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1442,14 +1642,38 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] +[[package]] +name = "tracy-client" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63de1e1d4115534008d8fd5788b39324d6f58fc707849090533828619351d855" +dependencies = [ + "loom", + "once_cell", + "tracy-client-sys", +] + +[[package]] +name = "tracy-client-sys" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b98232a2447ce0a58f9a0bfb5f5e39647b5c597c994b63945fcccd1306fafb" +dependencies = [ + "cc", +] + [[package]] name = "tungstenite" version = "0.21.0" @@ -1610,6 +1834,16 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +[[package]] +name = "webp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f53152f51fb5af0c08484c33d16cca96175881d1f3dec068c23b31a158c2d99" +dependencies = [ + "image", + "libwebp-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1641,6 +1875,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -1650,6 +1894,60 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 887bf0d..1360e87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ members = ["crates/*"] [workspace.dependencies] haku.path = "crates/haku" log = "0.4.22" +rkgk-image-ops.path = "crates/rkgk-image-ops" + +[profile.dev.package.rkgk-image-ops] +opt-level = 3 [profile.wasm-dev] inherits = "dev" diff --git a/crates/haku-wasm/Cargo.toml b/crates/haku-wasm/Cargo.toml index efcc2f4..fd41490 100644 --- a/crates/haku-wasm/Cargo.toml +++ b/crates/haku-wasm/Cargo.toml @@ -11,4 +11,5 @@ arrayvec = { version = "0.7.4", default-features = false } dlmalloc = { version = "0.2.6", features = ["global"] } haku.workspace = true log.workspace = true +paste = "1.0.15" diff --git a/crates/haku-wasm/src/lib.rs b/crates/haku-wasm/src/lib.rs index aa9444a..e723590 100644 --- a/crates/haku-wasm/src/lib.rs +++ b/crates/haku-wasm/src/lib.rs @@ -14,10 +14,10 @@ use haku::{ }, sexp::{parse_toplevel, Ast, Parser}, system::{ChunkId, System, SystemImage}, - value::{BytecodeLoc, Closure, FunctionName, Ref}, + value::{BytecodeLoc, Closure, FunctionName, Ref, Value}, vm::{Exception, Vm, VmImage, VmLimits}, }; -use log::info; +use log::{debug, info}; pub mod logging; mod panicking; @@ -66,6 +66,41 @@ impl Default for Limits { } } +#[no_mangle] +extern "C" fn haku_limits_new() -> *mut Limits { + Box::leak(Box::new(Limits::default())) +} + +#[no_mangle] +unsafe extern "C" fn haku_limits_destroy(limits: *mut Limits) { + drop(Box::from_raw(limits)) +} + +macro_rules! limit_setter { + ($name:tt) => { + paste::paste! { + #[no_mangle] + unsafe extern "C" fn [](limits: *mut Limits, value: usize) { + debug!("set limit {} = {value}", stringify!($name)); + + let limits = &mut *limits; + limits.$name = value; + } + } + }; +} + +limit_setter!(max_chunks); +limit_setter!(max_defs); +limit_setter!(ast_capacity); +limit_setter!(chunk_capacity); +limit_setter!(stack_capacity); +limit_setter!(call_stack_capacity); +limit_setter!(ref_capacity); +limit_setter!(fuel); +limit_setter!(pixmap_stack_capacity); +limit_setter!(transform_stack_capacity); + #[derive(Debug, Clone)] struct Instance { limits: Limits, @@ -76,13 +111,16 @@ struct Instance { defs_image: DefsImage, vm: Vm, vm_image: VmImage, + + value: Value, exception: Option, } #[no_mangle] -unsafe extern "C" fn haku_instance_new() -> *mut Instance { - // TODO: This should be a parameter. - let limits = Limits::default(); +unsafe extern "C" fn haku_instance_new(limits: *const Limits) -> *mut Instance { + let limits = *limits; + debug!("creating new instance with limits: {limits:?}"); + let system = System::new(limits.max_chunks); let defs = Defs::new(limits.max_defs); @@ -108,6 +146,7 @@ unsafe extern "C" fn haku_instance_new() -> *mut Instance { defs_image, vm, vm_image, + value: Value::Nil, exception: None, }); Box::leak(instance) @@ -125,6 +164,12 @@ unsafe extern "C" fn haku_reset(instance: *mut Instance) { instance.defs.restore_image(&instance.defs_image); } +#[no_mangle] +unsafe extern "C" fn haku_reset_vm(instance: *mut Instance) { + let instance = &mut *instance; + instance.vm.restore_image(&instance.vm_image); +} + #[no_mangle] unsafe extern "C" fn haku_has_exception(instance: *mut Instance) -> bool { (*instance).exception.is_some() @@ -285,13 +330,13 @@ unsafe extern "C" fn haku_compile_brush( } struct PixmapLock { - pixmap: Option, + pixmap: Pixmap, } #[no_mangle] extern "C" fn haku_pixmap_new(width: u32, height: u32) -> *mut PixmapLock { Box::leak(Box::new(PixmapLock { - pixmap: Some(Pixmap::new(width, height).expect("invalid pixmap size")), + pixmap: Pixmap::new(width, height).expect("invalid pixmap size"), })) } @@ -302,32 +347,18 @@ unsafe extern "C" fn haku_pixmap_destroy(pixmap: *mut PixmapLock) { #[no_mangle] unsafe extern "C" fn haku_pixmap_data(pixmap: *mut PixmapLock) -> *mut u8 { - let pixmap = (*pixmap) - .pixmap - .as_mut() - .expect("pixmap is already being rendered to"); - + let pixmap = &mut (*pixmap).pixmap; pixmap.pixels_mut().as_mut_ptr() as *mut u8 } #[no_mangle] unsafe extern "C" fn haku_pixmap_clear(pixmap: *mut PixmapLock) { - let pixmap = (*pixmap) - .pixmap - .as_mut() - .expect("pixmap is already being rendered to"); + let pixmap = &mut (*pixmap).pixmap; pixmap.pixels_mut().fill(PremultipliedColorU8::TRANSPARENT); } #[no_mangle] -unsafe extern "C" fn haku_render_brush( - instance: *mut Instance, - brush: *const Brush, - pixmap_a: *mut PixmapLock, - pixmap_b: *mut PixmapLock, - translation_x: f32, - translation_y: f32, -) -> StatusCode { +unsafe extern "C" fn haku_eval_brush(instance: *mut Instance, brush: *const Brush) -> StatusCode { let instance = &mut *instance; let brush = &*brush; @@ -347,7 +378,7 @@ unsafe extern "C" fn haku_render_brush( return StatusCode::OutOfRefSlots; }; - let scribble = match instance.vm.run(&instance.system, closure_id) { + instance.value = match instance.vm.run(&instance.system, closure_id) { Ok(value) => value, Err(exn) => { instance.exception = Some(exn); @@ -355,47 +386,36 @@ unsafe extern "C" fn haku_render_brush( } }; - let mut render = |pixmap: *mut PixmapLock| { - let pixmap_locked = (*pixmap) - .pixmap - .take() - .expect("pixmap is already being rendered to"); + StatusCode::Ok +} - let mut renderer = Renderer::new( - pixmap_locked, - &RendererLimits { - pixmap_stack_capacity: instance.limits.pixmap_stack_capacity, - transform_stack_capacity: instance.limits.transform_stack_capacity, - }, - ); - renderer.translate(translation_x, translation_y); - match renderer.render(&instance.vm, scribble) { - Ok(()) => (), - Err(exn) => { - instance.exception = Some(exn); - return StatusCode::RenderException; - } - } +#[no_mangle] +unsafe extern "C" fn haku_render_value( + instance: *mut Instance, + pixmap: *mut PixmapLock, + translation_x: f32, + translation_y: f32, +) -> StatusCode { + let instance = &mut *instance; - let pixmap_locked = renderer.finish(); + let pixmap_locked = &mut (*pixmap).pixmap; - (*pixmap).pixmap = Some(pixmap_locked); - - StatusCode::Ok - }; - - match render(pixmap_a) { - StatusCode::Ok => (), - other => return other, - } - if !pixmap_b.is_null() { - match render(pixmap_b) { - StatusCode::Ok => (), - other => return other, + let mut renderer = Renderer::new( + pixmap_locked, + &RendererLimits { + pixmap_stack_capacity: instance.limits.pixmap_stack_capacity, + transform_stack_capacity: instance.limits.transform_stack_capacity, + }, + ); + renderer.translate(translation_x, translation_y); + match renderer.render(&instance.vm, instance.value) { + Ok(()) => (), + Err(exn) => { + instance.exception = Some(exn); + instance.vm.restore_image(&instance.vm_image); + return StatusCode::RenderException; } } - instance.vm.restore_image(&instance.vm_image); - StatusCode::Ok } diff --git a/crates/haku/src/render.rs b/crates/haku/src/render.rs index 06b360f..11d32af 100644 --- a/crates/haku/src/render.rs +++ b/crates/haku/src/render.rs @@ -15,18 +15,23 @@ pub struct RendererLimits { pub transform_stack_capacity: usize, } -pub struct Renderer { - pixmap_stack: Vec, +pub enum RenderTarget<'a> { + Borrowed(&'a mut Pixmap), + Owned(Pixmap), +} + +pub struct Renderer<'a> { + pixmap_stack: Vec>, transform_stack: Vec, } -impl Renderer { - pub fn new(pixmap: Pixmap, limits: &RendererLimits) -> Self { +impl<'a> Renderer<'a> { + pub fn new(pixmap: &'a mut Pixmap, limits: &RendererLimits) -> Self { assert!(limits.pixmap_stack_capacity > 0); assert!(limits.transform_stack_capacity > 0); let mut blend_stack = Vec::with_capacity(limits.pixmap_stack_capacity); - blend_stack.push(pixmap); + blend_stack.push(RenderTarget::Borrowed(pixmap)); let mut transform_stack = Vec::with_capacity(limits.transform_stack_capacity); transform_stack.push(Transform::identity()); @@ -55,7 +60,10 @@ impl Renderer { } fn pixmap_mut(&mut self) -> &mut Pixmap { - self.pixmap_stack.last_mut().unwrap() + match self.pixmap_stack.last_mut().unwrap() { + RenderTarget::Borrowed(pixmap) => pixmap, + RenderTarget::Owned(pixmap) => pixmap, + } } pub fn render(&mut self, vm: &Vm, value: Value) -> Result<(), Exception> { @@ -123,10 +131,6 @@ impl Renderer { Ok(()) } - - pub fn finish(mut self) -> Pixmap { - self.pixmap_stack.drain(..).next().unwrap() - } } fn default_paint() -> Paint<'static> { diff --git a/crates/rkgk-image-ops/Cargo.toml b/crates/rkgk-image-ops/Cargo.toml new file mode 100644 index 0000000..e97be5b --- /dev/null +++ b/crates/rkgk-image-ops/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "rkgk-image-ops" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/crates/rkgk-image-ops/src/lib.rs b/crates/rkgk-image-ops/src/lib.rs new file mode 100644 index 0000000..b93cf3f --- /dev/null +++ b/crates/rkgk-image-ops/src/lib.rs @@ -0,0 +1,14 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} diff --git a/crates/rkgk/Cargo.toml b/crates/rkgk/Cargo.toml index 551fb9d..cda0208 100644 --- a/crates/rkgk/Cargo.toml +++ b/crates/rkgk/Cargo.toml @@ -13,8 +13,10 @@ dashmap = "6.0.1" derive_more = { version = "1.0.0", features = ["try_from"] } eyre = "0.6.12" haku.workspace = true +indexmap = "2.4.0" rand = "0.8.5" rand_chacha = "0.3.1" +rayon = "1.10.0" rusqlite = { version = "0.32.1", features = ["bundled"] } serde = { version = "1.0.206", features = ["derive"] } serde_json = "1.0.124" @@ -23,3 +25,9 @@ toml = "0.8.19" tower-http = { version = "0.5.2", features = ["fs"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["fmt"] } +tracy-client = { version = "0.17.1", optional = true} +webp = "0.3.0" + +[features] +default = [] +memory-profiling = ["dep:tracy-client"] diff --git a/crates/rkgk/src/api.rs b/crates/rkgk/src/api.rs index 54695f5..c5a41ed 100644 --- a/crates/rkgk/src/api.rs +++ b/crates/rkgk/src/api.rs @@ -9,15 +9,20 @@ use axum::{ }; use serde::{Deserialize, Serialize}; -use crate::Databases; +use crate::{config::Config, Databases}; mod wall; -pub fn router(dbs: Arc) -> Router { +pub struct Api { + pub config: Config, + pub dbs: Arc, +} + +pub fn router(api: Arc) -> Router { Router::new() .route("/login", post(login_new)) .route("/wall", get(wall::wall)) - .with_state(dbs) + .with_state(api) } #[derive(Deserialize)] @@ -35,7 +40,7 @@ enum NewUserResponse { Error { message: String }, } -async fn login_new(dbs: State>, params: Json) -> impl IntoResponse { +async fn login_new(api: State>, params: Json) -> impl IntoResponse { if !(1..=32).contains(¶ms.nickname.len()) { return ( StatusCode::BAD_REQUEST, @@ -45,7 +50,7 @@ async fn login_new(dbs: State>, params: Json) -> i ); } - match dbs.login.new_user(params.0.nickname).await { + match api.dbs.login.new_user(params.0.nickname).await { Ok(user_id) => ( StatusCode::OK, Json(NewUserResponse::Ok { diff --git a/crates/rkgk/src/api/wall.rs b/crates/rkgk/src/api/wall.rs index 9026e56..eb52cbd 100644 --- a/crates/rkgk/src/api/wall.rs +++ b/crates/rkgk/src/api/wall.rs @@ -8,21 +8,30 @@ use axum::{ response::Response, }; use eyre::{bail, Context, OptionExt}; -use schema::{Error, LoginRequest, LoginResponse, Online, Version, WallInfo}; +use haku::value::Value; +use schema::{ + ChunkInfo, Error, LoginRequest, LoginResponse, Notify, Online, Request, Version, WallInfo, +}; use serde::{Deserialize, Serialize}; -use tokio::select; +use tokio::{select, sync::mpsc, time::Instant}; use tracing::{error, info}; use crate::{ + haku::{Haku, Limits}, login::database::LoginStatus, - wall::{Event, JoinError, Session}, - Databases, + schema::Vec2, + wall::{ + self, chunk_encoder::ChunkEncoder, chunk_iterator::ChunkIterator, ChunkPosition, JoinError, + SessionHandle, UserInit, Wall, + }, }; +use super::Api; + mod schema; -pub async fn wall(State(dbs): State>, ws: WebSocketUpgrade) -> Response { - ws.on_upgrade(|ws| websocket(dbs, ws)) +pub async fn wall(State(api): State>, ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(|ws| websocket(api, ws)) } fn to_message(value: &T) -> Message @@ -51,8 +60,8 @@ async fn recv_expect(ws: &mut WebSocket) -> eyre::Result { .ok_or_eyre("connection closed unexpectedly")??) } -async fn websocket(dbs: Arc, mut ws: WebSocket) { - match fallible_websocket(dbs, &mut ws).await { +async fn websocket(api: Arc, mut ws: WebSocket) { + match fallible_websocket(api, &mut ws).await { Ok(()) => (), Err(e) => { _ = ws @@ -64,7 +73,7 @@ async fn websocket(dbs: Arc, mut ws: WebSocket) { } } -async fn fallible_websocket(dbs: Arc, ws: &mut WebSocket) -> eyre::Result<()> { +async fn fallible_websocket(api: Arc, ws: &mut WebSocket) -> eyre::Result<()> { #[cfg(debug_assertions)] let version = format!("{}-dev", env!("CARGO_PKG_VERSION")); #[cfg(not(debug_assertions))] @@ -73,9 +82,10 @@ async fn fallible_websocket(dbs: Arc, ws: &mut WebSocket) -> eyre::Re ws.send(to_message(&Version { version })).await?; let login_request: LoginRequest = from_message(&recv_expect(ws).await?)?; - let user_id = *login_request.user_id(); + let user_id = login_request.user; - match dbs + match api + .dbs .login .log_in(user_id) .await @@ -88,14 +98,24 @@ async fn fallible_websocket(dbs: Arc, ws: &mut WebSocket) -> eyre::Re return Ok(()); } } + let user_info = api + .dbs + .login + .user_info(user_id) + .await + .context("cannot get user info")? + .ok_or_eyre("user seems to have vanished")?; - let wall_id = match login_request { - LoginRequest::New { .. } => dbs.wall_broker.generate_id().await, - LoginRequest::Join { wall, .. } => wall, + let wall_id = match login_request.wall { + Some(wall) => wall, + None => api.dbs.wall_broker.generate_id().await, }; - let wall = dbs.wall_broker.open(wall_id); + let open_wall = api.dbs.wall_broker.open(wall_id); - let mut session_handle = match wall.join(Session::new(user_id)) { + let session_handle = match open_wall + .wall + .join(wall::Session::new(user_id, login_request.init.clone())) + { Ok(handle) => handle, Err(error) => { ws.send(to_message(&match error { @@ -110,8 +130,8 @@ async fn fallible_websocket(dbs: Arc, ws: &mut WebSocket) -> eyre::Re }; let mut users_online = vec![]; - for online in wall.online() { - let user_info = match dbs.login.user_info(online.user_id).await { + for online in open_wall.wall.online() { + let user_info = match api.dbs.login.user_info(online.user_id).await { Ok(Some(user_info)) => user_info, Ok(None) | Err(_) => { error!(?online, "could not get info about online user"); @@ -122,6 +142,9 @@ async fn fallible_websocket(dbs: Arc, ws: &mut WebSocket) -> eyre::Re session_id: online.session_id, nickname: user_info.nickname, cursor: online.cursor, + init: UserInit { + brush: online.brush, + }, }) } let users_online = users_online; @@ -129,25 +152,278 @@ async fn fallible_websocket(dbs: Arc, ws: &mut WebSocket) -> eyre::Re ws.send(to_message(&LoginResponse::LoggedIn { wall: wall_id, wall_info: WallInfo { - chunk_size: wall.settings().chunk_size, + chunk_size: open_wall.wall.settings().chunk_size, + paint_area: open_wall.wall.settings().paint_area, online: users_online, + haku_limits: api.config.haku.clone(), }, session_id: session_handle.session_id, })) .await?; - loop { - select! { - Some(message) = ws.recv() => { - let kind = from_message(&message?)?; - wall.event(Event { session_id: session_handle.session_id, kind }); + open_wall.wall.event(wall::Event { + session_id: session_handle.session_id, + kind: wall::EventKind::Join { + nickname: user_info.nickname, + init: login_request.init.clone(), + }, + }); + // Leave event is sent in SessionHandle's Drop implementation. + // This technically means that inbetween the user getting logged in and sending the Join event, + // we may end up losing the user and sending a Leave event, but Leave events are idempotent - + // they're only used to clean up the state of an existing user, but the user is not _required_ + // to exist on clients already. + // ...Well, we'll see how much havoc that wreaks in practice. Especially without TypeScript + // to remind us about unexpected nulls. + + SessionLoop::start( + open_wall.wall, + open_wall.chunk_encoder, + session_handle, + api.config.haku.clone(), + login_request.init.brush, + ) + .await? + .event_loop(ws) + .await?; + + Ok(()) +} + +struct SessionLoop { + wall: Arc, + chunk_encoder: Arc, + handle: SessionHandle, + render_commands_tx: mpsc::Sender, + viewport_chunks: ChunkIterator, +} + +enum RenderCommand { + SetBrush { brush: String }, + Plot { points: Vec }, +} + +impl SessionLoop { + async fn start( + wall: Arc, + chunk_encoder: Arc, + handle: SessionHandle, + limits: Limits, + brush: String, + ) -> eyre::Result { + // Limit how many commands may come in _pretty darn hard_ because these can be really + // CPU-intensive. + // If this ends up dropping commands - it's your fault for trying to DoS my server! + let (render_commands_tx, render_commands_rx) = mpsc::channel(1); + + render_commands_tx + .send(RenderCommand::SetBrush { brush }) + .await + .unwrap(); + + // We spawn our own thread so as not to clog the tokio blocking thread pool with our + // rendering shenanigans. + std::thread::Builder::new() + .name(String::from("haku render thread")) + .spawn({ + let wall = Arc::clone(&wall); + let chunk_encoder = Arc::clone(&chunk_encoder); + move || Self::render_thread(wall, chunk_encoder, limits, render_commands_rx) + }) + .context("could not spawn render thread")?; + + Ok(Self { + wall, + chunk_encoder, + handle, + render_commands_tx, + viewport_chunks: ChunkIterator::new(ChunkPosition::new(0, 0), ChunkPosition::new(0, 0)), + }) + } + + async fn event_loop(&mut self, ws: &mut WebSocket) -> eyre::Result<()> { + loop { + select! { + Some(message) = ws.recv() => { + let request = from_message(&message?)?; + self.process_request(ws, request).await?; + } + + Ok(wall_event) = self.handle.event_receiver.recv() => { + ws.send(to_message(&Notify::Wall { wall_event })).await?; + } + + else => break, + } + } + + Ok(()) + } + + async fn process_request(&mut self, ws: &mut WebSocket, request: Request) -> eyre::Result<()> { + match request { + Request::Wall { wall_event } => { + match &wall_event { + // This match only concerns itself with drawing-related events to offload + // all the evaluation and drawing work to this session's drawing thread. + wall::EventKind::Join { .. } + | wall::EventKind::Leave + | wall::EventKind::Cursor { .. } => (), + + wall::EventKind::SetBrush { brush } => { + // SetBrush is not dropped because it is a very important event. + _ = self + .render_commands_tx + .send(RenderCommand::SetBrush { + brush: brush.clone(), + }) + .await; + } + wall::EventKind::Plot { points } => { + // We drop commands if we take too long to render instead of lagging + // the WebSocket thread. + // Theoretically this will yield much better responsiveness, but it _will_ + // result in some visual glitches if we're getting bottlenecked. + _ = self.render_commands_tx.try_send(RenderCommand::Plot { + points: points.clone(), + }) + } + } + + self.wall.event(wall::Event { + session_id: self.handle.session_id, + kind: wall_event, + }); } - Ok(event) = session_handle.event_receiver.recv() => { - ws.send(to_message(&event)).await?; + Request::Viewport { + top_left, + bottom_right, + } => { + self.viewport_chunks = ChunkIterator::new(top_left, bottom_right); + self.send_chunks(ws).await?; } - else => break, + Request::MoreChunks => { + self.send_chunks(ws).await?; + } + } + + Ok(()) + } + + async fn send_chunks(&mut self, ws: &mut WebSocket) -> eyre::Result<()> { + let mut chunk_infos = vec![]; + let mut packet = vec![]; + + // Number of chunks iterated is limited to 300 per packet, so as not to let the client + // stall the server by sending in a huge viewport. + for _ in 0..300 { + if let Some(position) = self.viewport_chunks.next() { + if let Some(encoded) = self.chunk_encoder.encoded(position).await { + let offset = packet.len(); + packet.extend_from_slice(&encoded); + chunk_infos.push(ChunkInfo { + position, + offset: u32::try_from(offset).context("packet too big")?, + length: u32::try_from(encoded.len()).context("chunk image too big")?, + }); + + // The actual number of chunks per packet is limited by the packet's size, which + // we don't want to be too big, to maintain responsiveness - the client will + // only request more chunks once per frame, so interactions still have time to + // execute. We cap it to 256KiB in hopes that noone has Internet slow enough for + // this to cause a disconnect. + if packet.len() >= 256 * 1024 { + break; + } + } else { + // Length 0 indicates the server acknowledged the chunk, but it has no + // image data. + // This is used by clients to know that the chunk doesn't need downloading. + chunk_infos.push(ChunkInfo { + position, + offset: 0, + length: 0, + }); + } + } else { + break; + } + } + + ws.send(to_message(&Notify::Chunks { + chunks: chunk_infos, + })) + .await?; + ws.send(Message::Binary(packet)).await?; + + Ok(()) + } + + fn render_thread( + wall: Arc, + chunk_encoder: Arc, + limits: Limits, + mut commands: mpsc::Receiver, + ) { + let mut haku = Haku::new(limits); + let mut brush_ok = false; + + while let Some(command) = commands.blocking_recv() { + match command { + RenderCommand::SetBrush { brush } => { + brush_ok = haku.set_brush(&brush).is_ok(); + } + RenderCommand::Plot { points } => { + if brush_ok { + if let Ok(value) = haku.eval_brush() { + for point in points { + // Ignore the result. It's better if we render _something_ rather + // than nothing. + _ = draw_to_chunks(&haku, value, point, &wall, &chunk_encoder); + } + haku.reset_vm(); + } + } + } + } + } + } +} + +fn draw_to_chunks( + haku: &Haku, + value: Value, + center: Vec2, + wall: &Wall, + chunk_encoder: &ChunkEncoder, +) -> eyre::Result<()> { + let settings = wall.settings(); + + let chunk_size = settings.chunk_size as f32; + let paint_area = settings.paint_area as f32; + + let left = center.x - paint_area / 2.0; + let top = center.y - paint_area / 2.0; + + let left_chunk = f32::floor(left / chunk_size) as i32; + let top_chunk = f32::floor(top / chunk_size) as i32; + let right_chunk = f32::ceil((left + paint_area) / chunk_size) as i32; + let bottom_chunk = f32::ceil((top + paint_area) / chunk_size) as i32; + for chunk_y in top_chunk..bottom_chunk { + for chunk_x in left_chunk..right_chunk { + let x = f32::floor(-chunk_x as f32 * chunk_size + center.x); + let y = f32::floor(-chunk_y as f32 * chunk_size + center.y); + let chunk_ref = wall.get_or_create_chunk(ChunkPosition::new(chunk_x, chunk_y)); + let mut chunk = chunk_ref.blocking_lock(); + haku.render_value(&mut chunk.pixmap, value, Vec2 { x, y })?; + } + } + + for chunk_y in top_chunk..bottom_chunk { + for chunk_x in left_chunk..right_chunk { + chunk_encoder.invalidate_blocking(ChunkPosition::new(chunk_x, chunk_y)) } } diff --git a/crates/rkgk/src/api/wall/schema.rs b/crates/rkgk/src/api/wall/schema.rs index ace585b..fb392bf 100644 --- a/crates/rkgk/src/api/wall/schema.rs +++ b/crates/rkgk/src/api/wall/schema.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::{ login::UserId, schema::Vec2, - wall::{self, SessionId, WallId}, + wall::{self, ChunkPosition, SessionId, UserInit, WallId}, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -19,23 +19,12 @@ pub struct Error { } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde( - tag = "login", - rename_all = "camelCase", - rename_all_fields = "camelCase" -)] -pub enum LoginRequest { - New { user: UserId }, - Join { user: UserId, wall: WallId }, -} - -impl LoginRequest { - pub fn user_id(&self) -> &UserId { - match self { - LoginRequest::New { user } => user, - LoginRequest::Join { user, .. } => user, - } - } +#[serde(rename_all = "camelCase")] +pub struct LoginRequest { + pub user: UserId, + /// If null, a new wall is created. + pub wall: Option, + pub init: UserInit, } #[derive(Debug, Clone, Serialize)] @@ -44,12 +33,15 @@ pub struct Online { pub session_id: SessionId, pub nickname: String, pub cursor: Option, + pub init: UserInit, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct WallInfo { pub chunk_size: u32, + pub paint_area: u32, + pub haku_limits: crate::haku::Limits, pub online: Vec, } @@ -68,3 +60,40 @@ pub enum LoginResponse { UserDoesNotExist, TooManySessions, } + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde( + tag = "request", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum Request { + Wall { + wall_event: wall::EventKind, + }, + + Viewport { + top_left: ChunkPosition, + bottom_right: ChunkPosition, + }, + + MoreChunks, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ChunkInfo { + pub position: ChunkPosition, + pub offset: u32, + pub length: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde( + tag = "notify", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum Notify { + Wall { wall_event: wall::Event }, + Chunks { chunks: Vec }, +} diff --git a/crates/rkgk/src/config.rs b/crates/rkgk/src/config.rs index 520ffcf..93d202c 100644 --- a/crates/rkgk/src/config.rs +++ b/crates/rkgk/src/config.rs @@ -5,4 +5,5 @@ use crate::wall; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { pub wall: wall::Settings, + pub haku: crate::haku::Limits, } diff --git a/crates/rkgk/src/haku.rs b/crates/rkgk/src/haku.rs new file mode 100644 index 0000000..58df968 --- /dev/null +++ b/crates/rkgk/src/haku.rs @@ -0,0 +1,163 @@ +//! High-level wrapper for Haku. + +// TODO: This should be used as the basis for haku-wasm as well as haku tests in the future to +// avoid duplicating code. + +use eyre::{bail, Context, OptionExt}; +use haku::{ + bytecode::{Chunk, Defs, DefsImage}, + compiler::{Compiler, Source}, + render::{tiny_skia::Pixmap, Renderer, RendererLimits}, + sexp::{Ast, Parser}, + system::{ChunkId, System, SystemImage}, + value::{BytecodeLoc, Closure, FunctionName, Ref, Value}, + vm::{Vm, VmImage, VmLimits}, +}; +use serde::{Deserialize, Serialize}; + +use crate::schema::Vec2; + +#[derive(Debug, Clone, Deserialize, Serialize)] +// NOTE: For serialization, this struct does _not_ have serde(rename_all = "camelCase") on it, +// because we do some dynamic typing magic over on the JavaScript side to automatically call all +// the appropriate functions for setting these limits on the client side. +pub struct Limits { + pub max_chunks: usize, + pub max_defs: usize, + pub ast_capacity: usize, + pub chunk_capacity: usize, + pub stack_capacity: usize, + pub call_stack_capacity: usize, + pub ref_capacity: usize, + pub fuel: usize, + pub pixmap_stack_capacity: usize, + pub transform_stack_capacity: usize, +} + +pub struct Haku { + limits: Limits, + + system: System, + system_image: SystemImage, + defs: Defs, + defs_image: DefsImage, + vm: Vm, + vm_image: VmImage, + + brush: Option, +} + +impl Haku { + pub fn new(limits: Limits) -> Self { + let system = System::new(limits.max_chunks); + let defs = Defs::new(limits.max_defs); + let vm = Vm::new( + &defs, + &VmLimits { + stack_capacity: limits.stack_capacity, + call_stack_capacity: limits.call_stack_capacity, + ref_capacity: limits.ref_capacity, + fuel: limits.fuel, + }, + ); + + let system_image = system.image(); + let defs_image = defs.image(); + let vm_image = vm.image(); + + Self { + limits, + system, + system_image, + defs, + defs_image, + vm, + vm_image, + brush: None, + } + } + + fn reset(&mut self) { + self.system.restore_image(&self.system_image); + self.defs.restore_image(&self.defs_image); + } + + pub fn set_brush(&mut self, code: &str) -> eyre::Result<()> { + self.reset(); + + let ast = Ast::new(self.limits.ast_capacity); + let mut parser = Parser::new(ast, code); + let root = haku::sexp::parse_toplevel(&mut parser); + let ast = parser.ast; + + let src = Source { + code, + ast: &ast, + system: &self.system, + }; + + let mut chunk = Chunk::new(self.limits.chunk_capacity) + .expect("chunk capacity must be representable as a 16-bit number"); + let mut compiler = Compiler::new(&mut self.defs, &mut chunk); + haku::compiler::compile_expr(&mut compiler, &src, root) + .context("failed to compile the chunk")?; + + if !compiler.diagnostics.is_empty() { + bail!("diagnostics were emitted"); + } + + let chunk_id = self.system.add_chunk(chunk).context("too many chunks")?; + self.brush = Some(chunk_id); + + Ok(()) + } + + pub fn eval_brush(&mut self) -> eyre::Result { + let brush = self + .brush + .ok_or_eyre("brush is not compiled and ready to be used")?; + + let closure_id = self + .vm + .create_ref(Ref::Closure(Closure { + start: BytecodeLoc { + chunk_id: brush, + offset: 0, + }, + name: FunctionName::Anonymous, + param_count: 0, + captures: vec![], + })) + .context("not enough ref slots to create initial closure")?; + + let scribble = self + .vm + .run(&self.system, closure_id) + .context("an exception occurred while evaluating the scribble")?; + + Ok(scribble) + } + + pub fn render_value( + &self, + pixmap: &mut Pixmap, + value: Value, + translation: Vec2, + ) -> eyre::Result<()> { + let mut renderer = Renderer::new( + pixmap, + &RendererLimits { + pixmap_stack_capacity: self.limits.pixmap_stack_capacity, + transform_stack_capacity: self.limits.transform_stack_capacity, + }, + ); + renderer.translate(translation.x, translation.y); + let result = renderer.render(&self.vm, value); + + result.context("an exception occurred while rendering the scribble") + } + + pub fn reset_vm(&mut self) { + self.vm.restore_image(&self.vm_image); + } +} diff --git a/crates/rkgk/src/id.rs b/crates/rkgk/src/id.rs index 2985b39..29bc06c 100644 --- a/crates/rkgk/src/id.rs +++ b/crates/rkgk/src/id.rs @@ -5,7 +5,7 @@ use base64::Engine; pub fn serialize(f: &mut fmt::Formatter<'_>, prefix: &str, bytes: &[u8; 32]) -> fmt::Result { f.write_str(prefix)?; let mut buffer = [b'0'; 43]; - base64::engine::general_purpose::STANDARD_NO_PAD + base64::engine::general_purpose::URL_SAFE_NO_PAD .encode_slice(bytes, &mut buffer) .unwrap(); f.write_str(std::str::from_utf8(&buffer).unwrap())?; @@ -17,7 +17,7 @@ pub struct InvalidId; pub fn deserialize(s: &str, prefix: &str) -> Result<[u8; 32], InvalidId> { let mut bytes = [0; 32]; let b64 = s.strip_prefix(prefix).ok_or(InvalidId)?; - let decoded = base64::engine::general_purpose::STANDARD_NO_PAD + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode_slice(b64, &mut bytes) .map_err(|_| InvalidId)?; if decoded != bytes.len() { diff --git a/crates/rkgk/src/main.rs b/crates/rkgk/src/main.rs index f07dbeb..6da9d86 100644 --- a/crates/rkgk/src/main.rs +++ b/crates/rkgk/src/main.rs @@ -4,6 +4,7 @@ use std::{ sync::Arc, }; +use api::Api; use axum::Router; use config::Config; use copy_dir::copy_dir; @@ -16,6 +17,7 @@ use tracing_subscriber::fmt::format::FmtSpan; mod api; mod binary; mod config; +mod haku; mod id; #[cfg(debug_assertions)] mod live_reload; @@ -24,6 +26,11 @@ pub mod schema; mod serialization; mod wall; +#[cfg(feature = "memory-profiling")] +#[global_allocator] +static GLOBAL_ALLOCATOR: tracy_client::ProfiledAllocator = + tracy_client::ProfiledAllocator::new(std::alloc::System, 100); + struct Paths<'a> { target_dir: &'a Path, database_dir: &'a Path, @@ -81,13 +88,15 @@ async fn fallible_main() -> eyre::Result<()> { build(&paths)?; let dbs = Arc::new(database(&config, &paths)?); + let api = Arc::new(Api { config, dbs }); + let app = Router::new() .route_service( "/", ServeFile::new(paths.target_dir.join("static/index.html")), ) .nest_service("/static", ServeDir::new(paths.target_dir.join("static"))) - .nest("/api", api::router(dbs.clone())); + .nest("/api", api::router(api)); #[cfg(debug_assertions)] let app = app.nest("/dev/live-reload", live_reload::router()); @@ -103,6 +112,9 @@ async fn fallible_main() -> eyre::Result<()> { #[tokio::main] async fn main() { + #[cfg(feature = "memory-profiling")] + let _client = tracy_client::Client::start(); + color_eyre::install().unwrap(); tracing_subscriber::fmt() .with_span_events(FmtSpan::ACTIVE) diff --git a/crates/rkgk/src/wall.rs b/crates/rkgk/src/wall.rs index 4670c46..9a0a6b9 100644 --- a/crates/rkgk/src/wall.rs +++ b/crates/rkgk/src/wall.rs @@ -13,10 +13,13 @@ use haku::render::tiny_skia::Pixmap; use rand::RngCore; use serde::{Deserialize, Serialize}; use tokio::sync::{broadcast, Mutex}; +use tracing::info; use crate::{id, login::UserId, schema::Vec2, serialization::DeserializeFromStr}; pub mod broker; +pub mod chunk_encoder; +pub mod chunk_iterator; pub use broker::Broker; @@ -79,8 +82,20 @@ impl fmt::Display for InvalidWallId { impl Error for InvalidWallId {} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +pub struct ChunkPosition { + pub x: i32, + pub y: i32, +} + +impl ChunkPosition { + pub fn new(x: i32, y: i32) -> Self { + Self { x, y } + } +} + pub struct Chunk { - pixmap: Pixmap, + pub pixmap: Pixmap, } impl Chunk { @@ -95,13 +110,23 @@ impl Chunk { pub struct Settings { pub max_chunks: usize, pub max_sessions: usize, + pub paint_area: u32, pub chunk_size: u32, } +impl Settings { + pub fn chunk_at(&self, position: Vec2) -> ChunkPosition { + ChunkPosition::new( + f32::floor(position.x / self.chunk_size as f32) as i32, + f32::floor(position.y / self.chunk_size as f32) as i32, + ) + } +} + pub struct Wall { settings: Settings, - chunks: DashMap<(i32, i32), Arc>>, + chunks: DashMap>>, sessions: DashMap, session_id_counter: AtomicU32, @@ -109,9 +134,17 @@ pub struct Wall { event_sender: broadcast::Sender, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserInit { + // Provide a brush upon initialization, so that the user always has a valid brush set. + pub brush: String, +} + pub struct Session { pub user_id: UserId, pub cursor: Option, + pub brush: String, } pub struct SessionHandle { @@ -134,6 +167,9 @@ pub struct Event { rename_all_fields = "camelCase" )] pub enum EventKind { + Join { nickname: String, init: UserInit }, + Leave, + Cursor { position: Vec2 }, SetBrush { brush: String }, @@ -146,6 +182,7 @@ pub struct Online { pub session_id: SessionId, pub user_id: UserId, pub cursor: Option, + pub brush: String, } impl Wall { @@ -163,11 +200,11 @@ impl Wall { &self.settings } - pub fn get_chunk(&self, at: (i32, i32)) -> Option>> { + pub fn get_chunk(&self, at: ChunkPosition) -> Option>> { self.chunks.get(&at).map(|chunk| Arc::clone(&chunk)) } - pub fn get_or_create_chunk(&self, at: (i32, i32)) -> Arc> { + pub fn get_or_create_chunk(&self, at: ChunkPosition) -> Arc> { Arc::clone( &self .chunks @@ -198,6 +235,7 @@ impl Wall { session_id: *r.key(), user_id: r.user_id, cursor: r.value().cursor, + brush: r.value().brush.clone(), }) .collect() } @@ -205,12 +243,17 @@ impl Wall { pub fn event(&self, event: Event) { if let Some(mut session) = self.sessions.get_mut(&event.session_id) { match &event.kind { - EventKind::SetBrush { brush } => {} + // Join and Leave are events that only get broadcasted through the wall such that + // all users get them. We don't need to react to them in any way. + EventKind::Join { .. } | EventKind::Leave => (), EventKind::Cursor { position } => { session.cursor = Some(*position); } - EventKind::Plot { points } => {} + + // Drawing events are handled by the owner session's thread to make drawing as + // parallel as possible. + EventKind::SetBrush { .. } | EventKind::Plot { .. } => (), } } @@ -219,10 +262,11 @@ impl Wall { } impl Session { - pub fn new(user_id: UserId) -> Self { + pub fn new(user_id: UserId, user_init: UserInit) -> Self { Self { user_id, cursor: None, + brush: user_init.brush, } } } @@ -231,6 +275,10 @@ impl Drop for SessionHandle { fn drop(&mut self) { if let Some(wall) = self.wall.upgrade() { wall.sessions.remove(&self.session_id); + wall.event(Event { + session_id: self.session_id, + kind: EventKind::Leave, + }); // After the session is removed, the wall will be garbage collected later. } } @@ -240,7 +288,3 @@ pub enum JoinError { TooManyCurrentSessions, IdsExhausted, } - -pub enum EventError { - DeadSession, -} diff --git a/crates/rkgk/src/wall/broker.rs b/crates/rkgk/src/wall/broker.rs index a7bd3ac..01df98e 100644 --- a/crates/rkgk/src/wall/broker.rs +++ b/crates/rkgk/src/wall/broker.rs @@ -6,7 +6,7 @@ use rand_chacha::ChaCha20Rng; use tokio::sync::Mutex; use tracing::info; -use super::{Settings, Wall, WallId}; +use super::{chunk_encoder::ChunkEncoder, Settings, Wall, WallId}; /// The broker is the main way to access wall data. /// @@ -18,8 +18,10 @@ pub struct Broker { rng: Mutex, } -struct OpenWall { - wall: Arc, +#[derive(Clone)] +pub struct OpenWall { + pub wall: Arc, + pub chunk_encoder: Arc, } impl Broker { @@ -39,15 +41,14 @@ impl Broker { WallId::new(&mut *rng) } - pub fn open(&self, wall_id: WallId) -> Arc { - Arc::clone( - &self - .open_walls - .entry(wall_id) - .or_insert_with(|| OpenWall { - wall: Arc::new(Wall::new(self.wall_settings)), - }) - .wall, - ) + pub fn open(&self, wall_id: WallId) -> OpenWall { + let wall = Arc::new(Wall::new(self.wall_settings)); + self.open_walls + .entry(wall_id) + .or_insert_with(|| OpenWall { + chunk_encoder: Arc::new(ChunkEncoder::start(Arc::clone(&wall))), + wall, + }) + .clone() } } diff --git a/crates/rkgk/src/wall/chunk_encoder.rs b/crates/rkgk/src/wall/chunk_encoder.rs new file mode 100644 index 0000000..dfb7d71 --- /dev/null +++ b/crates/rkgk/src/wall/chunk_encoder.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use indexmap::IndexMap; +use tokio::sync::{mpsc, oneshot}; + +use super::{ChunkPosition, Wall}; + +/// Service which encodes chunks to WebP images and caches them in an LRU fashion. +pub struct ChunkEncoder { + commands_tx: mpsc::Sender, +} + +enum Command { + GetEncoded { + chunk: ChunkPosition, + reply: oneshot::Sender>>, + }, + + Invalidate { + chunk: ChunkPosition, + }, +} + +impl ChunkEncoder { + pub fn start(wall: Arc) -> Self { + let (commands_tx, commands_rx) = mpsc::channel(32); + + tokio::spawn(Self::service(wall, commands_rx)); + + Self { commands_tx } + } + + pub async fn encoded(&self, chunk: ChunkPosition) -> Option> { + let (tx, rx) = oneshot::channel(); + self.commands_tx + .send(Command::GetEncoded { chunk, reply: tx }) + .await + .ok()?; + rx.await.ok().flatten() + } + + pub async fn invalidate(&self, chunk: ChunkPosition) { + _ = self.commands_tx.send(Command::Invalidate { chunk }).await; + } + + pub fn invalidate_blocking(&self, chunk: ChunkPosition) { + _ = self + .commands_tx + .blocking_send(Command::Invalidate { chunk }); + } + + async fn encode(wall: &Wall, chunk: ChunkPosition) -> Option> { + let pixmap = { + // Clone out the pixmap to avoid unnecessary chunk mutex contention while the + // chunk is being encoded. + let chunk_ref = wall.get_chunk(chunk)?; + let chunk = chunk_ref.lock().await; + chunk.pixmap.clone() + }; + + let image = tokio::task::spawn_blocking(move || { + let webp = webp::Encoder::new( + pixmap.data(), + webp::PixelLayout::Rgba, + pixmap.width(), + pixmap.height(), + ); + // NOTE: There's an unnecessary copy here. Wonder if that kills performance much. + webp.encode_lossless().to_vec() + }) + .await + .ok()?; + + Some(Arc::from(image)) + } + + async fn service(wall: Arc, mut commands_rx: mpsc::Receiver) { + let mut encoded_lru: IndexMap>> = IndexMap::new(); + + while let Some(command) = commands_rx.recv().await { + match command { + Command::GetEncoded { chunk, reply } => { + if let Some(encoded) = encoded_lru.get(&chunk) { + _ = reply.send(encoded.clone()) + } else { + let encoded = Self::encode(&wall, chunk).await; + // TODO: Make this capacity configurable. + // 598 is chosen because under the default configuration, it would + // correspond to roughly two 3840x2160 displays. + if encoded_lru.len() >= 598 { + encoded_lru.shift_remove_index(0); + } + encoded_lru.insert(chunk, encoded.clone()); + _ = reply.send(encoded); + } + } + + Command::Invalidate { chunk } => { + encoded_lru.shift_remove(&chunk); + } + } + } + } +} diff --git a/crates/rkgk/src/wall/chunk_iterator.rs b/crates/rkgk/src/wall/chunk_iterator.rs new file mode 100644 index 0000000..9a77005 --- /dev/null +++ b/crates/rkgk/src/wall/chunk_iterator.rs @@ -0,0 +1,37 @@ +use super::ChunkPosition; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChunkIterator { + cursor: ChunkPosition, + left: i32, + bottom_right: ChunkPosition, +} + +impl ChunkIterator { + pub fn new(top_left: ChunkPosition, bottom_right: ChunkPosition) -> Self { + Self { + cursor: top_left, + left: top_left.x, + bottom_right, + } + } +} + +impl Iterator for ChunkIterator { + type Item = ChunkPosition; + + fn next(&mut self) -> Option { + let position = self.cursor; + + self.cursor.x += 1; + if self.cursor.y > self.bottom_right.y { + return None; + } + if self.cursor.x > self.bottom_right.x { + self.cursor.x = self.left; + self.cursor.y += 1; + } + + Some(position) + } +} diff --git a/docs/haku.dj b/docs/haku.dj new file mode 100644 index 0000000..cc3ff83 --- /dev/null +++ b/docs/haku.dj @@ -0,0 +1,52 @@ +# haku + +Haku is a little scripting language used by rakugaki for programming brushes. +Here's a brief tour of the language. + +## Your brush + +Your brush is a piece of code that describes what's to be drawn on the wall. + +For example: + +```haku +(stroke + 8 + (rgba 0 0 0 1) + (vec 0 0)) +``` + +This is the simplest brush you can write. +It demonstrates a few things: + +- The brush's task is to produce a description of what's to be drawn. +Brushes produce *scribbles* - commands that instruct rakugaki draw something on the wall. + + - This brush produces the `(stroke)` scribble. + This scribble is composed out of three things: + + - The stroke thickness - in this case `8`. + - The stroke color - in this case `(rgba 0 0 0 1)`. + Note that unlike most drawing programs, rakugaki brushes represent color channels with decimal numbers from 0 to 1, rather than integers from 0 to 255. + - The shape to draw - in this case a `(vec 0 0)`. + + - Vectors are aggregations of four generic decimal numbers, most often used to represent positions in the wall's Cartesian coordinate space. + Although vectors are mathematically not the same as points, brushes always execute in a coordinate space relative to where you want to draw with the brush, so a separate `(point)` type isn't needed. + + - Vectors in haku are four-dimensional, but the wall is two-dimensional, so the extra dimensions are discarded when drawing to the wall. + + - haku permits constructing vectors from zero two four values - from `(vec)`, up to `(vec x y w h)`. + Any values that you leave out end up being zero. + + - Note that a brush can only produce *one* scribble - this is because scribbles may be composed together using lists (described later.) + +I highly recommend that you play around with the brush to get a feel for editing haku code! + +## Limits + +The wall is infinite, but your brush may only draw in a small area around your cursor (~500 pixels.) +Drawing outside this area may result in pixels getting dropped in ugly ways, but it can also be used to your advantage in order to produce cool glitch art. + +Additionally, haku code has some pretty strong limitations on what it can do. +It cannot be too big, it cannot execute for too long, and it cannot consume too much memory. +It does not have access to the world outside the wall. diff --git a/rkgk.toml b/rkgk.toml index 9bfd1af..66d450a 100644 --- a/rkgk.toml +++ b/rkgk.toml @@ -1,5 +1,76 @@ [wall] + +# The settings below control the creation of new walls. + +# The maximum number of chunks on a wall. +# It is recommended to cap this to something reasonable so that users can't trash the server's +# disk space very easily. max_chunks = 65536 + +# Maximum concurrent sessions connected to a wall. +# Note that a single user can have multiple sessions at a time. max_sessions = 128 + +# The size of chunks. +# Choosing an appropriate size for chunks is a tradeoff between performance and disk space - 168 is +# chosen as a reasonable default chunk_size = 168 +# The size of the area that can be drawn over by a brush, in pixels. +# The larger this area, the more CPU-expensive brushes get overall, but the larger the image a brush +# can produce. +paint_area = 504 + +[haku] + +# The settings below control the Haku runtime on the server side. +# Technically clients may override these settings with some hackery, but then the server may not +# register changes they make to the canvas. + +# Maximum amount of source code chunks. +# This should be at least 2, to allow for loading in a standard library chunk. +max_chunks = 2 + +# Maximum amount of defs across all source code chunks. +max_defs = 256 + +# Maximum amount of AST nodes in a single parse. +ast_capacity = 1024 + +# Maximum size of a bytecode chunk. +# This must be <= 65536 due to bytecode limitations - offsets are stored as 16-bit integers. +chunk_capacity = 65536 + +# Maximum size of the value stack. +# This defines how many local variables and temporary values can be in scope at a given moment. +# Effectively, this limits how deep and complex a single expression can get. +stack_capacity = 1024 + +# Maximum call stack capacity. +# This defines how much code is allowed to call itself recursively. +call_stack_capacity = 256 + +# Maximum amount of refs. +# Refs are big, reused, unique values that do not fit on the value stack - akin to objects in +# languages like Python, but immutable. +ref_capacity = 2048 + +# Amount of fuel given to the VM. +# Each instruction executed by the VM consumes fuel. The VM will continue running until it runs out +# of fuel completely. +# An unfortunate side effect of this is that since Haku is a functional language, a brush running +# out of fuel means it will not be rendered at all, because there is no complete value returned. +fuel = 65536 + +# Capacity of the renderer's pixmap stack. +# The pixmap stack is used for blending layers together within a brush. +# Each (composite)-type scribble requires a single entry on this pixmap stack. +# In the end, this defines how deep compositing operations may nest. +pixmap_stack_capacity = 4 + +# Capacity of the renderer's transformation stack. +# The transformation stack is used for operations on the transform matrix, such as (translate). +# To render each transformed operation, a single entry of the transform stack is used. +# In the end, this defines how deep matrix transform operations may nest. +transform_stack_capacity = 16 + diff --git a/static/canvas-renderer.js b/static/canvas-renderer.js index a36a4e2..3924050 100644 --- a/static/canvas-renderer.js +++ b/static/canvas-renderer.js @@ -34,6 +34,13 @@ class CanvasRenderer extends HTMLElement { this.#render(); } + getWindowSize() { + return { + width: this.clientWidth, + height: this.clientHeight, + }; + } + #render() { // NOTE: We should probably render on-demand only when it's needed. requestAnimationFrame(() => this.#render()); @@ -41,6 +48,19 @@ class CanvasRenderer extends HTMLElement { this.#renderWall(); } + getVisibleRect() { + return this.viewport.getVisibleRect(this.getWindowSize()); + } + + getVisibleChunkRect() { + let visibleRect = this.viewport.getVisibleRect(this.getWindowSize()); + let left = Math.floor(visibleRect.x / this.wall.chunkSize); + let top = Math.floor(visibleRect.y / this.wall.chunkSize); + let right = Math.ceil((visibleRect.x + visibleRect.width) / this.wall.chunkSize); + let bottom = Math.ceil((visibleRect.y + visibleRect.height) / this.wall.chunkSize); + return { left, top, right, bottom }; + } + #renderWall() { if (this.wall == null) { console.debug("wall is not available, skipping rendering"); @@ -55,10 +75,7 @@ class CanvasRenderer extends HTMLElement { this.ctx.scale(this.viewport.zoom, this.viewport.zoom); this.ctx.translate(-this.viewport.panX, -this.viewport.panY); - let visibleRect = this.viewport.getVisibleRect({ - width: this.clientWidth, - height: this.clientHeight, - }); + let visibleRect = this.viewport.getVisibleRect(this.getWindowSize()); let left = Math.floor(visibleRect.x / this.wall.chunkSize); let top = Math.floor(visibleRect.y / this.wall.chunkSize); let right = Math.ceil((visibleRect.x + visibleRect.width) / this.wall.chunkSize); @@ -88,10 +105,7 @@ class CanvasRenderer extends HTMLElement { let [x, y] = this.viewport.toViewportSpace( event.clientX - this.clientLeft, event.offsetY - this.clientTop, - { - width: this.clientWidth, - height: this.clientHeight, - }, + this.getWindowSize(), ); this.dispatchEvent(Object.assign(new Event(".cursor"), { x, y })); } @@ -127,10 +141,7 @@ class CanvasRenderer extends HTMLElement { async #paintingBehaviour() { const paint = (x, y) => { - let [wallX, wallY] = this.viewport.toViewportSpace(x, y, { - width: this.clientWidth, - height: this.clientHeight, - }); + let [wallX, wallY] = this.viewport.toViewportSpace(x, y, this.getWindowSize()); this.dispatchEvent(Object.assign(new Event(".paint"), { x: wallX, y: wallY })); }; diff --git a/static/haku.js b/static/haku.js index 144233b..b756302 100644 --- a/static/haku.js +++ b/static/haku.js @@ -118,9 +118,16 @@ export class Haku { #pBrush = 0; #brushCode = null; - constructor() { - this.#pInstance = w.haku_instance_new(); + constructor(limits) { + let pLimits = w.haku_limits_new(); + for (let name of Object.keys(limits)) { + w[`haku_limits_set_${name}`](pLimits, limits[name]); + } + + this.#pInstance = w.haku_instance_new(pLimits); this.#pBrush = w.haku_brush_new(); + + w.haku_limits_destroy(pLimits); } setBrush(code) { @@ -166,18 +173,7 @@ export class Haku { return { status: "ok" }; } - renderBrush(pixmap, translationX, translationY) { - let statusCode = w.haku_render_brush( - this.#pInstance, - this.#pBrush, - pixmap.ptr, - // If we ever want to detect which pixels were touched (USING A SHADER.), we can use - // this to rasterize the brush _twice_, and then we can detect which pixels are the same - // between the two pixmaps. - 0, - translationX, - translationY, - ); + #statusCodeToResultObject(statusCode) { if (!w.haku_is_ok(statusCode)) { if (w.haku_is_exception(statusCode)) { return { @@ -196,8 +192,22 @@ export class Haku { message: readCString(w.haku_status_string(statusCode)), }; } + } else { + return { status: "ok" }; } + } - return { status: "ok" }; + evalBrush() { + return this.#statusCodeToResultObject(w.haku_eval_brush(this.#pInstance, this.#pBrush)); + } + + renderValue(pixmap, translationX, translationY) { + return this.#statusCodeToResultObject( + w.haku_render_value(this.#pInstance, pixmap.ptr, translationX, translationY), + ); + } + + resetVm() { + w.haku_reset_vm(this.#pInstance); } } diff --git a/static/index.css b/static/index.css index f732791..29ac5fd 100644 --- a/static/index.css +++ b/static/index.css @@ -178,8 +178,8 @@ rkgk-reticle-renderer { } } -rkgk-reticle { - --color: black; +rkgk-reticle-cursor { + --color: black; /* Overridden by JavaScript to set a per-user color. */ position: absolute; display: block; diff --git a/static/index.js b/static/index.js index 258d3df..8ff8bd7 100644 --- a/static/index.js +++ b/static/index.js @@ -1,73 +1,165 @@ -import { Painter } from "./painter.js"; import { Wall } from "./wall.js"; -import { Haku } from "./haku.js"; import { getUserId, newSession, waitForLogin } from "./session.js"; import { debounce } from "./framework.js"; +import { ReticleCursor } from "./reticle-renderer.js"; + +const updateInterval = 1000 / 60; let main = document.querySelector("main"); let canvasRenderer = main.querySelector("rkgk-canvas-renderer"); let reticleRenderer = main.querySelector("rkgk-reticle-renderer"); let brushEditor = main.querySelector("rkgk-brush-editor"); -let haku = new Haku(); -let painter = new Painter(512); - reticleRenderer.connectViewport(canvasRenderer.viewport); -canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.updateTransform()); // In the background, connect to the server. (async () => { await waitForLogin(); console.info("login ready! starting session"); - let session = await newSession(getUserId(), localStorage.getItem("rkgk.mostRecentWallId")); + let session = await newSession(getUserId(), localStorage.getItem("rkgk.mostRecentWallId"), { + brush: brushEditor.code, + }); localStorage.setItem("rkgk.mostRecentWallId", session.wallId); - let wall = new Wall(session.wallInfo.chunkSize); + let wall = new Wall(session.wallInfo); canvasRenderer.initialize(wall); for (let onlineUser of session.wallInfo.online) { - wall.onlineUsers.addUser(onlineUser.sessionId, { nickname: onlineUser.nickname }); + wall.onlineUsers.addUser(onlineUser.sessionId, { + nickname: onlineUser.nickname, + brush: onlineUser.init.brush, + }); } + let currentUser = wall.onlineUsers.getUser(session.sessionId); + session.addEventListener("error", (event) => console.error(event)); - session.addEventListener("action", (event) => { - if (event.kind.event == "cursor") { - let reticle = reticleRenderer.getOrAddReticle(wall.onlineUsers, event.sessionId); - let { x, y } = event.kind.position; - reticle.setCursor(x, y); + + session.addEventListener("wallEvent", (event) => { + let wallEvent = event.wallEvent; + if (wallEvent.sessionId != session.sessionId) { + if (wallEvent.kind.event == "join") { + wall.onlineUsers.addUser(wallEvent.sessionId, { + nickname: wallEvent.kind.nickname, + brush: wallEvent.kind.init.brush, + }); + } + + let user = wall.onlineUsers.getUser(wallEvent.sessionId); + if (user == null) { + console.warn("received event for an unknown user", wallEvent); + return; + } + + if (wallEvent.kind.event == "leave") { + if (user.reticle != null) { + reticleRenderer.removeReticle(user.reticle); + } + wall.onlineUsers.removeUser(wallEvent.sessionId); + } + + if (wallEvent.kind.event == "cursor") { + if (user.reticle == null) { + user.reticle = new ReticleCursor( + wall.onlineUsers.getUser(wallEvent.sessionId).nickname, + ); + reticleRenderer.addReticle(user.reticle); + } + + let { x, y } = wallEvent.kind.position; + user.reticle.setCursor(x, y); + } + + if (wallEvent.kind.event == "setBrush") { + user.setBrush(wallEvent.kind.brush); + } + + if (wallEvent.kind.event == "plot") { + for (let { x, y } of wallEvent.kind.points) { + user.renderBrushToChunks(wall, x, y); + } + } } }); - let compileBrush = () => haku.setBrush(brushEditor.code); - compileBrush(); - brushEditor.addEventListener(".codeChanged", () => compileBrush()); + let pendingChunks = 0; + let chunkDownloadStates = new Map(); - let reportCursor = debounce(1000 / 60, (x, y) => session.reportCursor(x, y)); + function sendViewportUpdate() { + let visibleRect = canvasRenderer.getVisibleChunkRect(); + session.sendViewport(visibleRect); + + for (let chunkY = visibleRect.top; chunkY < visibleRect.bottom; ++chunkY) { + for (let chunkX = visibleRect.left; chunkX < visibleRect.right; ++chunkX) { + let key = Wall.chunkKey(chunkX, chunkY); + let currentState = chunkDownloadStates.get(key); + if (currentState == null) { + chunkDownloadStates.set(key, "requested"); + pendingChunks += 1; + } + } + } + console.info("pending chunks after viewport update", pendingChunks); + } + + canvasRenderer.addEventListener(".viewportUpdate", sendViewportUpdate); + sendViewportUpdate(); + + session.addEventListener("chunks", (event) => { + let { chunkInfo, chunkData } = event; + + console.info("received data for chunks", { + chunkInfoLength: chunkInfo.length, + chunkDataSize: chunkData.size, + }); + + for (let info of event.chunkInfo) { + let key = Wall.chunkKey(info.position.x, info.position.y); + if (chunkDownloadStates.get(key) == "requested") { + pendingChunks -= 1; + } + chunkDownloadStates.set(key, "downloaded"); + + if (info.length > 0) { + let blob = chunkData.slice(info.offset, info.offset + info.length, "image/webp"); + createImageBitmap(blob).then((bitmap) => { + let chunk = wall.getOrCreateChunk(info.position.x, info.position.y); + chunk.ctx.globalCompositeOperation = "copy"; + chunk.ctx.drawImage(bitmap, 0, 0); + chunk.syncToPixmap(); + }); + } + } + }); + + let reportCursor = debounce(updateInterval, (x, y) => session.sendCursor(x, y)); canvasRenderer.addEventListener(".cursor", async (event) => { reportCursor(event.x, event.y); }); - canvasRenderer.addEventListener(".paint", async (event) => { - painter.renderBrush(haku); - let imageBitmap = await painter.createImageBitmap(); - - let left = event.x - painter.paintArea / 2; - let top = event.y - painter.paintArea / 2; - - let leftChunk = Math.floor(left / wall.chunkSize); - let topChunk = Math.floor(top / wall.chunkSize); - let rightChunk = Math.ceil((left + painter.paintArea) / wall.chunkSize); - let bottomChunk = Math.ceil((top + painter.paintArea) / wall.chunkSize); - for (let chunkY = topChunk; chunkY < bottomChunk; ++chunkY) { - for (let chunkX = leftChunk; chunkX < rightChunk; ++chunkX) { - let chunk = wall.getOrCreateChunk(chunkX, chunkY); - let x = Math.floor(-chunkX * wall.chunkSize + left); - let y = Math.floor(-chunkY * wall.chunkSize + top); - chunk.ctx.drawImage(imageBitmap, x, y); - } + let plotQueue = []; + async function flushPlotQueue() { + let points = plotQueue.splice(0, plotQueue.length); + if (points.length != 0) { + session.sendPlot(points); } - imageBitmap.close(); + } + + setInterval(flushPlotQueue, updateInterval); + + canvasRenderer.addEventListener(".paint", async (event) => { + plotQueue.push({ x: event.x, y: event.y }); + currentUser.renderBrushToChunks(wall, event.x, event.y); + }); + + canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.render()); + + currentUser.setBrush(brushEditor.code); + brushEditor.addEventListener(".codeChanged", async () => { + flushPlotQueue(); + currentUser.setBrush(brushEditor.code); + session.sendSetBrush(brushEditor.code); }); session.eventLoop(); diff --git a/static/online-users.js b/static/online-users.js index e364dac..c3caecb 100644 --- a/static/online-users.js +++ b/static/online-users.js @@ -1,12 +1,53 @@ -export class OnlineUsers extends EventTarget { - #users = new Map(); +import { Haku } from "./haku.js"; +import { Painter } from "./painter.js"; - constructor() { - super(); +export class User { + nickname = ""; + brush = ""; + reticle = null; + + isBrushOk = false; + + constructor(wallInfo, nickname) { + this.nickname = nickname; + + this.haku = new Haku(wallInfo.hakuLimits); + this.painter = new Painter(wallInfo.paintArea); } - addUser(sessionId, userInfo) { - this.#users.set(sessionId, userInfo); + setBrush(brush) { + let compileResult = this.haku.setBrush(brush); + this.isBrushOk = compileResult.status == "ok"; + return compileResult; + } + + renderBrushToChunks(wall, x, y) { + this.painter.renderBrushToWall(this.haku, x, y, wall); + } +} + +export class OnlineUsers extends EventTarget { + #wallInfo; + #users = new Map(); + + constructor(wallInfo) { + super(); + + this.#wallInfo = wallInfo; + } + + addUser(sessionId, { nickname, brush }) { + if (!this.#users.has(sessionId)) { + console.info("user added", sessionId, nickname); + + let user = new User(this.#wallInfo, nickname); + user.setBrush(brush); + this.#users.set(sessionId, user); + return user; + } else { + console.info("user already exists", sessionId, nickname); + return this.#users.get(sessionId); + } } getUser(sessionId) { @@ -14,6 +55,11 @@ export class OnlineUsers extends EventTarget { } removeUser(sessionId) { - this.#users.delete(sessionId); + if (this.#users.has(sessionId)) { + let user = this.#users.get(sessionId); + console.info("user removed", sessionId, user.nickname); + // TODO: Cleanup reticles + this.#users.delete(sessionId); + } } } diff --git a/static/painter.js b/static/painter.js index 1c25c0e..b9bc3f4 100644 --- a/static/painter.js +++ b/static/painter.js @@ -1,22 +1,36 @@ -import { Pixmap } from "./haku.js"; - export class Painter { - #pixmap; - imageBitmap; - constructor(paintArea) { this.paintArea = paintArea; - this.#pixmap = new Pixmap(paintArea, paintArea); } - async createImageBitmap() { - return await createImageBitmap(this.#pixmap.imageData); - } + renderBrushToWall(haku, centerX, centerY, wall) { + let evalResult = haku.evalBrush(); + if (evalResult.status != "ok") return evalResult; - renderBrush(haku) { - this.#pixmap.clear(0, 0, 0, 0); - let result = haku.renderBrush(this.#pixmap, this.paintArea / 2, this.paintArea / 2); + let left = centerX - this.paintArea / 2; + let top = centerY - this.paintArea / 2; - return result; + let leftChunk = Math.floor(left / wall.chunkSize); + let topChunk = Math.floor(top / wall.chunkSize); + let rightChunk = Math.ceil((left + this.paintArea) / wall.chunkSize); + let bottomChunk = Math.ceil((top + this.paintArea) / wall.chunkSize); + for (let chunkY = topChunk; chunkY < bottomChunk; ++chunkY) { + for (let chunkX = leftChunk; chunkX < rightChunk; ++chunkX) { + let x = Math.floor(-chunkX * wall.chunkSize + centerX); + let y = Math.floor(-chunkY * wall.chunkSize + centerY); + let chunk = wall.getOrCreateChunk(chunkX, chunkY); + + let renderResult = haku.renderValue(chunk.pixmap, x, y); + if (renderResult.status != "ok") return renderResult; + } + } + haku.resetVm(); + + for (let y = topChunk; y < bottomChunk; ++y) { + for (let x = leftChunk; x < rightChunk; ++x) { + let chunk = wall.getChunk(x, y); + chunk.syncFromPixmap(); + } + } } } diff --git a/static/reticle-renderer.js b/static/reticle-renderer.js index a4d991f..2a1ba9d 100644 --- a/static/reticle-renderer.js +++ b/static/reticle-renderer.js @@ -1,7 +1,10 @@ export class Reticle extends HTMLElement { - #kind = null; - #data = {}; + render(_viewport, _windowSize) { + throw new Error("Reticle.render must be overridden"); + } +} +export class ReticleCursor extends Reticle { #container; constructor(nickname) { @@ -14,84 +17,60 @@ export class Reticle extends HTMLElement { this.#container = this.appendChild(document.createElement("div")); this.#container.classList.add("container"); + + this.classList.add("cursor"); + + let arrow = this.#container.appendChild(document.createElement("div")); + arrow.classList.add("arrow"); + + let nickname = this.#container.appendChild(document.createElement("div")); + nickname.classList.add("nickname"); + nickname.textContent = this.nickname; } getColor() { - let hash = 5381; + let hash = 8803; for (let i = 0; i < this.nickname.length; ++i) { - hash <<= 5; - hash += hash; - hash += this.nickname.charCodeAt(i); - hash &= 0xffff; + hash = (hash << 5) - hash + this.nickname.charCodeAt(i); + hash |= 0; } - return `oklch(70% 0.2 ${(hash / 0xffff) * 360}deg)`; - } - - #update(kind, data) { - this.#data = data; - - if (kind != this.#kind) { - this.classList = ""; - this.#container.replaceChildren(); - this.#kind = kind; - } - - this.dispatchEvent(new Event(".update")); + return `oklch(65% 0.2 ${(hash / 0xffff) * 360}deg)`; } setCursor(x, y) { - this.#update("cursor", { x, y }); + this.x = x; + this.y = y; + this.dispatchEvent(new Event(".update")); } render(viewport, windowSize) { - if (!this.rendered) { - if (this.#kind == "cursor") { - this.classList.add("cursor"); - - let arrow = this.#container.appendChild(document.createElement("div")); - arrow.classList.add("arrow"); - - let nickname = this.#container.appendChild(document.createElement("div")); - nickname.classList.add("nickname"); - nickname.textContent = this.nickname; - } - } - - if (this.#kind == "cursor") { - let { x, y } = this.#data; - let [viewportX, viewportY] = viewport.toScreenSpace(x, y, windowSize); - this.style.transform = `translate(${viewportX}px, ${viewportY}px)`; - } - - this.rendered = true; + let [viewportX, viewportY] = viewport.toScreenSpace(this.x, this.y, windowSize); + this.style.transform = `translate(${viewportX}px, ${viewportY}px)`; } } -customElements.define("rkgk-reticle", Reticle); +customElements.define("rkgk-reticle-cursor", ReticleCursor); export class ReticleRenderer extends HTMLElement { - #reticles = new Map(); + #reticles = new Set(); #reticlesDiv; connectedCallback() { this.#reticlesDiv = this.appendChild(document.createElement("div")); this.#reticlesDiv.classList.add("reticles"); - this.updateTransform(); - let resizeObserver = new ResizeObserver(() => this.updateTransform()); + this.render(); + let resizeObserver = new ResizeObserver(() => this.render()); resizeObserver.observe(this); } connectViewport(viewport) { this.viewport = viewport; - this.updateTransform(); + this.render(); } - getOrAddReticle(onlineUsers, sessionId) { - if (this.#reticles.has(sessionId)) { - return this.#reticles.get(sessionId); - } else { - let reticle = new Reticle(onlineUsers.getUser(sessionId).nickname); + addReticle(reticle) { + if (!this.#reticles.has(reticle)) { reticle.addEventListener(".update", () => { if (this.viewport != null) { reticle.render(this.viewport, { @@ -100,28 +79,26 @@ export class ReticleRenderer extends HTMLElement { }); } }); - this.#reticles.set(sessionId, reticle); + this.#reticles.add(reticle); this.#reticlesDiv.appendChild(reticle); - return reticle; } } - removeReticle(sessionId) { - if (this.#reticles.has(sessionId)) { - let reticle = this.#reticles.get(sessionId); - this.#reticles.delete(sessionId); + removeReticle(reticle) { + if (this.#reticles.has(reticle)) { + this.#reticles.delete(reticle); this.#reticlesDiv.removeChild(reticle); } } - updateTransform() { + render() { if (this.viewport == null) { console.debug("viewport is disconnected, skipping transform update"); return; } let windowSize = { width: this.clientWidth, height: this.clientHeight }; - for (let [_, reticle] of this.#reticles) { + for (let reticle of this.#reticles.values()) { reticle.render(this.viewport, windowSize); } } diff --git a/static/session.js b/static/session.js index d06faca..81b4688 100644 --- a/static/session.js +++ b/static/session.js @@ -85,8 +85,16 @@ class Session extends EventTarget { } } + async #recvBinary() { + let event = await listen([this.ws, "message"]); + if (event.data instanceof Blob) { + return event.data; + } else { + throw new Error("received a text message where a binary message was expected"); + } + } + #sendJson(object) { - console.debug("sendJson", object); this.ws.send(JSON.stringify(object)); } @@ -100,7 +108,7 @@ class Session extends EventTarget { ); } - async join(wallId) { + async join(wallId, userInit) { console.info("joining wall", wallId); this.wallId = wallId; @@ -123,22 +131,32 @@ class Session extends EventTarget { try { await listen([this.ws, "open"]); - await this.joinInner(); + await this.joinInner(wallId, userInit); } catch (error) { this.#dispatchError(error, "connection", `communication failed: ${error.toString()}`); } } - async joinInner() { + async joinInner(wallId, userInit) { let version = await this.#recvJson(); console.info("protocol version", version.version); // TODO: This should probably verify that the version is compatible. // We don't have a way of sending Rust stuff to JavaScript just yet, so we don't care about it. + let init = { + brush: userInit.brush, + }; if (this.wallId == null) { - this.#sendJson({ login: "new", user: this.userId }); + this.#sendJson({ + user: this.userId, + init, + }); } else { - this.#sendJson({ login: "join", user: this.userId, wall: this.wallId }); + this.#sendJson({ + user: this.userId, + wall: wallId, + init, + }); } let loginResponse = await this.#recvJson(); @@ -164,9 +182,9 @@ class Session extends EventTarget { while (true) { let event = await listen([this.ws, "message"]); if (typeof event.data == "string") { - await this.#processEvent(JSON.parse(event.data)); + await this.#processNotify(JSON.parse(event.data)); } else { - console.warn("binary event not yet supported"); + console.warn("unhandled binary event", event.data); } } } catch (error) { @@ -174,27 +192,74 @@ class Session extends EventTarget { } } - async #processEvent(event) { - if (event.kind != null) { + async #processNotify(notify) { + if (notify.notify == "wall") { this.dispatchEvent( - Object.assign(new Event("action"), { - sessionId: event.sessionId, - kind: event.kind, + Object.assign(new Event("wallEvent"), { + sessionId: notify.sessionId, + wallEvent: notify.wallEvent, + }), + ); + } + + if (notify.notify == "chunks") { + let chunkData = await this.#recvBinary(); + this.dispatchEvent( + Object.assign(new Event("chunks"), { + chunkInfo: notify.chunks, + chunkData, }), ); } } - async reportCursor(x, y) { + sendCursor(x, y) { this.#sendJson({ - event: "cursor", - position: { x, y }, + request: "wall", + wallEvent: { + event: "cursor", + position: { x, y }, + }, + }); + } + + sendPlot(points) { + this.#sendJson({ + request: "wall", + wallEvent: { + event: "plot", + points, + }, + }); + } + + sendSetBrush(brush) { + this.#sendJson({ + request: "wall", + wallEvent: { + event: "setBrush", + brush, + }, + }); + } + + sendViewport({ left, top, right, bottom }) { + this.#sendJson({ + request: "viewport", + topLeft: { x: left, y: top }, + bottomRight: { x: right, y: bottom }, + }); + } + + sendMoreChunks() { + this.#sendJson({ + request: "moreChunks", }); } } -export async function newSession(userId, wallId) { +export async function newSession(userId, wallId, userInit) { let session = new Session(userId); - await session.join(wallId); + await session.join(wallId, userInit); return session; } diff --git a/static/wall.js b/static/wall.js index f2f2dec..e522d00 100644 --- a/static/wall.js +++ b/static/wall.js @@ -1,18 +1,29 @@ +import { Pixmap } from "./haku.js"; import { OnlineUsers } from "./online-users.js"; export class Chunk { constructor(size) { + this.pixmap = new Pixmap(size, size); this.canvas = new OffscreenCanvas(size, size); this.ctx = this.canvas.getContext("2d"); } + + syncFromPixmap() { + this.ctx.putImageData(this.pixmap.imageData, 0, 0); + } + + syncToPixmap() { + let imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height); + this.pixmap.imageData.data.set(imageData.data, 0); + } } export class Wall { #chunks = new Map(); - onlineUsers = new OnlineUsers(); - constructor(chunkSize) { - this.chunkSize = chunkSize; + constructor(wallInfo) { + this.chunkSize = wallInfo.chunkSize; + this.onlineUsers = new OnlineUsers(wallInfo); } static chunkKey(x, y) {