diff --git a/Cargo.lock b/Cargo.lock index a223001..0ea1625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -817,13 +817,14 @@ dependencies = [ [[package]] name = "image" -version = "0.25.2" +version = "0.25.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10" +checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" dependencies = [ "bytemuck", "byteorder-lite", "num-traits", + "png", ] [[package]] @@ -1344,12 +1345,14 @@ dependencies = [ "haku", "haku2", "handlebars", + "image", "indexmap", "jotdown", "mime_guess", "rand", "rand_chacha", "rayon", + "rkgk-image-ops", "rusqlite", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 910cd85..5fb2332 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = ["crates/*"] haku.path = "crates/haku" haku2.path = "crates/haku2" log = "0.4.22" +rkgk-image-ops.path = "crates/rkgk-image-ops" tiny-skia = { version = "0.11.4", default-features = false } [profile.dev.package.rkgk-image-ops] diff --git a/crates/haku2/build.rs b/crates/haku2/build.rs index b5495f3..a68dc93 100644 --- a/crates/haku2/build.rs +++ b/crates/haku2/build.rs @@ -6,6 +6,8 @@ use std::{ }; fn main() -> Result<(), Box> { + println!("cargo::rerun-if-changed=build.zig"); + println!("cargo::rerun-if-changed=build.zig.zon"); println!("cargo::rerun-if-changed=src"); let out_dir = env::var("OUT_DIR").unwrap(); @@ -58,8 +60,5 @@ fn main() -> Result<(), Box> { panic!("zig failed to build"); } - println!("cargo::rustc-link-search={out_dir}/zig-out/lib"); - println!("cargo::rustc-link-lib=haku2"); - Ok(()) } diff --git a/crates/haku2/build.zig b/crates/haku2/build.zig index f540685..098aeb6 100644 --- a/crates/haku2/build.zig +++ b/crates/haku2/build.zig @@ -12,13 +12,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .pic = true, }); - const lib = b.addStaticLibrary(.{ - .name = "haku2", - .root_module = mod, - }); - lib.pie = true; - lib.bundle_compiler_rt = true; - b.installArtifact(lib); const mod_wasm = b.createModule(.{ .root_source_file = b.path("src/haku2.zig"), diff --git a/crates/haku2/src/lib.rs b/crates/haku2/src/lib.rs index 708a5fc..09630af 100644 --- a/crates/haku2/src/lib.rs +++ b/crates/haku2/src/lib.rs @@ -1,575 +1,3 @@ -use std::{ - alloc::{self, Layout}, - error::Error, - fmt::{self, Display}, - marker::{PhantomData, PhantomPinned}, - ptr::{self, NonNull}, - slice, -}; - -use log::trace; - +/// WebAssembly code for haku2. +/// haku2 is purely a client-side library, and does not have Rust bindings. pub static WASM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/zig-out/bin/haku2.wasm")); - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_alloc(size: usize, align: usize) -> *mut u8 { - if let Ok(layout) = Layout::from_size_align(size, align) { - alloc::alloc(layout) - } else { - ptr::null_mut() - } -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_realloc( - ptr: *mut u8, - size: usize, - align: usize, - new_size: usize, -) -> *mut u8 { - if let Ok(layout) = Layout::from_size_align(size, align) { - alloc::realloc(ptr, layout, new_size) - } else { - ptr::null_mut() - } -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_dealloc(ptr: *mut u8, size: usize, align: usize) { - match Layout::from_size_align(size, align) { - Ok(layout) => alloc::dealloc(ptr, layout), - Err(_) => { - log::error!("__haku2_dealloc: invalid layout size={size} align={align} ptr={ptr:?}") - } - } -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_log_err( - scope: *const u8, - scope_len: usize, - msg: *const u8, - len: usize, -) { - let scope = String::from_utf8_lossy(slice::from_raw_parts(scope, scope_len)); - let msg = String::from_utf8_lossy(slice::from_raw_parts(msg, len)); - log::error!("{scope}: {msg}"); -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_log_warn( - scope: *const u8, - scope_len: usize, - msg: *const u8, - len: usize, -) { - let scope = String::from_utf8_lossy(slice::from_raw_parts(scope, scope_len)); - let msg = String::from_utf8_lossy(slice::from_raw_parts(msg, len)); - log::warn!("{scope}: {msg}"); -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_log_info( - scope: *const u8, - scope_len: usize, - msg: *const u8, - len: usize, -) { - let scope = String::from_utf8_lossy(slice::from_raw_parts(scope, scope_len)); - let msg = String::from_utf8_lossy(slice::from_raw_parts(msg, len)); - log::info!("{scope}: {msg}"); -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_log_debug( - scope: *const u8, - scope_len: usize, - msg: *const u8, - len: usize, -) { - let scope = String::from_utf8_lossy(slice::from_raw_parts(scope, scope_len)); - let msg = String::from_utf8_lossy(slice::from_raw_parts(msg, len)); - log::debug!("{scope}: {msg}"); -} - -#[repr(C)] -struct ScratchC { - _data: (), - _marker: PhantomData<(*mut u8, PhantomPinned)>, -} - -#[repr(C)] -struct LimitsC { - _data: (), - _marker: PhantomData<(*mut u8, PhantomPinned)>, -} - -#[repr(C)] -struct DefsC { - _data: (), - _marker: PhantomData<(*mut u8, PhantomPinned)>, -} - -#[repr(C)] -struct VmC { - _data: (), - _marker: PhantomData<(*mut u8, PhantomPinned)>, -} - -extern "C" { - fn haku2_scratch_new(max: usize) -> *mut ScratchC; - fn haku2_scratch_destroy(scratch: *mut ScratchC); - fn haku2_scratch_reset(scratch: *mut ScratchC); - - fn haku2_limits_new() -> *mut LimitsC; - fn haku2_limits_destroy(limits: *mut LimitsC); - fn haku2_limits_set_stack_capacity(limits: *mut LimitsC, new: usize); - fn haku2_limits_set_call_stack_capacity(limits: *mut LimitsC, new: usize); - - fn haku2_defs_parse( - defs_string: *const u8, - defs_len: usize, - tags_string: *const u8, - tags_len: usize, - ) -> *mut DefsC; - fn haku2_defs_destroy(defs: *mut DefsC); - - fn haku2_vm_new() -> *mut VmC; - fn haku2_vm_destroy(vm: *mut VmC); - fn haku2_vm_reset( - vm: *mut VmC, - s: *mut ScratchC, - defs: *const DefsC, - limits: *const LimitsC, - fuel: u32, - ); - fn haku2_vm_run_main( - vm: *mut VmC, - scratch: *mut ScratchC, - code: *const u8, - code_len: usize, - local_count: u8, - ) -> bool; - fn haku2_vm_has_cont(vm: *const VmC) -> bool; - fn haku2_vm_is_dotter(vm: *const VmC) -> bool; - fn haku2_vm_run_dotter( - vm: *mut VmC, - scratch: *mut ScratchC, - from_x: f32, - from_y: f32, - to_x: f32, - to_y: f32, - num: f32, - ) -> bool; - fn haku2_vm_exception_len(vm: *const VmC) -> usize; - fn haku2_vm_exception_render(vm: *const VmC, buffer: *mut u8); - - // improper_ctypes is emitted for `*mut CanvasC`, which is an opaque {} on the Zig side and - // therefore FFI-safe. - #[expect(improper_ctypes)] - fn haku2_render(vm: *mut VmC, canvas: *mut CanvasC, max_depth: usize) -> bool; -} - -#[derive(Debug)] -pub struct Scratch { - raw: NonNull, -} - -impl Scratch { - pub fn new(max: usize) -> Scratch { - // SAFETY: haku2_scratch_new does not have any safety invariants. - let raw = NonNull::new(unsafe { haku2_scratch_new(max) }).expect("out of memory"); - trace!("Scratch::new -> {raw:?}"); - Scratch { raw } - } - - pub fn reset(&mut self) { - trace!("Scratch::reset({:?})", self.raw); - // SAFETY: The pointer passed is non-null. - unsafe { - haku2_scratch_reset(self.raw.as_ptr()); - } - } -} - -impl Drop for Scratch { - fn drop(&mut self) { - trace!("Scratch::drop({:?})", self.raw); - // SAFETY: The pointer passed is non-null. - unsafe { - haku2_scratch_destroy(self.raw.as_ptr()); - } - } -} - -#[derive(Debug, Clone, Copy)] -pub struct LimitsSpec { - pub stack_capacity: usize, - pub call_stack_capacity: usize, -} - -#[derive(Debug)] -pub struct Limits { - raw: NonNull, -} - -// SAFETY: Limits's backing storage is only modified on creation. -// Changing the limits requires creating a new instance. -unsafe impl Send for Limits {} -unsafe impl Sync for Limits {} - -impl Limits { - pub fn new(spec: LimitsSpec) -> Self { - // SAFETY: haku2_limits_new has no safety invariants. - let limits = NonNull::new(unsafe { haku2_limits_new() }).expect("out of memory"); - - // SAFETY: The following functions are called on a valid pointer. - unsafe { - haku2_limits_set_stack_capacity(limits.as_ptr(), spec.stack_capacity); - haku2_limits_set_call_stack_capacity(limits.as_ptr(), spec.call_stack_capacity); - } - - Self { raw: limits } - } -} - -impl Drop for Limits { - fn drop(&mut self) { - // SAFETY: The pointer passed is non-null. - unsafe { - haku2_limits_destroy(self.raw.as_ptr()); - } - } -} - -#[derive(Debug)] -pub struct Defs { - raw: NonNull, -} - -// SAFETY: Defs' backing storage is not modified after creation. -unsafe impl Send for Defs {} -unsafe impl Sync for Defs {} - -impl Defs { - pub fn parse(defs: &str, tags: &str) -> Self { - Self { - raw: NonNull::new(unsafe { - haku2_defs_parse(defs.as_ptr(), defs.len(), tags.as_ptr(), tags.len()) - }) - .expect("out of memory"), - } - } -} - -impl Drop for Defs { - fn drop(&mut self) { - // SAFETY: The pointer passed is non-null. - unsafe { - haku2_defs_destroy(self.raw.as_ptr()); - } - } -} - -#[derive(Debug)] -pub struct Code { - defs: Defs, - main_chunk: Vec, - main_local_count: u8, -} - -impl Code { - /// Creates a new instance of `Code` from a valid vector of bytes. - /// - /// # Safety - /// - /// This does not perform any validation, and there is no way to perform such - /// validation before constructing this. The bytecode must simply be valid, which is the case - /// for bytecode emitted directly by the compiler. - /// - /// Untrusted bytecode should never ever be loaded under any circumstances. - pub unsafe fn new(defs: Defs, main_chunk: Vec, main_local_count: u8) -> Self { - Self { - defs, - main_chunk, - main_local_count, - } - } -} - -/// A VM that is ready to run and loaded with valid bytecode. -#[derive(Debug)] -pub struct Vm { - scratch: Scratch, - code: Code, - limits: Limits, - inner: VmInner, -} - -#[derive(Debug)] -pub enum Cont<'vm> { - None, - Dotter(ContDotter<'vm>), -} - -#[derive(Debug)] -pub struct ContDotter<'vm> { - vm: &'vm mut Vm, -} - -#[derive(Debug, Clone, Copy)] -pub struct Dotter { - pub from: (f32, f32), - pub to: (f32, f32), - pub num: f32, -} - -impl Vm { - pub fn new(scratch: Scratch, code: Code, limits: Limits) -> Self { - // SAFETY: haku2_vm_new cannot fail. - // Do note that this returns an uninitialized VM, which must be reset before use. - let raw = NonNull::new(unsafe { haku2_vm_new() }).expect("out of memory"); - trace!("Vm::new({scratch:?}, {code:?}, {limits:?}) -> {raw:?}"); - Self { - // SAFETY: - // - Ownership of scratch is passed to the VM, so the VM cannot outlive the scratch space. - // - The VM never gives you any references back, so this is safe to do. - // - The other arguments are only borrowed immutably for construction. - inner: VmInner { raw }, - scratch, - code, - limits, - } - } - - /// Begin running code. This makes the VM enter a "trampoline" state: after this call, you may - /// proceed to call `cont` as many times as it returns a value other than [`Cont::None`]. - /// - /// Calling `begin` again during this process will work correctly, and result in another - /// continuation being stack on top of the old one---at the expense of a stack slot. - pub fn begin(&mut self, fuel: u32) -> Result<(), Exception> { - trace!("Vm::begin({self:?}, {fuel})"); - self.scratch.reset(); - let ok = unsafe { - haku2_vm_reset( - self.inner.raw.as_ptr(), - self.scratch.raw.as_ptr(), - self.code.defs.raw.as_ptr(), - self.limits.raw.as_ptr(), - fuel, - ); - haku2_vm_run_main( - self.inner.raw.as_ptr(), - self.scratch.raw.as_ptr(), - self.code.main_chunk.as_ptr(), - self.code.main_chunk.len(), - self.code.main_local_count, - ) - }; - if ok { - Ok(()) - } else { - Err(self.exception().expect("missing exception after !ok")) - } - } - - /// Returns whether `cont()` can be called to run the next continuation. - pub fn has_cont(&self) -> bool { - unsafe { haku2_vm_has_cont(self.inner.raw.as_ptr()) } - } - - fn is_dotter(&self) -> bool { - // SAFETY: The pointer is valid. - unsafe { haku2_vm_is_dotter(self.inner.raw.as_ptr()) } - } - - /// Returns how the VM should continue executing after the previous execution. - pub fn cont(&mut self) -> Cont<'_> { - match () { - _ if self.is_dotter() => Cont::Dotter(ContDotter { vm: self }), - _ => Cont::None, - } - } - - /// Renders the current scribble on top of the stack. - /// If the value on top is not a scribble, throws an exception. - /// - /// The rendering is performed by calling into the [`Canvas`] trait. - pub fn render(&mut self, canvas: &mut dyn Canvas, max_depth: usize) -> Result<(), Exception> { - let mut wrapped = CanvasC { inner: canvas }; - let ok = unsafe { haku2_render(self.inner.raw.as_ptr(), &mut wrapped, max_depth) }; - if ok { - Ok(()) - } else { - Err(self.exception().expect("missing exception after !ok")) - } - } - - /// Render the current exception out to a string. - /// Returns `None` if there's no exception. - pub fn exception(&self) -> Option { - // SAFETY: The pointer passed to this function is valid. - let len = unsafe { haku2_vm_exception_len(self.inner.raw.as_ptr()) }; - if len == 0 { - return None; - } - - let mut buffer = vec![0; len]; - // SAFETY: The length of the buffer is as indicated by haku2_vm_exception_len. - unsafe { - haku2_vm_exception_render(self.inner.raw.as_ptr(), buffer.as_mut_ptr()); - } - Some(Exception { - message: String::from_utf8_lossy(&buffer).into_owned(), - }) - } - - /// Take the `Scratch` out of the VM for reuse in another one. - /// The scratch memory will be reset (no bytes will be consumed.) - pub fn into_scratch(self) -> Scratch { - trace!("Vm::into_scratch({self:?})"); - let Vm { - mut scratch, - code: _, - inner: _, - limits: _, - } = self; - scratch.reset(); - scratch - } -} - -impl ContDotter<'_> { - pub fn run(self, dotter: &Dotter) -> Result<(), Exception> { - trace!("ContDotter::run({self:?}, {dotter:?})"); - - let Dotter { - from: (from_x, from_y), - to: (to_x, to_y), - num, - } = *dotter; - - let ok = unsafe { - haku2_vm_run_dotter( - self.vm.inner.raw.as_ptr(), - self.vm.scratch.raw.as_ptr(), - from_x, - from_y, - to_x, - to_y, - num, - ) - }; - if ok { - Ok(()) - } else { - Err(self.vm.exception().expect("missing exception after !ok")) - } - } -} - -#[derive(Debug)] -struct VmInner { - raw: NonNull, -} - -impl Drop for VmInner { - fn drop(&mut self) { - trace!("VmInner::drop({:?})", self.raw); - // SAFETY: The pointer passed is non-null. - unsafe { - haku2_vm_destroy(self.raw.as_ptr()); - } - } -} - -#[derive(Debug)] -pub struct Exception { - message: String, -} - -impl Display for Exception { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.message) - } -} - -impl Error for Exception {} - -/// Marker for the VM to indicate that the rendering did not go down correctly. -/// If this is encountered, it throws an exception and aborts rendering. -#[derive(Debug)] -pub struct RenderError; - -pub trait Canvas { - fn begin(&mut self) -> Result<(), RenderError>; - fn line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) -> Result<(), RenderError>; - fn rectangle(&mut self, x: f32, y: f32, width: f32, height: f32) -> Result<(), RenderError>; - fn circle(&mut self, x: f32, y: f32, r: f32) -> Result<(), RenderError>; - fn fill(&mut self, r: u8, g: u8, b: u8, a: u8) -> Result<(), RenderError>; - fn stroke(&mut self, r: u8, g: u8, b: u8, a: u8, thickness: f32) -> Result<(), RenderError>; -} - -// SAFETY NOTE: I'm not sure the ownership model for this is quite correct. -// Given how the &mut's ownership flows through the Zig side of the code, it _should_ be fine, -// but I'm not an unsafe code expert to say this is the case for sure. - -#[repr(C)] -struct CanvasC<'a> { - inner: &'a mut dyn Canvas, -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_canvas_begin(c: *mut CanvasC) -> bool { - let c = &mut *c; - c.inner.begin().is_ok() -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_canvas_line( - c: *mut CanvasC, - x1: f32, - y1: f32, - x2: f32, - y2: f32, -) -> bool { - let c = &mut *c; - c.inner.line(x1, y1, x2, y2).is_ok() -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_canvas_rectangle( - c: *mut CanvasC, - x: f32, - y: f32, - width: f32, - height: f32, -) -> bool { - let c = &mut *c; - c.inner.rectangle(x, y, width, height).is_ok() -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_canvas_circle(c: *mut CanvasC, x: f32, y: f32, r: f32) -> bool { - let c = &mut *c; - c.inner.circle(x, y, r).is_ok() -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_canvas_fill(c: *mut CanvasC, r: u8, g: u8, b: u8, a: u8) -> bool { - let c = &mut *c; - c.inner.fill(r, g, b, a).is_ok() -} - -#[unsafe(no_mangle)] -unsafe extern "C" fn __haku2_canvas_stroke( - c: *mut CanvasC, - r: u8, - g: u8, - b: u8, - a: u8, - thickness: f32, -) -> bool { - let c = &mut *c; - c.inner.stroke(r, g, b, a, thickness).is_ok() -} diff --git a/crates/rkgk-image-ops/src/lib.rs b/crates/rkgk-image-ops/src/lib.rs index b93cf3f..305426f 100644 --- a/crates/rkgk-image-ops/src/lib.rs +++ b/crates/rkgk-image-ops/src/lib.rs @@ -1,14 +1,36 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +#[derive(Debug, Clone, Copy)] +pub struct Image<'a> { + pub width: u32, + pub height: u32, + pub data: &'a [u8], } -#[cfg(test)] -mod tests { - use super::*; +#[derive(Debug)] +pub struct ImageMut<'a> { + pub width: u32, + pub height: u32, + pub data: &'a mut [u8], +} - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); +impl ImageMut<'_> { + pub fn composite_alpha(&mut self, src: &Image<'_>) { + assert_eq!(self.width, src.width); + assert_eq!(self.height, src.height); + + fn fixp_mul(a: u8, b: u8) -> u8 { + ((a as u16 * b as u16 + 255) >> 8) as u8 + } + + fn alpha_blend(dst: u8, src: u8, alpha: u8) -> u8 { + fixp_mul(src, alpha) + fixp_mul(dst, 255 - alpha) + } + + for (dst_rgba, src_rgba) in self.data.chunks_exact_mut(4).zip(src.data.chunks_exact(4)) { + let src_alpha = src_rgba[3]; + dst_rgba[0] = alpha_blend(dst_rgba[0], src_rgba[0], src_alpha); + dst_rgba[1] = alpha_blend(dst_rgba[1], src_rgba[1], src_alpha); + dst_rgba[2] = alpha_blend(dst_rgba[2], src_rgba[2], src_alpha); + dst_rgba[3] = alpha_blend(dst_rgba[3], src_rgba[3], src_alpha); + } } } diff --git a/crates/rkgk/Cargo.toml b/crates/rkgk/Cargo.toml index a4b9d73..8967f6d 100644 --- a/crates/rkgk/Cargo.toml +++ b/crates/rkgk/Cargo.toml @@ -18,11 +18,13 @@ haku.workspace = true haku2.workspace = true handlebars = "6.0.0" indexmap = { version = "2.4.0", features = ["serde"] } +image = { version = "0.25.6", default-features = false, features = ["png"] } jotdown = "0.5.0" mime_guess = "2.0.5" rand = "0.8.5" rand_chacha = "0.3.1" rayon = "1.10.0" +rkgk-image-ops.workspace = true rusqlite = { version = "0.32.1", features = ["bundled"] } serde = { version = "1.0.206", features = ["derive"] } serde_json = "1.0.124" diff --git a/crates/rkgk/src/api/wall.rs b/crates/rkgk/src/api/wall.rs index 7d4f25d..a1a3445 100644 --- a/crates/rkgk/src/api/wall.rs +++ b/crates/rkgk/src/api/wall.rs @@ -1,5 +1,6 @@ use std::{ collections::{HashSet, VecDeque}, + io::Cursor, sync::Arc, }; @@ -12,10 +13,14 @@ use axum::{ }; use base64::Engine; use eyre::{bail, Context, OptionExt}; +use image::{DynamicImage, ImageFormat, ImageReader}; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator as _}; +use rkgk_image_ops::{Image, ImageMut}; use schema::{ ChunkInfo, Error, LoginRequest, LoginResponse, Notify, Online, Request, Version, WallInfo, }; use serde::{Deserialize, Serialize}; +use tiny_skia::{IntSize, Pixmap}; use tokio::{ select, sync::{mpsc, oneshot}, @@ -23,13 +28,14 @@ use tokio::{ use tracing::{error, info, info_span, instrument}; use crate::{ - haku::{Haku, Limits}, login::{self, database::LoginStatus}, - schema::Vec2, wall::{ - self, auto_save::AutoSave, chunk_images::ChunkImages, chunk_iterator::ChunkIterator, - database::ChunkDataPair, render::ChunkCanvas, ChunkPosition, Interaction, JoinError, - SessionHandle, UserInit, Wall, WallId, + self, + auto_save::AutoSave, + chunk_images::{ChunkImages, LoadedChunks}, + chunk_iterator::ChunkIterator, + database::ChunkDataPair, + ChunkPosition, JoinError, SessionHandle, UserInit, Wall, WallId, }, }; @@ -167,6 +173,7 @@ async fn fallible_websocket(api: Arc, ws: &mut WebSocket) -> eyre::Result<( wall_info: WallInfo { chunk_size: open_wall.wall.settings().chunk_size, paint_area: open_wall.wall.settings().paint_area, + max_edit_size: open_wall.wall.settings().max_edit_size, online: users_online, haku_limits: api.config.haku.clone(), }, @@ -195,8 +202,6 @@ async fn fallible_websocket(api: Arc, ws: &mut WebSocket) -> eyre::Result<( open_wall.chunk_images, open_wall.auto_save, session_handle, - api.config.haku.clone(), - login_request.init.brush, ) .await? .event_loop(ws) @@ -219,9 +224,16 @@ struct SessionLoop { pending_images: VecDeque, } +struct EditWithData { + chunk: ChunkPosition, + data_type: String, + data: Vec, +} + enum RenderCommand { - Interact { - interactions: Vec, + Edit { + chunks: LoadedChunks, + edits: Vec, done: oneshot::Sender<()>, }, } @@ -233,26 +245,12 @@ impl SessionLoop { chunk_images: Arc, auto_save: 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); - let thread_ready = { - let (done_tx, done_rx) = oneshot::channel(); - render_commands_tx - .send(RenderCommand::Interact { - interactions: vec![Interaction::SetBrush { brush }], - done: done_tx, - }) - .await - .unwrap(); - done_rx - }; - // We spawn our own thread so as not to clog the tokio blocking thread pool with our // rendering shenanigans. std::thread::Builder::new() @@ -263,13 +261,11 @@ impl SessionLoop { let _span = info_span!("render_thread", ?wall_id, session_id = ?handle.session_id) .entered(); - Self::render_thread(wall, limits, render_commands_rx) + render_thread(wall, render_commands_rx) } }) .context("could not spawn render thread")?; - thread_ready.await?; - Ok(Self { wall_id, wall, @@ -310,51 +306,49 @@ impl SessionLoop { } 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. + match wall_event { wall::EventKind::Join { .. } | wall::EventKind::Leave - | wall::EventKind::Cursor { .. } => (), + | wall::EventKind::Cursor { .. } + | wall::EventKind::Interact { .. } => { + self.wall.event(wall::Event { + session_id: self.handle.session_id, + kind: wall_event, + }); + } - wall::EventKind::Interact { interactions } => { - let (done_tx, done_rx) = oneshot::channel(); + wall::EventKind::Edit { edits } => { + let chunk_data = recv_expect(ws).await?.into_data(); + + let mut edits_with_data = Vec::with_capacity(edits.len()); + for edit in edits { + if let Some(data) = chunk_data + .get(edit.data_offset..edit.data_offset + edit.data_length) + { + edits_with_data.push(EditWithData { + chunk: edit.chunk, + data_type: edit.data_type, + data: data.to_owned(), + }); + } + } let chunks_to_modify: Vec<_> = - chunks_to_modify(self.wall.settings(), interactions) - .into_iter() - .collect(); + edits_with_data.iter().map(|edit| edit.chunk).collect(); match self.chunk_images.load(chunks_to_modify.clone()).await { - Ok(_) => { - if interactions - .iter() - .any(|i| matches!(i, Interaction::SetBrush { .. })) - { - // SetBrush is an important event, so we wait for the render thread - // to unload. - _ = self - .render_commands_tx - .send(RenderCommand::Interact { - interactions: interactions.clone(), - done: done_tx, - }) - .await; - } else { - // If there is no SetBrush, there's no need to wait, so we fire events - // blindly. If the thread's not okay with that... well, whatever. - // That's your issue for making a really slow brush. - let send_result = - self.render_commands_tx.try_send(RenderCommand::Interact { - interactions: interactions.clone(), - done: done_tx, - }); - if send_result.is_err() { - info!( - ?interactions, - "render thread is overloaded, dropping interaction request" - ); - } - } + Ok(chunks) => { + let (done_tx, done_rx) = oneshot::channel(); + // Wait during contention. + // We don't want to drop any edits, as that would result in + // graphical glitches and desyncs. + _ = self + .render_commands_tx + .send(RenderCommand::Edit { + chunks, + edits: edits_with_data, + done: done_tx, + }) + .await; let auto_save = Arc::clone(&self.auto_save); tokio::spawn(async move { @@ -366,11 +360,6 @@ impl SessionLoop { } } } - - self.wall.event(wall::Event { - session_id: self.handle.session_id, - kind: wall_event, - }); } Request::Viewport { @@ -446,132 +435,82 @@ impl SessionLoop { Ok(()) } - - fn render_thread(wall: Arc, limits: Limits, mut commands: mpsc::Receiver) { - let mut haku = Haku::new(limits); - let mut brush_ok = false; - let mut current_render_area = RenderArea::default(); - - while let Some(command) = commands.blocking_recv() { - let RenderCommand::Interact { interactions, done } = command; - - let mut queue = VecDeque::from(interactions); - while let Some(interaction) = queue.pop_front() { - if let Some(render_area) = render_area(wall.settings(), &interaction) { - current_render_area = render_area; - } - - match interaction { - Interaction::SetBrush { brush } => { - brush_ok = haku.set_brush(&brush).is_ok(); - } - - Interaction::Dotter { from, to, num } => { - if brush_ok { - jumpstart_trampoline(&mut haku); - match haku.cont() { - haku2::Cont::Dotter(dotter) => match dotter.run(&haku2::Dotter { - from: (from.x, from.y), - to: (to.x, to.y), - num, - }) { - Ok(_) => (), - Err(err) => error!("exception while running dotter: {err}"), - }, - other => error!("received Dotter interaction when a {other:?} continuation was next") - } - } - } - - Interaction::Scribble => { - match haku.cont() { - haku2::Cont::None => { - draw_to_chunks(&wall, current_render_area, &mut haku); - } - _ => error!("tried to draw a scribble with an active continuation"), - } - - current_render_area = RenderArea::default(); - } - } - } - - _ = done.send(()); - } - } } -#[derive(Debug, Clone, Copy, Default)] -struct RenderArea { - top_left: Vec2, - bottom_right: Vec2, -} +fn render_thread(wall: Arc, mut commands: mpsc::Receiver) { + while let Some(command) = commands.blocking_recv() { + let RenderCommand::Edit { + chunks, + edits, + done, + } = command; -fn render_area(wall_settings: &wall::Settings, interaction: &Interaction) -> Option { - match interaction { - Interaction::Dotter { from, to, .. } => { - let half_paint_area = wall_settings.paint_area as f32 / 2.0; - Some(RenderArea { - top_left: Vec2::new(from.x - half_paint_area, from.y - half_paint_area), - bottom_right: Vec2::new(to.x + half_paint_area, to.y + half_paint_area), + let positions: Vec<_> = edits.iter().map(|edit| edit.chunk).collect(); + + let chunk_size = wall.settings().chunk_size; + let chunk_int_size = IntSize::from_wh(chunk_size, chunk_size).unwrap(); + info!("decoding edits"); + let decoded = edits + .par_iter() + .flat_map(|edit| match &edit.data_type[..] { + "image/png" => { + let mut reader = ImageReader::new(Cursor::new(&edit.data)); + reader.set_format(ImageFormat::Png); + reader.limits({ + let mut limits = image::Limits::no_limits(); + limits.max_image_width = Some(chunk_size); + limits.max_image_height = Some(chunk_size); + limits + }); + + reader + .decode() + .context("image decoding failed") + .and_then(|image| { + if image.width() != chunk_size || image.height() != chunk_size { + bail!( + "{:?} image size {}x{} does not match chunk size {}", + edit.chunk, + image.width(), + image.height(), + chunk_size + ); + } + Ok(image) + }) + .map(DynamicImage::into_rgba8) + .inspect_err(|err| info!(?edit.chunk, ?err, "error while decoding")) + .ok() + .and_then(|image| Pixmap::from_vec(image.into_raw(), chunk_int_size)) + } + + _ => { + info!(edit.data_type, "unknown data type"); + None + } }) + .collect::>(); + info!("edits decoded"); + + let mut chunks_locked = Vec::new(); + for position in &positions { + chunks_locked.push(chunks.chunks[position].blocking_lock()); } - Interaction::SetBrush { .. } | Interaction::Scribble => None, - } -} - -fn chunks_to_modify( - wall_settings: &wall::Settings, - interactions: &[Interaction], -) -> HashSet { - let mut chunks = HashSet::new(); - - for interaction in interactions { - // NOTE: This is mostly a tentative overestimation, and can result in more chunks being - // marked as needing autosave than will be touched in reality. - // It's better to play safe in this case than lose data. - if let Some(render_area) = render_area(wall_settings, interaction) { - let top_left_chunk = wall_settings.chunk_at(render_area.top_left); - let bottom_right_chunk = wall_settings.chunk_at_ceil(render_area.bottom_right); - for chunk_y in top_left_chunk.y..bottom_right_chunk.y { - for chunk_x in top_left_chunk.x..bottom_right_chunk.x { - chunks.insert(ChunkPosition::new(chunk_x, chunk_y)); - } - } - } - } - - chunks -} - -fn jumpstart_trampoline(haku: &mut Haku) { - if !haku.has_cont() { - if let Err(e) = haku.eval_brush() { - error!("eval_brush2 exception: {e:?}"); - } - } -} - -#[instrument(skip(wall, vm))] -fn draw_to_chunks(wall: &Wall, render_area: RenderArea, vm: &mut Haku) { - let settings = wall.settings(); - - let chunk_size = settings.chunk_size as f32; - - let top_left_chunk = settings.chunk_at(render_area.top_left); - let bottom_right_chunk = settings.chunk_at_ceil(render_area.bottom_right); - - for chunk_y in top_left_chunk.y..bottom_right_chunk.y { - for chunk_x in top_left_chunk.x..bottom_right_chunk.x { - let x = f32::floor(-chunk_x as f32 * chunk_size); - let y = f32::floor(-chunk_y as f32 * chunk_size); - let chunk_ref = wall.get_or_create_chunk(ChunkPosition::new(chunk_x, chunk_y)); - let mut chunk = chunk_ref.blocking_lock(); - chunk.touch(); - let mut canvas = ChunkCanvas::new(&mut chunk).translated(x, y); - if let Err(e) = vm.render(&mut canvas, 256) { - info!(chunk_x, chunk_y, "drawing failed: {e}"); - } - } + + for (mut dst_chunk, src_pixmap) in chunks_locked.into_iter().zip(decoded) { + let mut dst = ImageMut { + width: chunk_size, + height: chunk_size, + data: dst_chunk.pixmap.data_mut(), + }; + let src = Image { + width: chunk_size, + height: chunk_size, + data: src_pixmap.data(), + }; + dst.composite_alpha(&src); + } + + _ = done.send(()); } } diff --git a/crates/rkgk/src/api/wall/schema.rs b/crates/rkgk/src/api/wall/schema.rs index 30b41fc..ed8c242 100644 --- a/crates/rkgk/src/api/wall/schema.rs +++ b/crates/rkgk/src/api/wall/schema.rs @@ -42,6 +42,7 @@ pub struct Online { pub struct WallInfo { pub chunk_size: u32, pub paint_area: u32, + pub max_edit_size: usize, pub haku_limits: crate::haku::Limits, pub online: Vec, } diff --git a/crates/rkgk/src/haku.rs b/crates/rkgk/src/haku.rs index 0ea52ad..afc2614 100644 --- a/crates/rkgk/src/haku.rs +++ b/crates/rkgk/src/haku.rs @@ -1,20 +1,6 @@ //! 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::{ - ast::Ast, - bytecode::{Chunk, Defs, DefsImage, DefsLimits}, - compiler::{Compiler, Source}, - lexer::{lex, Lexer}, - parser::{self, Parser, ParserLimits}, - source::SourceCode, - token::Lexis, -}; use serde::{Deserialize, Serialize}; -use tracing::{info, instrument, Level}; #[derive(Debug, Clone, Deserialize, Serialize)] // NOTE: For serialization, this struct does _not_ have serde(rename_all = "camelCase") on it, @@ -36,123 +22,3 @@ pub struct Limits { pub memory: usize, pub render_max_depth: usize, } - -pub struct Haku { - limits: Limits, - - defs: Defs, - defs_image: DefsImage, - - vm: Option, -} - -impl Haku { - pub fn new(limits: Limits) -> Self { - let defs = Defs::new(&DefsLimits { - max_defs: limits.max_defs, - max_tags: limits.max_tags, - }); - - let defs_image = defs.image(); - - Self { - limits, - defs, - defs_image, - vm: None, - } - } - - fn reset(&mut self) { - self.defs.restore_image(&self.defs_image); - } - - #[instrument(skip(self, code), err)] - pub fn set_brush(&mut self, code: &str) -> eyre::Result<()> { - info!(?code); - - self.reset(); - - let code = SourceCode::limited_len(code, self.limits.max_source_code_len) - .ok_or_eyre("source code is too long")?; - - let mut lexer = Lexer::new(Lexis::new(self.limits.max_tokens), code); - lex(&mut lexer)?; - - let mut parser = Parser::new( - &lexer.lexis, - &ParserLimits { - max_events: self.limits.max_parser_events, - }, - ); - parser::toplevel(&mut parser); - let mut ast = Ast::new(self.limits.ast_capacity); - let (root, parser_diagnostics) = parser.into_ast(&mut ast)?; - - let src = Source { code, ast: &ast }; - - 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")?; - let closure_spec = compiler.closure_spec(); - - if !lexer.diagnostics.is_empty() - || !parser_diagnostics.is_empty() - || !compiler.diagnostics.is_empty() - { - info!(?lexer.diagnostics, ?parser_diagnostics, ?compiler.diagnostics, "diagnostics were emitted"); - bail!("diagnostics were emitted"); - } - - let scratch = self - .vm - .take() - .map(|vm| vm.into_scratch()) - .unwrap_or_else(|| haku2::Scratch::new(self.limits.memory)); - let defs = haku2::Defs::parse(&self.defs.serialize_defs(), &self.defs.serialize_tags()); - // SAFETY: The code is fresh out of the compiler oven, so it is guaranteed to be valid. - // Well, more or less. There may lurk bugs. - let code = unsafe { haku2::Code::new(defs, chunk.bytecode, closure_spec.local_count) }; - let limits = haku2::Limits::new(haku2::LimitsSpec { - stack_capacity: self.limits.stack_capacity, - call_stack_capacity: self.limits.call_stack_capacity, - }); - self.vm = Some(haku2::Vm::new(scratch, code, limits)); - - info!("brush set successfully"); - - Ok(()) - } - - #[instrument(skip(self), err(level = Level::INFO))] - pub fn eval_brush(&mut self) -> eyre::Result<()> { - let vm = self - .vm - .as_mut() - .ok_or_eyre("brush is not compiled and ready to be used")?; - vm.begin(self.limits.fuel as u32) - .context("an exception occurred during begin()")?; - Ok(()) - } - - #[instrument(skip(self, canvas, max_depth), err(level = Level::INFO))] - pub fn render(&mut self, canvas: &mut dyn haku2::Canvas, max_depth: usize) -> eyre::Result<()> { - let vm = self - .vm - .as_mut() - .ok_or_eyre("VM is not ready for rendering")?; - vm.render(canvas, max_depth) - .context("exception while rendering")?; - Ok(()) - } - - pub fn has_cont(&mut self) -> bool { - self.vm.as_mut().expect("VM is not started").has_cont() - } - - pub fn cont(&mut self) -> haku2::Cont<'_> { - self.vm.as_mut().expect("VM is not started").cont() - } -} diff --git a/crates/rkgk/src/wall.rs b/crates/rkgk/src/wall.rs index e283cbe..f7219e9 100644 --- a/crates/rkgk/src/wall.rs +++ b/crates/rkgk/src/wall.rs @@ -23,7 +23,6 @@ pub mod broker; pub mod chunk_images; pub mod chunk_iterator; pub mod database; -pub mod render; pub use broker::Broker; pub use database::Database; @@ -135,6 +134,7 @@ pub struct Settings { pub max_sessions: usize, pub paint_area: u32, pub chunk_size: u32, + pub max_edit_size: usize, } impl Settings { @@ -208,6 +208,7 @@ pub enum EventKind { Cursor { position: Vec2 }, Interact { interactions: Vec }, + Edit { edits: Vec }, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -222,6 +223,15 @@ pub enum Interaction { Scribble, } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub struct Edit { + pub chunk: ChunkPosition, + pub data_type: String, // media type of `data` + pub data_offset: usize, + pub data_length: usize, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Online { @@ -254,11 +264,16 @@ impl Wall { self.chunks.get(&at).map(|chunk| Arc::clone(&chunk)) } - pub fn get_or_create_chunk(&self, at: ChunkPosition) -> Arc> { - Arc::clone(&self.chunks.entry(at).or_insert_with(|| { - info!(?at, "chunk created"); - Arc::new(Mutex::new(Chunk::new(self.settings.chunk_size))) - })) + pub fn get_or_create_chunk(&self, at: ChunkPosition) -> (Arc>, bool) { + let entry = self.chunks.entry(at); + let created = matches!(&entry, dashmap::Entry::Vacant(_)); + ( + Arc::clone(&entry.or_insert_with(|| { + info!(?at, "chunk created"); + Arc::new(Mutex::new(Chunk::new(self.settings.chunk_size))) + })), + created, + ) } pub fn join(self: &Arc, session: Session) -> Result { @@ -291,9 +306,9 @@ impl Wall { pub fn event(&self, event: Event) { if let Some(mut session) = self.sessions.get_mut(&event.session_id) { match &event.kind { - // 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 => (), + // Events that get broadcasted through the wall such that all clients get them. + // No need to react in any way. + EventKind::Join { .. } | EventKind::Leave | EventKind::Interact { .. } => (), EventKind::Cursor { position } => { session.cursor = Some(*position); @@ -301,7 +316,7 @@ impl Wall { // Drawing events are handled by the owner session's thread to make drawing as // parallel as possible. - EventKind::Interact { .. } => {} + EventKind::Edit { .. } => {} } } diff --git a/crates/rkgk/src/wall/auto_save.rs b/crates/rkgk/src/wall/auto_save.rs index 5029305..0dc2c4b 100644 --- a/crates/rkgk/src/wall/auto_save.rs +++ b/crates/rkgk/src/wall/auto_save.rs @@ -1,8 +1,4 @@ -use std::{ - collections::{HashMap, HashSet}, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{collections::HashSet, sync::Arc, time::Duration}; use serde::{Deserialize, Serialize}; use tokio::{ diff --git a/crates/rkgk/src/wall/chunk_images.rs b/crates/rkgk/src/wall/chunk_images.rs index da8dddc..0e73d9b 100644 --- a/crates/rkgk/src/wall/chunk_images.rs +++ b/crates/rkgk/src/wall/chunk_images.rs @@ -1,13 +1,13 @@ -use std::{sync::Arc, time::Instant}; +use std::{collections::HashMap, sync::Arc, time::Instant}; use dashmap::DashSet; use eyre::Context; use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}; use tiny_skia::{IntSize, Pixmap}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, Mutex}; use tracing::{error, info, instrument}; -use super::{database::ChunkDataPair, ChunkPosition, Database, Wall}; +use super::{database::ChunkDataPair, Chunk, ChunkPosition, Database, Wall}; /// Chunk image encoding, caching, and storage service. pub struct ChunkImages { @@ -28,6 +28,13 @@ pub struct NewlyEncoded { pub last_mod: Instant, } +/// Chunks loaded from a load operation. +/// Returned from loads to keep reference counts above 1, such that the chunks are not garbage +/// collected while they're loaded and being operated on. +pub struct LoadedChunks { + pub chunks: HashMap>>, +} + enum Command { Encode { chunks: Vec, @@ -36,7 +43,7 @@ enum Command { Load { chunks: Vec, - reply: oneshot::Sender>, + reply: oneshot::Sender>, }, } @@ -68,7 +75,7 @@ impl ChunkImages { rx.await.ok().unwrap_or_default() } - pub async fn load(&self, chunks: Vec) -> eyre::Result<()> { + pub async fn load(&self, chunks: Vec) -> eyre::Result { let (tx, rx) = oneshot::channel(); self.commands_tx .send(Command::Load { chunks, reply: tx }) @@ -156,20 +163,29 @@ impl ChunkImageLoop { _ = reply.send(EncodeResult { data: all, new }); } - async fn load_inner(self: Arc, mut chunks: Vec) -> eyre::Result<()> { - // Skip already loaded chunks. - chunks.retain(|&position| !self.wall.has_chunk(position)); - if chunks.is_empty() { - return Ok(()); + async fn load_inner(self: Arc, chunks: Vec) -> eyre::Result { + // Reference all the chunks that we need, such that they're not garbage collected + // during loading. + let mut chunk_refs = HashMap::new(); + let mut positions_to_load = vec![]; + for &position in &chunks { + let (chunk, created) = self.wall.get_or_create_chunk(position); + chunk_refs.insert(position, chunk); + + if created { + positions_to_load.push(position); + } } - info!(?chunks, "to load"); + if positions_to_load.is_empty() { + return Ok(LoadedChunks { chunks: chunk_refs }); + } - let chunks = self.db.read_chunks(chunks.clone()).await?; + info!(?positions_to_load, "to load"); - let chunks2 = chunks.clone(); + let to_load = self.db.read_chunks(positions_to_load.clone()).await?; let decoded = tokio::task::spawn_blocking(move || { - chunks2 + to_load .par_iter() .flat_map(|ChunkDataPair { position, data }| { webp::Decoder::new(data) @@ -193,30 +209,25 @@ impl ChunkImageLoop { .await .context("failed to decode chunks from the database")?; - // I don't know yet if locking all the chunks is a good idea at this point. - // I can imagine contended chunks having some trouble loading. - let chunk_arcs: Vec<_> = decoded - .iter() - .map(|(position, _)| self.wall.get_or_create_chunk(*position)) - .collect(); - let mut chunk_refs = Vec::with_capacity(chunk_arcs.len()); - for arc in &chunk_arcs { - chunk_refs.push(arc.lock().await); + let mut chunk_locks = Vec::with_capacity(decoded.len()); + for (position, _) in &decoded { + let chunk_ref = &chunk_refs[position]; + chunk_locks.push(chunk_ref.lock().await); } - info!(num = ?chunk_refs.len(), "replacing chunks' pixmaps"); - for ((_, pixmap), mut chunk) in decoded.into_iter().zip(chunk_refs) { + info!(num = ?chunk_locks.len(), "replacing chunks' pixmaps"); + for ((_, pixmap), mut chunk) in decoded.into_iter().zip(chunk_locks) { chunk.pixmap = pixmap; } - Ok(()) + Ok(LoadedChunks { chunks: chunk_refs }) } #[instrument(skip(self, reply))] async fn load( self: Arc, chunks: Vec, - reply: oneshot::Sender>, + reply: oneshot::Sender>, ) { _ = reply.send(self.load_inner(chunks).await); } diff --git a/crates/rkgk/src/wall/database.rs b/crates/rkgk/src/wall/database.rs index 4810d6a..3e146c0 100644 --- a/crates/rkgk/src/wall/database.rs +++ b/crates/rkgk/src/wall/database.rs @@ -1,4 +1,4 @@ -use std::{convert::identity, path::PathBuf, sync::Arc, time::Instant}; +use std::{convert::identity, path::PathBuf, sync::Arc}; use eyre::Context; use rusqlite::Connection; @@ -88,6 +88,8 @@ pub fn start(settings: Settings) -> eyre::Result { info!("initial setup"); + let version: u32 = db.pragma_query_value(None, "user_version", |x| x.get(0))?; + db.execute_batch( r#" PRAGMA application_id = 0x726B6757; -- rkgW @@ -172,10 +174,31 @@ pub fn start(settings: Settings) -> eyre::Result { ), )?; + // Migrations + + if version < 1 { + info!("migrate v1: add max_edit_size"); + db.execute_batch( + r#" + PRAGMA user_version = 1; + ALTER TABLE t_wall_settings ADD COLUMN max_edit_size INTEGER; + "#, + )?; + db.execute( + r#" + UPDATE OR IGNORE t_wall_settings + SET max_edit_size = ?; + "#, + (settings.default_wall_settings.max_edit_size,), + )?; + } + + // Set up access thread + let wall_settings = db.query_row( r#" SELECT - max_chunks, max_sessions, paint_area, chunk_size + max_chunks, max_sessions, paint_area, chunk_size, max_edit_size FROM t_wall_settings; "#, (), @@ -185,6 +208,7 @@ pub fn start(settings: Settings) -> eyre::Result { max_sessions: row.get(1)?, paint_area: row.get(2)?, chunk_size: row.get(3)?, + max_edit_size: row.get(4)?, }) }, )?; diff --git a/crates/rkgk/src/wall/render.rs b/crates/rkgk/src/wall/render.rs deleted file mode 100644 index 0299fb7..0000000 --- a/crates/rkgk/src/wall/render.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! Implementation of a haku2 canvas based on tiny-skia. - -use std::mem; - -use tiny_skia::{ - BlendMode, Color, FillRule, LineCap, Paint, PathBuilder, Shader, Stroke, Transform, -}; - -use super::Chunk; - -pub struct ChunkCanvas<'c> { - chunk: &'c mut Chunk, - transform: Transform, - pb: PathBuilder, -} - -impl<'c> ChunkCanvas<'c> { - pub fn new(chunk: &'c mut Chunk) -> Self { - Self { - chunk, - transform: Transform::identity(), - pb: PathBuilder::new(), - } - } - - pub fn translated(mut self, x: f32, y: f32) -> Self { - self.transform = self.transform.post_translate(x, y); - self - } -} - -impl haku2::Canvas for ChunkCanvas<'_> { - fn begin(&mut self) -> Result<(), haku2::RenderError> { - self.pb.clear(); - Ok(()) - } - - fn line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) -> Result<(), haku2::RenderError> { - self.pb.move_to(x1, y1); - self.pb.line_to(x2, y2); - Ok(()) - } - - fn rectangle( - &mut self, - x: f32, - y: f32, - width: f32, - height: f32, - ) -> Result<(), haku2::RenderError> { - if let Some(rect) = tiny_skia::Rect::from_xywh(x, y, width, height) { - self.pb.push_rect(rect); - } - Ok(()) - } - - fn circle(&mut self, x: f32, y: f32, r: f32) -> Result<(), haku2::RenderError> { - self.pb.push_circle(x, y, r); - Ok(()) - } - - fn fill(&mut self, r: u8, g: u8, b: u8, a: u8) -> Result<(), haku2::RenderError> { - let pb = mem::take(&mut self.pb); - if let Some(path) = pb.finish() { - let paint = Paint { - shader: Shader::SolidColor(Color::from_rgba8(r, g, b, a)), - ..default_paint() - }; - self.chunk - .pixmap - .fill_path(&path, &paint, FillRule::EvenOdd, self.transform, None); - } - Ok(()) - } - - fn stroke( - &mut self, - r: u8, - g: u8, - b: u8, - a: u8, - thickness: f32, - ) -> Result<(), haku2::RenderError> { - let pb = mem::take(&mut self.pb); - if let Some(path) = pb.finish() { - let paint = Paint { - shader: Shader::SolidColor(Color::from_rgba8(r, g, b, a)), - ..default_paint() - }; - self.chunk.pixmap.stroke_path( - &path, - &paint, - &Stroke { - width: thickness, - line_cap: LineCap::Round, - ..Default::default() - }, - self.transform, - None, - ); - } - Ok(()) - } -} - -fn default_paint() -> Paint<'static> { - Paint { - shader: Shader::SolidColor(Color::BLACK), - blend_mode: BlendMode::SourceOver, - anti_alias: false, - force_hq_pipeline: false, - } -} diff --git a/rkgk.toml b/rkgk.toml index 906f663..5cd488c 100644 --- a/rkgk.toml +++ b/rkgk.toml @@ -48,6 +48,11 @@ chunk_size = 168 # can produce. paint_area = 504 +# Maximum size of a single edit, in chunks. +# You don't want to make this _too_ large, otherwise the client will try to allocate too many +# chunks at once. Way more than WebAssembly's 4GiB address space can handle. +max_edit_size = 256 + [wall_broker.auto_save] # How often should modified chunks be saved to the database. diff --git a/static/brush-box.js b/static/brush-box.js index 3470c94..e995e0a 100644 --- a/static/brush-box.js +++ b/static/brush-box.js @@ -318,7 +318,7 @@ export class BrushBox extends HTMLElement { if (id == null) { // Save a new preset. id = `user/${randomId()}`; - console.log("saving new brush", id); + console.info("saving new brush", id); this.userPresets.push({ id, name, @@ -327,7 +327,7 @@ export class BrushBox extends HTMLElement { } else { // Overwrite an existing one. let preset = this.userPresets.find((p) => p.id == id); - console.log("overwriting existing brush", preset); + console.info("overwriting existing brush", preset); preset.code = code; } this.saveUserPresets(); diff --git a/static/canvas-renderer.js b/static/canvas-renderer.js index 6b5821b..8c934df 100644 --- a/static/canvas-renderer.js +++ b/static/canvas-renderer.js @@ -1,6 +1,6 @@ -import { listen } from "rkgk/framework.js"; +import { listen, Pool } from "rkgk/framework.js"; import { Viewport } from "rkgk/viewport.js"; -import { Wall } from "rkgk/wall.js"; +import { Wall, chunkKey } from "rkgk/wall.js"; class CanvasRenderer extends HTMLElement { viewport = new Viewport(); @@ -196,7 +196,8 @@ class CanvasRenderer extends HTMLElement { console.debug("initialized atlas allocator", this.atlasAllocator); - this.chunksThisFrame = new Map(); + this.batches = []; + this.batchPool = new Pool(); console.debug("GL error state", this.gl.getError()); @@ -294,31 +295,55 @@ class CanvasRenderer extends HTMLElement { this.#collectChunksThisFrame(); - for (let [i, chunks] of this.chunksThisFrame) { - let atlas = this.atlasAllocator.atlases[i]; - this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.id); - - this.#resetRectBuffer(); - for (let chunk of chunks) { - let { i, allocation } = this.getChunkAllocation(chunk.x, chunk.y); + for (let batch of this.batches) { + for (let [i, chunks] of batch) { let atlas = this.atlasAllocator.atlases[i]; - this.#pushRect( - chunk.x * this.wall.chunkSize, - chunk.y * this.wall.chunkSize, - this.wall.chunkSize, - this.wall.chunkSize, - (allocation.x * atlas.chunkSize) / atlas.textureSize, - (allocation.y * atlas.chunkSize) / atlas.textureSize, - atlas.chunkSize / atlas.textureSize, - atlas.chunkSize / atlas.textureSize, - ); + this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.id); + + this.#resetRectBuffer(); + for (let chunk of chunks) { + let { i, allocation } = this.getChunkAllocation( + chunk.layerId, + chunk.x, + chunk.y, + ); + let atlas = this.atlasAllocator.atlases[i]; + this.#pushRect( + chunk.x * this.wall.chunkSize, + chunk.y * this.wall.chunkSize, + this.wall.chunkSize, + this.wall.chunkSize, + (allocation.x * atlas.chunkSize) / atlas.textureSize, + (allocation.y * atlas.chunkSize) / atlas.textureSize, + atlas.chunkSize / atlas.textureSize, + atlas.chunkSize / atlas.textureSize, + ); + } + this.#drawRects(); } - this.#drawRects(); } + + // TODO: This is a nice debug view. + // There should be a switch to it somewhere in the app. + /* + let x = 0; + let y = 0; + for (let atlas of this.atlasAllocator.atlases) { + this.#resetRectBuffer(); + this.gl.bindTexture(this.gl.TEXTURE_2D, atlas.id); + this.#pushRect(x, y, atlas.textureSize, atlas.textureSize, 0, 0, 1, 1); + this.#drawRects(); + if (x > atlas.textureSize * 16) { + y += atlas.textureSize; + x = 0; + } + x += atlas.textureSize; + } + */ } - getChunkAllocation(chunkX, chunkY) { - let key = Wall.chunkKey(chunkX, chunkY); + getChunkAllocation(layerId, chunkX, chunkY) { + let key = `${layerId}/${chunkKey(chunkX, chunkY)}`; if (this.chunkAllocations.has(key)) { return this.chunkAllocations.get(key); } else { @@ -328,36 +353,54 @@ class CanvasRenderer extends HTMLElement { } } + deallocateChunks(layer) { + for (let chunkKey of layer.chunks.keys()) { + let key = `${layer.id}/${chunkKey}`; + if (this.chunkAllocations.has(key)) { + let allocation = this.chunkAllocations.get(key); + this.atlasAllocator.dealloc(allocation); + this.chunkAllocations.delete(key); + } + } + } + #collectChunksThisFrame() { - // NOTE: Not optimal that we don't preserve the arrays anyhow; it would be better if we - // preserved the allocations. - this.chunksThisFrame.clear(); + for (let batch of this.batches) { + batch.clear(); + this.batchPool.free(batch); + } + this.batches.splice(0, this.batches.length); 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); - for (let chunkY = top; chunkY < bottom; ++chunkY) { - for (let chunkX = left; chunkX < right; ++chunkX) { - let chunk = this.wall.getChunk(chunkX, chunkY); - if (chunk != null) { - if (chunk.renderDirty) { - this.#updateChunkTexture(chunkX, chunkY); - chunk.renderDirty = false; + + for (let layer of this.wall.layers) { + let batch = this.batchPool.alloc(Map); + for (let chunkY = top; chunkY < bottom; ++chunkY) { + for (let chunkX = left; chunkX < right; ++chunkX) { + let chunk = layer.getChunk(chunkX, chunkY); + if (chunk != null) { + if (chunk.renderDirty) { + this.#updateChunkTexture(layer, chunkX, chunkY); + chunk.renderDirty = false; + } + + let allocation = this.getChunkAllocation(layer.id, chunkX, chunkY); + + let array = batch.get(allocation.i); + if (array == null) { + array = []; + batch.set(allocation.i, array); + } + + array.push({ layerId: layer.id, x: chunkX, y: chunkY }); } - - let allocation = this.getChunkAllocation(chunkX, chunkY); - - let array = this.chunksThisFrame.get(allocation.i); - if (array == null) { - array = []; - this.chunksThisFrame.set(allocation.i, array); - } - - array.push({ x: chunkX, y: chunkY }); } } + this.batches.push(batch); } } @@ -395,9 +438,9 @@ class CanvasRenderer extends HTMLElement { this.gl.drawArraysInstanced(this.gl.TRIANGLES, 0, 6, this.uboRectsNum); } - #updateChunkTexture(chunkX, chunkY) { - let allocation = this.getChunkAllocation(chunkX, chunkY); - let chunk = this.wall.getChunk(chunkX, chunkY); + #updateChunkTexture(layer, chunkX, chunkY) { + let allocation = this.getChunkAllocation(layer.id, chunkX, chunkY); + let chunk = layer.getChunk(chunkX, chunkY); this.atlasAllocator.upload(this.gl, allocation, chunk.pixmap); } @@ -474,6 +517,10 @@ class CanvasRenderer extends HTMLElement { } } } + + commitInteraction() { + this.dispatchEvent(new Event(".commitInteraction")); + } } customElements.define("rkgk-canvas-renderer", CanvasRenderer); @@ -513,6 +560,7 @@ class InteractEvent extends Event { if (event.type == "mouseup" && event.button == 0) { // Break the loop. + this.canvasRenderer.commitInteraction(); return; } })(); @@ -576,6 +624,10 @@ class Atlas { return this.free.pop(); } + dealloc(xy) { + this.free.push(xy); + } + upload(gl, { x, y }, pixmap) { gl.bindTexture(gl.TEXTURE_2D, this.id); gl.texSubImage2D( @@ -621,6 +673,11 @@ class AtlasAllocator { return { i, allocation }; } + dealloc({ i, allocation }) { + let atlas = this.atlases[i]; + atlas.dealloc(allocation); + } + upload(gl, { i, allocation }, pixmap) { this.atlases[i].upload(gl, allocation, pixmap); } diff --git a/static/framework.js b/static/framework.js index ed382e1..5c76c91 100644 --- a/static/framework.js +++ b/static/framework.js @@ -54,6 +54,24 @@ export function debounce(time, fn) { }; } +export class Pool { + constructor() { + this.pool = []; + } + + alloc(ctor) { + if (this.pool.length > 0) { + return this.pool.pop(); + } else { + return new ctor(); + } + } + + free(obj) { + this.pool.push(obj); + } +} + export class SaveData { constructor(prefix) { this.prefix = `rkgk.${prefix}`; diff --git a/static/index.js b/static/index.js index 3bcb378..d04c4dc 100644 --- a/static/index.js +++ b/static/index.js @@ -192,7 +192,7 @@ function readUrl(urlString) { } }); - let sendViewportUpdate = debounce(updateInterval, () => { + let sendViewportUpdate = debounce(updateInterval / 4, () => { let visibleRect = canvasRenderer.getVisibleChunkRect(); session.sendViewport(visibleRect); }); @@ -213,7 +213,12 @@ function readUrl(urlString) { let blob = chunkData.slice(info.offset, info.offset + info.length, "image/webp"); updatePromises.push( createImageBitmap(blob).then((bitmap) => { - let chunk = wall.getOrCreateChunk(info.position.x, info.position.y); + let chunk = wall.mainLayer.getOrCreateChunk( + info.position.x, + info.position.y, + ); + if (chunk == null) return; + chunk.ctx.globalCompositeOperation = "copy"; chunk.ctx.drawImage(bitmap, 0, 0); chunk.syncToPixmap(); @@ -230,7 +235,7 @@ function readUrl(urlString) { } }); - let reportCursor = debounce(updateInterval, (x, y) => session.sendCursor(x, y), console.log); + let reportCursor = debounce(updateInterval, (x, y) => session.sendCursor(x, y)); canvasRenderer.addEventListener(".cursor", async (event) => { reportCursor(event.x, event.y); }); @@ -248,12 +253,38 @@ function readUrl(urlString) { canvasRenderer.addEventListener(".interact", async (event) => { if (!currentUser.haku.ok) return; + let layer = currentUser.getScratchLayer(wall); let result = await currentUser.haku.evalBrush( - selfController(interactionQueue, wall, event), + selfController(interactionQueue, wall, layer, event), ); brushEditor.renderHakuResult(result); }); + canvasRenderer.addEventListener(".commitInteraction", async () => { + let scratchLayer = currentUser.commitScratchLayer(wall); + if (scratchLayer == null) return; + + canvasRenderer.deallocateChunks(scratchLayer); + let edits = await scratchLayer.toEdits(); + scratchLayer.destroy(); + + let editRecords = []; + let dataParts = []; + let cursor = 0; + for (let edit of edits) { + editRecords.push({ + chunk: edit.chunk, + dataType: edit.data.type, + dataOffset: cursor, + dataLength: edit.data.size, + }); + dataParts.push(edit.data); + cursor += edit.data.size; + } + + session.sendEdit(editRecords, new Blob(dataParts)); + }); + canvasRenderer.addEventListener(".viewportUpdate", () => reticleRenderer.render()); canvasRenderer.addEventListener(".viewportUpdateEnd", () => updateUrl(session, canvasRenderer.viewport), diff --git a/static/online-users.js b/static/online-users.js index fbee3ee..4467107 100644 --- a/static/online-users.js +++ b/static/online-users.js @@ -1,5 +1,6 @@ import { ContKind, Haku } from "rkgk/haku.js"; import { renderToChunksInArea, dotterRenderArea } from "rkgk/painter.js"; +import { Layer } from "rkgk/wall.js"; export class User { nickname = ""; @@ -9,19 +10,22 @@ export class User { isBrushOk = false; simulation = null; + scratchLayer = null; + constructor(wallInfo, nickname) { this.nickname = nickname; this.haku = new Haku(wallInfo.hakuLimits); } destroy() { + console.info("destroying user", this.nickname); this.haku.destroy(); } setBrush(brush) { console.groupCollapsed("setBrush", this.nickname); let compileResult = this.haku.setBrush(brush); - console.log("compiling brush complete", compileResult); + console.info("compiling brush complete", compileResult); console.groupEnd(); this.isBrushOk = compileResult.status == "ok"; @@ -32,14 +36,14 @@ export class User { renderBrushToChunks(wall, x, y) { console.groupCollapsed("renderBrushToChunks", this.nickname); let result = this.painter.renderBrushToWall(this.haku, x, y, wall); - console.log("rendering brush to chunks complete"); + console.info("rendering brush to chunks complete"); console.groupEnd(); return result; } simulate(wall, interactions) { - console.group("simulate"); + console.group("simulate", this.nickname); for (let interaction of interactions) { if (interaction.kind == "setBrush") { this.simulation = null; @@ -48,7 +52,7 @@ export class User { if (this.isBrushOk) { if (this.simulation == null) { - console.log("no simulation -- beginning brush"); + console.info("no simulation -- beginning brush"); this.simulation = { renderArea: { left: 0, top: 0, right: 0, bottom: 0 } }; this.haku.beginBrush(); } @@ -67,13 +71,13 @@ export class User { if (interaction.kind == "scribble" && this.#expectContKind(ContKind.Scribble)) { renderToChunksInArea( - wall, + this.getScratchLayer(wall), this.simulation.renderArea, (pixmap, translationX, translationY) => { return this.haku.contScribble(pixmap, translationX, translationY); }, ); - console.log("ended simulation"); + console.info("ended simulation"); this.simulation = null; } } @@ -100,6 +104,34 @@ export class User { memoryMax: wallInfo.hakuLimits.memory, }; } + + getScratchLayer(wall) { + if (this.scratchLayer == null) { + this.scratchLayer = wall.addLayer( + new Layer({ + name: `scratch ${this.nickname}`, + chunkSize: wall.chunkSize, + chunkLimit: wall.maxEditSize, + }), + ); + } + return this.scratchLayer; + } + + // Returns the scratch layer committed to the wall, so that the caller may do additional + // processing with the completed layer (i.e. send to the server.) + // The layer has to be .destroy()ed once you're done working with it. + commitScratchLayer(wall) { + if (this.scratchLayer != null) { + wall.mainLayer.compositeAlpha(this.scratchLayer); + wall.removeLayer(this.scratchLayer); + let scratchLayer = this.scratchLayer; + this.scratchLayer = null; + return scratchLayer; + } else { + console.error("commitScratchLayer without an active scratch layer", this.nickname); + } + } } export class OnlineUsers extends EventTarget { diff --git a/static/painter.js b/static/painter.js index 5456586..2e9b067 100644 --- a/static/painter.js +++ b/static/painter.js @@ -1,10 +1,20 @@ import { listen } from "rkgk/framework.js"; -function* chunksInRectangle(left, top, right, bottom, chunkSize) { - let leftChunk = Math.floor(left / chunkSize); - let topChunk = Math.floor(top / chunkSize); - let rightChunk = Math.ceil(right / chunkSize); - let bottomChunk = Math.ceil(bottom / chunkSize); +function numChunksInRectangle(rect, chunkSize) { + let leftChunk = Math.floor(rect.left / chunkSize); + let topChunk = Math.floor(rect.top / chunkSize); + let rightChunk = Math.ceil(rect.right / chunkSize); + let bottomChunk = Math.ceil(rect.bottom / chunkSize); + let numX = rightChunk - leftChunk; + let numY = bottomChunk - topChunk; + return numX * numY; +} + +function* chunksInRectangle(rect, chunkSize) { + let leftChunk = Math.floor(rect.left / chunkSize); + let topChunk = Math.floor(rect.top / chunkSize); + let rightChunk = Math.ceil(rect.right / chunkSize); + let bottomChunk = Math.ceil(rect.bottom / chunkSize); for (let chunkY = topChunk; chunkY < bottomChunk; ++chunkY) { for (let chunkX = leftChunk; chunkX < rightChunk; ++chunkX) { yield [chunkX, chunkY]; @@ -12,17 +22,13 @@ function* chunksInRectangle(left, top, right, bottom, chunkSize) { } } -export function renderToChunksInArea(wall, renderArea, renderToPixmap) { - for (let [chunkX, chunkY] of chunksInRectangle( - renderArea.left, - renderArea.top, - renderArea.right, - renderArea.bottom, - wall.chunkSize, - )) { - let chunk = wall.getOrCreateChunk(chunkX, chunkY); - let translationX = -chunkX * wall.chunkSize; - let translationY = -chunkY * wall.chunkSize; +export function renderToChunksInArea(layer, renderArea, renderToPixmap) { + for (let [chunkX, chunkY] of chunksInRectangle(renderArea, layer.chunkSize)) { + let chunk = layer.getOrCreateChunk(chunkX, chunkY); + if (chunk == null) continue; + + let translationX = -chunkX * layer.chunkSize; + let translationY = -chunkY * layer.chunkSize; let result = renderToPixmap(chunk.pixmap, translationX, translationY); chunk.markModified(); if (result.status != "ok") return result; @@ -41,13 +47,19 @@ export function dotterRenderArea(wall, dotter) { }; } -export function selfController(interactionQueue, wall, event) { +export function selfController(interactionQueue, wall, layer, event) { let renderArea = null; return { async runScribble(renderToPixmap) { interactionQueue.push({ kind: "scribble" }); if (renderArea != null) { - return renderToChunksInArea(wall, renderArea, renderToPixmap); + let numChunksToRender = numChunksInRectangle(renderArea, layer.chunkSize); + let result = renderToChunksInArea(layer, renderArea, renderToPixmap); + if (!layer.canFitNewChunks(numChunksToRender)) { + console.debug("too many chunks rendered; committing interaction early"); + event.earlyCommitInteraction(); + } + return result; } else { console.debug("render area is empty, nothing will be rendered"); } diff --git a/static/session.js b/static/session.js index 96fc9ba..d24c5a7 100644 --- a/static/session.js +++ b/static/session.js @@ -266,6 +266,17 @@ class Session extends EventTarget { }); } + sendEdit(edits, data) { + this.#sendJson({ + request: "wall", + wallEvent: { + event: "edit", + edits, + }, + }); + this.ws.send(data); + } + sendInteraction(interactions) { this.#sendJson({ request: "wall", diff --git a/static/wall.js b/static/wall.js index fd34636..4c8f846 100644 --- a/static/wall.js +++ b/static/wall.js @@ -9,6 +9,10 @@ export class Chunk { this.renderDirty = false; } + destroy() { + this.pixmap.destroy(); + } + syncFromPixmap() { this.ctx.putImageData(this.pixmap.getImageData(), 0, 0); } @@ -23,31 +27,117 @@ export class Chunk { } } +let layerIdCounter = 0; + +export class Layer { + chunks = new Map(); + id = layerIdCounter++; + + constructor({ name, chunkSize, chunkLimit }) { + this.name = name; + this.chunkSize = chunkSize; + this.chunkLimit = chunkLimit; + + console.info("created layer", this.id, this.name); + } + + destroy() { + for (let { chunk } of this.chunks.values()) { + chunk.destroy(); + } + } + + getChunk(x, y) { + return this.chunks.get(chunkKey(x, y))?.chunk; + } + + getOrCreateChunk(x, y) { + let key = chunkKey(x, y); + if (this.chunks.has(key)) { + return this.chunks.get(key)?.chunk; + } else { + if (this.chunkLimit != null && this.chunks.size >= this.chunkLimit) return null; + + let chunk = new Chunk(this.chunkSize); + this.chunks.set(key, { x, y, chunk }); + return chunk; + } + } + + compositeAlpha(src) { + for (let { x, y, chunk: srcChunk } of src.chunks.values()) { + srcChunk.syncFromPixmap(); + let dstChunk = this.getOrCreateChunk(x, y); + if (dstChunk == null) continue; + + dstChunk.ctx.globalCompositeOperation = "source-over"; + dstChunk.ctx.drawImage(srcChunk.canvas, 0, 0); + dstChunk.syncToPixmap(); + dstChunk.markModified(); + } + } + + async toEdits() { + let edits = []; + + let start = performance.now(); + + for (let { x, y, chunk } of this.chunks.values()) { + edits.push({ + chunk: { x, y }, + data: chunk.canvas.convertToBlob({ type: "image/png" }), + }); + } + + for (let edit of edits) { + edit.data = await edit.data; + } + + let end = performance.now(); + console.debug("toEdits done", end - start); + + return edits; + } +} + +export function chunkKey(x, y) { + return `${x},${y}`; +} + export class Wall { - #chunks = new Map(); + layers = []; // do not modify directly; only read + #layersById = new Map(); constructor(wallInfo) { this.chunkSize = wallInfo.chunkSize; this.paintArea = wallInfo.paintArea; + this.maxEditSize = wallInfo.maxEditSize; this.onlineUsers = new OnlineUsers(wallInfo); + + this.mainLayer = new Layer({ name: "main", chunkSize: this.chunkSize }); + this.addLayer(this.mainLayer); } - static chunkKey(x, y) { - return `(${x},${y})`; - } - - getChunk(x, y) { - return this.#chunks.get(Wall.chunkKey(x, y)); - } - - getOrCreateChunk(x, y) { - let key = Wall.chunkKey(x, y); - if (this.#chunks.has(key)) { - return this.#chunks.get(key); + addLayer(layer) { + if (!this.#layersById.get(layer.id)) { + this.layers.push(layer); + this.#layersById.set(layer.id, layer); } else { - let chunk = new Chunk(this.chunkSize); - this.#chunks.set(key, chunk); - return chunk; + console.warn("attempt to add layer more than once", layer); + } + return layer; + } + + removeLayer(layer) { + if (this.#layersById.delete(layer.id)) { + let index = this.layers.findIndex((x) => x == layer); + this.layers.splice(index, 1); + } else { + console.warn("attempt to remove layer more than once", layer); } } + + getLayerById(id) { + return this.#layersById.get(id); + } }