diff --git a/.agents/resume b/.agents/resume index 440c0ad..3b1d1a1 100755 --- a/.agents/resume +++ b/.agents/resume @@ -1,4 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -command -v npm >/dev/null + +export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" +for command in npm cargo wasm-pack; do command -v "$command" >/dev/null; done amp orb services ensure diff --git a/.agents/setup b/.agents/setup index 9607cad..f2b5b05 100755 --- a/.agents/setup +++ b/.agents/setup @@ -1,4 +1,14 @@ #!/usr/bin/env bash set -euo pipefail + +export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" +if ! command -v rustup >/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal +fi +toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)" +rustup toolchain install "$toolchain" --profile minimal --component rust-src,rustfmt --target wasm32-unknown-unknown +if ! command -v wasm-pack >/dev/null; then + cargo install wasm-pack --version 0.15.0 --locked +fi npm ci amp orb services ensure diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..6984723 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,14 @@ +[target.wasm32-unknown-unknown] +rustflags = [ + "-Ctarget-feature=+atomics,+bulk-memory,+mutable-globals,+simd128", + "-Clink-args=--shared-memory", + "-Clink-args=--max-memory=1073741824", + "-Clink-args=--import-memory", + "-Clink-args=--export=__wasm_init_tls", + "-Clink-args=--export=__tls_size", + "-Clink-args=--export=__tls_align", + "-Clink-args=--export=__tls_base", +] + +[unstable] +build-std = ["std", "panic_abort"] diff --git a/.gitignore b/.gitignore index ea8c5ab..51bb85b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ lerna-debug.log* node_modules dist dist-ssr +target +core/pkg *.local # Editor directories and files diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..76f88df --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,186 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "yawn-core" +version = "0.1.0" +dependencies = [ + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cd5f904 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +members = ["core"] +resolver = "2" + +[profile.release] +opt-level = "s" +lto = true diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..048e9eb --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "yawn-core" +version = "0.1.0" +description = "Shared render-data arena and render-graph compiler" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +serde_json = "1" +wasm-bindgen = "0.2" diff --git a/packages/yawn-core/package.json b/core/package.json similarity index 52% rename from packages/yawn-core/package.json rename to core/package.json index 8596729..c4576f6 100644 --- a/packages/yawn-core/package.json +++ b/core/package.json @@ -1,7 +1,8 @@ { "name": "@yawn/core", "version": "0.1.0", + "description": "Rust/WASM render data and render graph core", "type": "module", "exports": "./src/index.js", - "files": ["src"] + "files": ["src", "pkg"] } diff --git a/packages/yawn-core/src/index.js b/core/src/index.js similarity index 96% rename from packages/yawn-core/src/index.js rename to core/src/index.js index 9fe81c4..b04054a 100644 --- a/packages/yawn-core/src/index.js +++ b/core/src/index.js @@ -1,4 +1,4 @@ -const TYPES = { f32: Float32Array, u32: Uint32Array, i32: Int32Array }; +const types = { f32: Float32Array, u32: Uint32Array, i32: Int32Array }; export class SharedRows { constructor(buffer, descriptor) { @@ -11,7 +11,7 @@ export class SharedRows { get stride() { return this.descriptor.stride; } get format() { return this.descriptor.format; } get view() { - return new TYPES[this.format](this.buffer, this.descriptor.offset, this.rows * this.stride / 4); + return new types[this.format](this.buffer, this.descriptor.offset, this.rows * this.stride / 4); } row(index) { diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..7bfcbe7 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,424 @@ +use std::collections::{HashMap, HashSet}; + +use serde_json::{Map, Number, Value}; +use wasm_bindgen::prelude::*; + +const ALIGNMENT: u32 = 64; + +#[wasm_bindgen] +pub struct Core { + _arena: Box<[u8]>, + base: u32, + capacity: u32, + used: u32, +} + +#[wasm_bindgen] +impl Core { + #[wasm_bindgen(constructor)] + pub fn new(arena_bytes: u32) -> Result { + if arena_bytes < ALIGNMENT || arena_bytes > u32::MAX - (ALIGNMENT - 1) { + return Err(JsError::new("INIT")); + } + let mut arena = vec![0; (arena_bytes + ALIGNMENT - 1) as usize].into_boxed_slice(); + let pointer = arena.as_mut_ptr() as usize; + let base = ((pointer + ALIGNMENT as usize - 1) & !(ALIGNMENT as usize - 1)) as u32; + Ok(Self { + _arena: arena, + base, + capacity: arena_bytes, + used: 0, + }) + } + + pub fn allocate(&mut self, rows: u32, stride: u32, format: &str) -> Result { + if rows == 0 || stride < 16 || stride % 16 != 0 || !matches!(format, "f32" | "u32" | "i32") + { + return Err(JsError::new("ALLOCATION")); + } + let offset = align(self.used, ALIGNMENT).ok_or_else(|| JsError::new("ARENA_OOM"))?; + let bytes = rows + .checked_mul(stride) + .ok_or_else(|| JsError::new("ARENA_OOM"))?; + self.used = offset + .checked_add(bytes) + .filter(|end| *end <= self.capacity) + .ok_or_else(|| JsError::new("ARENA_OOM"))?; + Ok(self.base + offset) + } + + pub fn compile_graph(&self, source: &str) -> Result { + compile_graph(source).map_err(JsError::new) + } +} + +fn align(value: u32, alignment: u32) -> Option { + value + .checked_add(alignment - 1) + .map(|value| value & !(alignment - 1)) +} + +enum Expression { + Atom(Value), + List(Vec), +} + +struct Parser<'a> { + source: &'a [u8], + at: usize, +} + +impl<'a> Parser<'a> { + fn parse(source: &'a str) -> Result { + let mut parser = Parser { + source: source.as_bytes(), + at: 0, + }; + let expression = parser.expression()?; + parser.whitespace(); + if parser.at != parser.source.len() { + return Err("GRAPH_WIRE"); + } + Ok(expression) + } + + fn expression(&mut self) -> Result { + self.whitespace(); + match self.source.get(self.at) { + Some(b'(') => self.list(), + Some(b'"') => self.string(), + Some(b')') | None => Err("GRAPH_WIRE"), + Some(_) => self.atom(), + } + } + + fn list(&mut self) -> Result { + self.at += 1; + let mut values = Vec::new(); + loop { + self.whitespace(); + match self.source.get(self.at) { + Some(b')') => { + self.at += 1; + return Ok(Expression::List(values)); + } + None => return Err("GRAPH_WIRE"), + _ => values.push(self.expression()?), + } + } + } + + fn string(&mut self) -> Result { + let start = self.at; + self.at += 1; + let mut escaped = false; + while let Some(&byte) = self.source.get(self.at) { + self.at += 1; + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + let value = serde_json::from_slice(&self.source[start..self.at]) + .map_err(|_| "GRAPH_WIRE")?; + return Ok(Expression::Atom(Value::String(value))); + } + } + Err("GRAPH_WIRE") + } + + fn atom(&mut self) -> Result { + let start = self.at; + while self + .source + .get(self.at) + .is_some_and(|byte| !byte.is_ascii_whitespace() && !matches!(byte, b'(' | b')')) + { + self.at += 1; + } + let token = std::str::from_utf8(&self.source[start..self.at]).map_err(|_| "GRAPH_WIRE")?; + let value = match token { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + "null" => Value::Null, + _ => serde_json::from_str::(token) + .ok() + .filter(Value::is_number) + .unwrap_or_else(|| Value::String(token.into())), + }; + Ok(Expression::Atom(value)) + } + + fn whitespace(&mut self) { + while self + .source + .get(self.at) + .is_some_and(u8::is_ascii_whitespace) + { + self.at += 1; + } + } +} + +fn compile_graph(source: &str) -> Result { + let root = Parser::parse(source)?; + let Expression::List(mut root) = root else { + return Err("GRAPH_WIRE"); + }; + if root.len() != 3 + || !matches!(&root[0], Expression::Atom(Value::String(tag)) if tag == "yawn-graph") + || !matches!(&root[1], Expression::Atom(Value::Number(version)) if version.as_u64() == Some(1)) + { + return Err("GRAPH_WIRE"); + } + let mut graph = decode(root.pop().unwrap())?; + let object = graph.as_object_mut().ok_or("GRAPH_SHAPE")?; + if !object.get("id").is_some_and(Value::is_string) { + return Err("GRAPH_SHAPE"); + } + let passes = sort_passes(object.get("passes").ok_or("GRAPH_PASS")?)?; + plan_resources(object, &passes)?; + object.insert("passes".into(), Value::Array(passes)); + serde_json::to_string(&graph).map_err(|_| "GRAPH_WIRE") +} + +fn decode(expression: Expression) -> Result { + let Expression::List(mut values) = expression else { + return match expression { + Expression::Atom(value) => Ok(value), + Expression::List(_) => unreachable!(), + }; + }; + if values.is_empty() { + return Err("GRAPH_WIRE"); + } + let tag = match values.remove(0) { + Expression::Atom(Value::String(tag)) => tag, + _ => return Err("GRAPH_WIRE"), + }; + if tag == "array" { + return values.into_iter().map(decode).collect(); + } + if tag != "object" { + return Err("GRAPH_WIRE"); + } + let mut object = Map::new(); + for field in values { + let Expression::List(mut field) = field else { + return Err("GRAPH_WIRE"); + }; + if field.len() != 3 + || !matches!(&field[0], Expression::Atom(Value::String(tag)) if tag == "field") + { + return Err("GRAPH_WIRE"); + } + let value = decode(field.pop().unwrap())?; + let key = match field.pop().unwrap() { + Expression::Atom(Value::String(key)) => key, + _ => return Err("GRAPH_WIRE"), + }; + if object.insert(key, value).is_some() { + return Err("GRAPH_WIRE"); + } + } + Ok(Value::Object(object)) +} + +fn sort_passes(value: &Value) -> Result, &'static str> { + let passes = value.as_array().ok_or("GRAPH_PASS")?; + let mut ids = HashMap::new(); + for (index, pass) in passes.iter().enumerate() { + let id = pass + .as_object() + .and_then(|pass| pass.get("id")) + .and_then(Value::as_str) + .ok_or("GRAPH_PASS")?; + if ids.insert(id, index).is_some() { + return Err("GRAPH_PASS"); + } + } + let mut dependencies = Vec::with_capacity(passes.len()); + for pass in passes { + let after = pass.get("after").map_or(Ok(&[][..]), |after| { + after.as_array().map(Vec::as_slice).ok_or("GRAPH_ARRAY") + })?; + dependencies.push( + after + .iter() + .map(|dependency| { + dependency + .as_str() + .and_then(|id| ids.get(id).copied()) + .ok_or("GRAPH_DEPENDENCY") + }) + .collect::, _>>()?, + ); + } + let mut emitted = vec![false; passes.len()]; + let mut result = Vec::with_capacity(passes.len()); + while result.len() < passes.len() { + let ready = (0..passes.len()).find(|&index| { + !emitted[index] + && dependencies[index] + .iter() + .all(|dependency| emitted[*dependency]) + }); + let index = ready.ok_or("GRAPH_CYCLE")?; + emitted[index] = true; + result.push(passes[index].clone()); + } + Ok(result) +} + +fn plan_resources(graph: &mut Map, passes: &[Value]) -> Result<(), &'static str> { + let mut used = HashSet::new(); + let mut lifetimes = HashMap::new(); + for (frame, pass) in passes.iter().enumerate() { + for id in pass_resources(pass)? { + used.insert(id.to_owned()); + lifetimes + .entry(id.to_owned()) + .and_modify(|lifetime: &mut (usize, usize)| lifetime.1 = frame) + .or_insert((frame, frame)); + } + } + let Some(resources) = graph.get_mut("resources") else { + return Ok(()); + }; + let resources = resources.as_object_mut().ok_or("GRAPH_RESOURCE")?; + for kind in ["buffers", "samplers"] { + if let Some(declarations) = resources.get_mut(kind) { + let declarations = declarations.as_array_mut().ok_or("GRAPH_RESOURCE")?; + let mut ids = HashSet::new(); + declarations.retain(|declaration| { + declaration + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| ids.insert(id.to_owned()) && used.contains(id)) + }); + } + } + let Some(textures) = resources.get_mut("textures") else { + return Ok(()); + }; + let textures = textures.as_array_mut().ok_or("GRAPH_RESOURCE")?; + let mut ids = HashSet::new(); + let mut planned = Vec::new(); + for mut declaration in textures.drain(..) { + let object = declaration.as_object_mut().ok_or("GRAPH_RESOURCE")?; + let id = object + .get("id") + .and_then(Value::as_str) + .ok_or("GRAPH_RESOURCE")?; + if !ids.insert(id.to_owned()) { + return Err("GRAPH_RESOURCE"); + } + let Some(&(first, last)) = lifetimes.get(id) else { + continue; + }; + planned.push((declaration, first, last)); + } + planned.sort_by_key(|value| value.1); + let mut output = Vec::new(); + let mut slots: Vec<(String, usize)> = Vec::new(); + for (mut declaration, first, last) in planned { + let object = declaration.as_object_mut().unwrap(); + let key = texture_key(object)?; + let slot = if object.get("transient") == Some(&Value::Bool(false)) { + None + } else { + slots + .iter() + .position(|value| value.0 == key && value.1 < first) + }; + let slot = match slot { + Some(slot) => { + slots[slot].1 = last; + slot + } + None => { + slots.push((key, last)); + slots.len() - 1 + } + }; + object.insert("slot".into(), Value::Number(Number::from(slot as u64))); + output.push(declaration); + } + *textures = output; + Ok(()) +} + +fn pass_resources(pass: &Value) -> Result, &'static str> { + let mut result = Vec::new(); + for field in ["bindings", "color", "vertexBuffers"] { + if let Some(values) = pass.get(field) { + for value in values.as_array().ok_or("GRAPH_ARRAY")? { + result.push( + value + .get("resource") + .and_then(Value::as_str) + .ok_or("GRAPH_RESOURCE")?, + ); + } + } + } + for field in ["depth", "indexBuffer"] { + if let Some(value) = pass.get(field) { + result.push( + value + .get("resource") + .and_then(Value::as_str) + .ok_or("GRAPH_RESOURCE")?, + ); + } + } + Ok(result) +} + +fn texture_key(texture: &Map) -> Result { + let mut size = texture.get("size").cloned().unwrap_or_else(|| { + Value::Array(vec![ + Value::String("canvas".into()), + Value::String("canvas".into()), + ]) + }); + let size = size.as_array_mut().ok_or("GRAPH_TEXTURE_SIZE")?; + if !(2..=3).contains(&size.len()) { + return Err("GRAPH_TEXTURE_SIZE"); + } + if size.len() == 2 { + size.push(Value::Number(Number::from(1))); + } + let format = texture + .get("format") + .and_then(Value::as_str) + .ok_or("GRAPH_RESOURCE")?; + let mut usage = texture.get("usage").map_or(Ok(Vec::new()), |usage| { + usage + .as_array() + .ok_or("GRAPH_TEXTURE_USAGE")? + .iter() + .map(|value| value.as_str().ok_or("GRAPH_TEXTURE_USAGE")) + .collect::, _>>() + })?; + usage.sort_unstable(); + usage.dedup(); + serde_json::to_string(&( + size, + format, + usage, + texture + .get("mipLevelCount") + .and_then(Value::as_u64) + .unwrap_or(1), + texture + .get("sampleCount") + .and_then(Value::as_u64) + .unwrap_or(1), + texture + .get("dimension") + .and_then(Value::as_str) + .unwrap_or("2d"), + )) + .map_err(|_| "GRAPH_RESOURCE") +} diff --git a/packages/yawn-core/src/worker.js b/core/src/worker.js similarity index 70% rename from packages/yawn-core/src/worker.js rename to core/src/worker.js index d5ffa60..8f7eb03 100644 --- a/packages/yawn-core/src/worker.js +++ b/core/src/worker.js @@ -1,45 +1,11 @@ -let canvas, context, device, surfaceFormat, memory, used = 0, loadout; +import initWasm, { Core as WasmCore } from "../pkg/yawn_core.js"; + +let canvas, context, device, surfaceFormat, memory, core, loadout; const arrays = new Map(); const align = (value, multiple) => Math.ceil(value / multiple) * multiple; const fail = code => { throw new Error(code); }; const list = value => value === undefined ? [] : Array.isArray(value) ? value : fail("GRAPH_ARRAY"); -function parse(source) { - if (typeof source !== "string") fail("GRAPH_WIRE"); - const tokens = source.match(/\s*(\(|\)|"(?:\\.|[^"\\])*"|[^\s()]+)/gu) ?? []; - let at = 0; - const read = () => { - const token = tokens[at++]?.trim(); - if (token === "(") { - const value = []; - while (tokens[at]?.trim() !== ")") { - if (at >= tokens.length) fail("GRAPH_WIRE"); - value.push(read()); - } - at++; - return value; - } - if (!token || token === ")") fail("GRAPH_WIRE"); - if (token[0] === '"') return JSON.parse(token); - if (token === "true") return true; - if (token === "false") return false; - if (token === "null") return null; - return Number.isFinite(Number(token)) ? Number(token) : token; - }; - const root = read(); - if (at !== tokens.length || root[0] !== "yawn-graph" || root[1] !== 1) fail("GRAPH_WIRE"); - const decode = value => { - if (!Array.isArray(value)) return value; - if (value[0] === "array") return value.slice(1).map(decode); - if (value[0] === "object") return Object.fromEntries(value.slice(1).map(field => { - if (field[0] !== "field" || field.length !== 3) fail("GRAPH_WIRE"); - return [field[1], decode(field[2])]; - })); - fail("GRAPH_WIRE"); - }; - return decode(root[2]); -} - function index(items, code) { const result = new Map(); for (const item of list(items)) { @@ -49,22 +15,6 @@ function index(items, code) { return result; } -function sortPasses(passes) { - const byId = index(passes, "GRAPH_PASS"); - const waiting = new Map([...byId].map(([id, pass]) => [id, new Set(list(pass.after))])); - for (const dependencies of waiting.values()) - for (const dependency of dependencies) if (!byId.has(dependency)) fail("GRAPH_DEPENDENCY"); - const result = []; - while (waiting.size) { - const ready = [...waiting].find(([, dependencies]) => !dependencies.size); - if (!ready) fail("GRAPH_CYCLE"); - waiting.delete(ready[0]); - result.push(byId.get(ready[0])); - for (const dependencies of waiting.values()) dependencies.delete(ready[0]); - } - return result; -} - const bufferUsage = names => list(names).reduce((usage, name) => usage | ({ uniform: GPUBufferUsage.UNIFORM, storage: GPUBufferUsage.STORAGE, vertex: GPUBufferUsage.VERTEX, index: GPUBufferUsage.INDEX, @@ -78,20 +28,12 @@ const textureUsage = names => list(names).reduce((usage, name) => usage | ({ async function compile(graph) { if (!graph || typeof graph.id !== "string") fail("GRAPH_SHAPE"); - const passes = sortPasses(graph.passes); + const passes = list(graph.passes); const renderDeclarations = index(graph.pipelines?.render, "GRAPH_PIPELINE"); const computeDeclarations = index(graph.pipelines?.compute, "GRAPH_PIPELINE"); const resources = new Map(), owned = []; try { - const usedResources = new Set(passes.flatMap(pass => [ - ...list(pass.bindings).map(x => x.resource), - ...list(pass.color).map(x => x.resource), - ...(pass.depth ? [pass.depth.resource] : []), - ...list(pass.vertexBuffers).map(x => x.resource), - ...(pass.indexBuffer ? [pass.indexBuffer.resource] : []), - ])); for (const declaration of list(graph.resources?.buffers)) { - if (!usedResources.has(declaration.id)) continue; const source = arrays.get(declaration.array); if (!source) fail("GRAPH_ARRAY_UNKNOWN"); const gpu = device.createBuffer({ size: align(source.bytes, 4), usage: bufferUsage(declaration.usage) }); @@ -99,18 +41,9 @@ async function compile(graph) { owned.push(gpu); } - const textures = index(graph.resources?.textures, "GRAPH_RESOURCE"), lifetimes = new Map(), slots = []; - passes.forEach((pass, frame) => { - for (const id of [...list(pass.bindings).map(x => x.resource), ...list(pass.color).map(x => x.resource), ...(pass.depth ? [pass.depth.resource] : [])]) { - if (!textures.has(id)) continue; - const lifetime = lifetimes.get(id) ?? [frame, frame]; - lifetime[1] = frame; - lifetimes.set(id, lifetime); - } - }); - for (const declaration of textures.values()) { - const lifetime = lifetimes.get(declaration.id); - if (!lifetime) continue; + const textureSlots = new Map(); + for (const declaration of list(graph.resources?.textures)) { + if (!Number.isInteger(declaration.slot)) fail("GRAPH_RESOURCE"); const size = declaration.size ?? ["canvas", "canvas"]; if (!Array.isArray(size) || size.length < 2 || size.length > 3) fail("GRAPH_TEXTURE_SIZE"); const descriptor = { @@ -121,18 +54,17 @@ async function compile(graph) { sampleCount: declaration.sampleCount ?? 1, dimension: declaration.dimension ?? "2d", }; - const key = JSON.stringify(descriptor); - let slot = declaration.transient === false ? undefined : slots.find(value => value.key === key && value.last < lifetime[0]); + let slot = textureSlots.get(declaration.slot); if (!slot) { const gpu = device.createTexture(descriptor); - slot = { key, last: lifetime[1], gpu, view: gpu.createView() }; - slots.push(slot); + slot = { gpu, view: gpu.createView() }; + textureSlots.set(declaration.slot, slot); owned.push(gpu); - } else slot.last = lifetime[1]; + } resources.set(declaration.id, { kind: "texture", gpu: slot.gpu, view: slot.view }); } for (const declaration of list(graph.resources?.samplers)) - if (usedResources.has(declaration.id)) resources.set(declaration.id, { + resources.set(declaration.id, { kind: "sampler", gpu: device.createSampler(declaration.descriptor), }); @@ -256,7 +188,10 @@ addEventListener("message", async ({ data: message }) => { if (message.type === "init") { if (!(message.canvas instanceof OffscreenCanvas) || !Number.isInteger(message.arenaBytes) || message.arenaBytes < 64) fail("INIT"); canvas = message.canvas; - memory = new SharedArrayBuffer(align(message.arenaBytes, 64)); + const wasm = await initWasm(); + core = new WasmCore(message.arenaBytes); + memory = wasm.memory.buffer; + if (!(memory instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED"); const adapter = await navigator.gpu?.requestAdapter(); if (!adapter) fail("WEBGPU_UNAVAILABLE"); device = await adapter.requestDevice(); @@ -268,15 +203,12 @@ addEventListener("message", async ({ data: message }) => { tick(); } else if (message.type === "allocate") { const { name, rows, stride, format } = message; - if (typeof name !== "string" || !name || arrays.has(name) || !Number.isInteger(rows) || rows < 1 || - !Number.isInteger(stride) || stride < 16 || stride % 16 || !["f32", "u32", "i32"].includes(format)) fail("ALLOCATION"); - const offset = align(used, 64), bytes = rows * stride; - if (!Number.isSafeInteger(bytes) || offset + bytes > memory.byteLength) fail("ARENA_OOM"); + if (typeof name !== "string" || !name || arrays.has(name)) fail("ALLOCATION"); + const offset = core.allocate(rows, stride, format), bytes = rows * stride; result = { name, rows, stride, format, offset }; arrays.set(name, { ...result, bytes }); - used = offset + bytes; } else if (message.type === "load-graph") { - const next = await compile(parse(message.serialized)); + const next = await compile(JSON.parse(core.compile_graph(message.serialized))); const previous = loadout; loadout = next; previous?.owned.forEach(resource => resource.destroy?.()); diff --git a/docs/index.md b/docs/index.md index 1118c6d..a1df226 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ layout: home hero: name: Yawn text: Shared render data and a render graph. - tagline: Two core files, one fixed arena, no built-in scene model or shader. + tagline: One Rust/WASM core, one fixed arena, no built-in scene model or shader. actions: - theme: brand text: Open the playground @@ -32,11 +32,11 @@ color.row(0)[0] = 0.8; // direct SharedArrayBuffer write await loadGraph(core, graph); // infrequent message ``` -`@yawn/core` contains only the public shared-row client and its worker. The worker owns the fixed 64-byte-aligned arena, S-expression graph compiler, WebGPU loadout, and transient texture aliasing. Every scene convention and every byte of WGSL comes from an addon or application. +`@yawn/core` contains only the Rust/WASM render-data arena and graph compiler plus the browser worker required to execute WebGPU. Rust owns the fixed 64-byte-aligned shared arena, DAG ordering, resource culling, and transient texture planning; the worker materializes the resulting loadout. Every scene convention and every byte of WGSL comes from an addon or application. ```text -JSO / FXNode ──▶ AST ──▶ S-expression ──▶ core worker ──▶ WebGPU -any JS thread ───────────── direct SAB row writes ────────────┘ +JSO / FXNode ──▶ AST ──▶ S-expression ──▶ Rust/WASM ──▶ worker ──▶ WebGPU +any JS thread ────────────── direct SAB row writes ────────────────────┘ ``` The addon packages provide graph serialization, optional WGSL, glTF import directly into shared rows, and conventional camera/material/mesh handles. None of them add semantics to core. diff --git a/package-lock.json b/package-lock.json index 850afdd..1351602 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "yawn", "version": "0.1.0", "workspaces": [ - "packages/*", + "core", "addons/*" ], "devDependencies": { @@ -45,6 +45,10 @@ "@yawn/render-graph-ast": "0.1.0" } }, + "core": { + "name": "@yawn/core", + "version": "0.1.0" + }, "node_modules/@algolia/abtesting": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.23.0.tgz", @@ -1238,7 +1242,7 @@ } }, "node_modules/@yawn/core": { - "resolved": "packages/yawn-core", + "resolved": "core", "link": true }, "node_modules/@yawn/default-pipelines": { @@ -2256,10 +2260,6 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } - }, - "packages/yawn-core": { - "name": "@yawn/core", - "version": "0.1.0" } } } diff --git a/package.json b/package.json index c36aa9f..c53eee6 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", - "workspaces": ["packages/*", "addons/*"], + "workspaces": ["core", "addons/*"], "scripts": { - "start": "vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}" + "start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}" }, "devDependencies": { "vitepress": "^1.6.4" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..b81bf01 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly-2025-10-20" +components = ["rust-src", "rustfmt"] +targets = ["wasm32-unknown-unknown"]