This commit is contained in:
liquidex 2024-08-15 20:01:23 +02:00
parent 26ba098183
commit 2f7bcbb14e
30 changed files with 1691 additions and 315 deletions

300
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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 [<haku_limits_set_ $name>](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<Exception>,
}
#[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: 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
}

View file

@ -15,18 +15,23 @@ pub struct RendererLimits {
pub transform_stack_capacity: usize,
}
pub struct Renderer {
pixmap_stack: Vec<Pixmap>,
pub enum RenderTarget<'a> {
Borrowed(&'a mut Pixmap),
Owned(Pixmap),
}
pub struct Renderer<'a> {
pixmap_stack: Vec<RenderTarget<'a>>,
transform_stack: Vec<Transform>,
}
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> {

View file

@ -0,0 +1,6 @@
[package]
name = "rkgk-image-ops"
version = "0.1.0"
edition = "2021"
[dependencies]

View file

@ -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);
}
}

View file

@ -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"]

View file

@ -9,15 +9,20 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use crate::Databases;
use crate::{config::Config, Databases};
mod wall;
pub fn router<S>(dbs: Arc<Databases>) -> Router<S> {
pub struct Api {
pub config: Config,
pub dbs: Arc<Databases>,
}
pub fn router<S>(api: Arc<Api>) -> Router<S> {
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<Arc<Databases>>, params: Json<NewUserParams>) -> impl IntoResponse {
async fn login_new(api: State<Arc<Api>>, params: Json<NewUserParams>) -> impl IntoResponse {
if !(1..=32).contains(&params.nickname.len()) {
return (
StatusCode::BAD_REQUEST,
@ -45,7 +50,7 @@ async fn login_new(dbs: State<Arc<Databases>>, params: Json<NewUserParams>) -> 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 {

View file

@ -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<Arc<Databases>>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|ws| websocket(dbs, ws))
pub async fn wall(State(api): State<Arc<Api>>, ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|ws| websocket(api, ws))
}
fn to_message<T>(value: &T) -> Message
@ -51,8 +60,8 @@ async fn recv_expect(ws: &mut WebSocket) -> eyre::Result<Message> {
.ok_or_eyre("connection closed unexpectedly")??)
}
async fn websocket(dbs: Arc<Databases>, mut ws: WebSocket) {
match fallible_websocket(dbs, &mut ws).await {
async fn websocket(api: Arc<Api>, mut ws: WebSocket) {
match fallible_websocket(api, &mut ws).await {
Ok(()) => (),
Err(e) => {
_ = ws
@ -64,7 +73,7 @@ async fn websocket(dbs: Arc<Databases>, mut ws: WebSocket) {
}
}
async fn fallible_websocket(dbs: Arc<Databases>, ws: &mut WebSocket) -> eyre::Result<()> {
async fn fallible_websocket(api: Arc<Api>, 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<Databases>, 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<Databases>, 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<Databases>, 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<Databases>, 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<Databases>, 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<Wall>,
chunk_encoder: Arc<ChunkEncoder>,
handle: SessionHandle,
render_commands_tx: mpsc::Sender<RenderCommand>,
viewport_chunks: ChunkIterator,
}
enum RenderCommand {
SetBrush { brush: String },
Plot { points: Vec<Vec2> },
}
impl SessionLoop {
async fn start(
wall: Arc<Wall>,
chunk_encoder: Arc<ChunkEncoder>,
handle: SessionHandle,
limits: Limits,
brush: String,
) -> eyre::Result<Self> {
// 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<Wall>,
chunk_encoder: Arc<ChunkEncoder>,
limits: Limits,
mut commands: mpsc::Receiver<RenderCommand>,
) {
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))
}
}

View file

@ -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<WallId>,
pub init: UserInit,
}
#[derive(Debug, Clone, Serialize)]
@ -44,12 +33,15 @@ pub struct Online {
pub session_id: SessionId,
pub nickname: String,
pub cursor: Option<Vec2>,
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<Online>,
}
@ -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<ChunkInfo> },
}

View file

@ -5,4 +5,5 @@ use crate::wall;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub wall: wall::Settings,
pub haku: crate::haku::Limits,
}

163
crates/rkgk/src/haku.rs Normal file
View file

@ -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<ChunkId>,
}
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<Value> {
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);
}
}

View file

@ -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() {

View file

@ -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<std::alloc::System> =
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)

View file

@ -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<Mutex<Chunk>>>,
chunks: DashMap<ChunkPosition, Arc<Mutex<Chunk>>>,
sessions: DashMap<SessionId, Session>,
session_id_counter: AtomicU32,
@ -109,9 +134,17 @@ pub struct Wall {
event_sender: broadcast::Sender<Event>,
}
#[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<Vec2>,
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<Vec2>,
pub brush: String,
}
impl Wall {
@ -163,11 +200,11 @@ impl Wall {
&self.settings
}
pub fn get_chunk(&self, at: (i32, i32)) -> Option<Arc<Mutex<Chunk>>> {
pub fn get_chunk(&self, at: ChunkPosition) -> Option<Arc<Mutex<Chunk>>> {
self.chunks.get(&at).map(|chunk| Arc::clone(&chunk))
}
pub fn get_or_create_chunk(&self, at: (i32, i32)) -> Arc<Mutex<Chunk>> {
pub fn get_or_create_chunk(&self, at: ChunkPosition) -> Arc<Mutex<Chunk>> {
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,
}

View file

@ -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<ChaCha20Rng>,
}
struct OpenWall {
wall: Arc<Wall>,
#[derive(Clone)]
pub struct OpenWall {
pub wall: Arc<Wall>,
pub chunk_encoder: Arc<ChunkEncoder>,
}
impl Broker {
@ -39,15 +41,14 @@ impl Broker {
WallId::new(&mut *rng)
}
pub fn open(&self, wall_id: WallId) -> Arc<Wall> {
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()
}
}

View file

@ -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<Command>,
}
enum Command {
GetEncoded {
chunk: ChunkPosition,
reply: oneshot::Sender<Option<Arc<[u8]>>>,
},
Invalidate {
chunk: ChunkPosition,
},
}
impl ChunkEncoder {
pub fn start(wall: Arc<Wall>) -> 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<Arc<[u8]>> {
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<Arc<[u8]>> {
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<Wall>, mut commands_rx: mpsc::Receiver<Command>) {
let mut encoded_lru: IndexMap<ChunkPosition, Option<Arc<[u8]>>> = 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);
}
}
}
}
}

View file

@ -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<Self::Item> {
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)
}
}

52
docs/haku.dj Normal file
View file

@ -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.

View file

@ -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

View file

@ -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 }));
};

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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();

View file

@ -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);
}
}
}

View file

@ -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();
}
}
}
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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) {