From 47882a832d898becb06473d0eabbe762d14f485b Mon Sep 17 00:00:00 2001 From: Max Richter Date: Thu, 22 Jan 2026 18:48:16 +0100 Subject: [PATCH] feat: first working version of new allocator --- .cargo/config.toml | 9 + README.md | 9 +- SHARED_MEMORY_REFACTOR_PLAN.md | 783 ++++++++++++++++++ SUMMARY.md | 227 +++++ SUMMARY_RUNTIME.md | 294 +++++++ app/src/lib/runtime/runtime-executor.ts | 212 ++--- docs/DEVELOPING_NODES.md | 9 +- nodes/max/plantarium/float/Cargo.toml | 2 +- nodes/max/plantarium/float/src/lib.rs | 6 +- nodes/max/plantarium/math/Cargo.toml | 2 +- nodes/max/plantarium/math/src/lib.rs | 29 +- nodes/max/plantarium/output/Cargo.toml | 2 +- .../output/src/{inputs.json => input.json} | 0 nodes/max/plantarium/output/src/lib.rs | 41 +- package.json | 1 + packages/macros/src/lib.rs | 192 +++-- packages/registry/src/node-registry-client.ts | 11 +- packages/types/src/types.ts | 2 +- packages/utils/Cargo.toml | 4 + packages/utils/src/encoding.rs | 49 ++ packages/utils/src/lib.rs | 20 +- packages/utils/src/wasm-wrapper.ts | 37 +- 22 files changed, 1668 insertions(+), 273 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 SHARED_MEMORY_REFACTOR_PLAN.md create mode 100644 SUMMARY.md create mode 100644 SUMMARY_RUNTIME.md rename nodes/max/plantarium/output/src/{inputs.json => input.json} (100%) diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..be42c80 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,9 @@ +[build] +rustflags = [ + "-C", + "link-arg=--import-memory", + "-C", + "link-arg=--initial-memory=67108864", # 64 MiB + "-C", + "link-arg=--max-memory=536870912", # 512 MiB +] diff --git a/README.md b/README.md index a78425a..669e91b 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ Nodarium
-

Nodarium

+

Nodarium

- Nodarium is a WebAssembly based visual programming language. + Nodarium is a WebAssembly based visual programming language.

-Currently this visual programming language is used to develop https://nodes.max-richter.dev, a procedural modelling tool for 3d-plants. +Currently this visual programming language is used to develop , a procedural modelling tool for 3d-plants. # Table of contents @@ -22,12 +22,11 @@ Currently this visual programming language is used to develop https://nodes.max- # Developing -### Install prerequisites: +### Install prerequisites - [Node.js](https://nodejs.org/en/download) - [pnpm](https://pnpm.io/installation) - [rust](https://www.rust-lang.org/tools/install) -- wasm-pack ### Install dependencies diff --git a/SHARED_MEMORY_REFACTOR_PLAN.md b/SHARED_MEMORY_REFACTOR_PLAN.md new file mode 100644 index 0000000..e330046 --- /dev/null +++ b/SHARED_MEMORY_REFACTOR_PLAN.md @@ -0,0 +1,783 @@ +# Shared Memory Refactor Plan + +## Executive Summary + +Migrate to a single shared `WebAssembly.Memory` instance imported by all nodes using `--import-memory`. The `#[nodarium_execute]` macro writes the function's return value directly to shared memory at the specified offset. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Shared WebAssembly.Memory │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ [Node A output] [Node B output] [Node C output] ... │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ +│ │ │ Vec │ │ Vec │ │ Vec │ │ │ +│ │ │ 4 bytes │ │ 12 bytes │ │ 2KB │ │ │ +│ │ └────────────┘ └────────────┘ └────────────┘ │ │ +│ │ │ │ +│ │ offset: 0 ────────────────────────────────────────────────► │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + ▲ + │ + │ import { memory } from "env" + ┌─────────────────────────┼─────────────────────────┐ + │ │ │ + ┌────┴────┐ ┌────┴────┐ ┌────┴────┐ + │ Node A │ │ Node B │ │ Node C │ + │ WASM │ │ WASM │ │ WASM │ + └─────────┘ └─────────┘ └─────────┘ +``` + +## Phase 1: Compilation Configuration + +### 1.1 Cargo Config + +```toml +# nodes/max/plantarium/box/.cargo/config.toml +[build] +rustflags = ["-C", "link-arg=--import-memory"] +``` + +Or globally in `Cargo.toml`: + +```toml +[profile.release] +rustflags = ["-C", "link-arg=--import-memory"] +``` + +### 1.2 Import Memory Semantics + +With `--import-memory`: + +- Nodes **import** memory from the host (not export their own) +- All nodes receive the same `WebAssembly.Memory` instance +- Memory is read/write accessible from all modules +- No `memory.grow` needed (host manages allocation) + +## Phase 2: Macro Design + +### 2.1 Clean Node API + +```rust +// input.json has 3 inputs: op_type, a, b +nodarium_definition_file!("src/input.json"); + +#[nodarium_execute] +pub fn execute(op_type: *i32, a: *i32, b: *i32) -> Vec { + // Read inputs directly from shared memory + let op = unsafe { *op_type }; + let a_val = f32::from_bits(unsafe { *a } as u32); + let b_val = f32::from_bits(unsafe { *b } as u32); + + let result = match op { + 0 => a_val + b_val, + 1 => a_val - b_val, + 2 => a_val * b_val, + 3 => a_val / b_val, + _ => 0.0, + }; + + // Return Vec, macro handles writing to shared memory + vec![result.to_bits()] +} +``` + +### 2.2 Macro Implementation + +```rust +// packages/macros/src/lib.rs + +#[proc_macro_attribute] +pub fn nodarium_execute(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input_fn = parse_macro_input!(item as syn::ItemFn); + let fn_name = &input_fn.sig.ident; + + // Parse definition to get input count + let project_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let def: NodeDefinition = serde_json::from_str(&fs::read_to_string( + Path::new(&project_dir).join("src/input.json") + ).unwrap()).unwrap(); + + let input_count = def.inputs.as_ref().map(|i| i.len()).unwrap_or(0); + + // Validate signature + validate_signature(&input_fn, input_count); + + // Generate wrapper + generate_execute_wrapper(input_fn, fn_name, input_count) +} + +fn validate_signature(fn_sig: &syn::Signature, expected_inputs: usize) { + let param_count = fn_sig.inputs.len(); + if param_count != expected_inputs { + panic!( + "Execute function has {} parameters but definition has {} inputs\n\ + Definition inputs: {:?}\n\ + Expected signature:\n\ + pub fn execute({}) -> Vec", + param_count, + expected_inputs, + def.inputs.as_ref().map(|i| i.keys().collect::>()), + (0..expected_inputs) + .map(|i| format!("arg{}: *const i32", i)) + .collect::>() + .join(", ") + ); + } + + // Verify return type is Vec + match &fn_sig.output { + syn::ReturnType::Type(_, ty) => { + if !matches!(&**ty, syn::Type::Path(tp) if tp.path.is_ident("Vec")) { + panic!("Execute function must return Vec"); + } + } + syn::ReturnType::Default => { + panic!("Execute function must return Vec"); + } + } +} + +fn generate_execute_wrapper( + input_fn: syn::ItemFn, + fn_name: &syn::Ident, + input_count: usize, +) -> TokenStream { + let arg_names: Vec<_> = (0..input_count) + .map(|i| syn::Ident::new(&format!("arg{}", i), proc_macro2::Span::call_site())) + .collect(); + + let expanded = quote! { + #input_fn + + #[no_mangle] + pub extern "C" fn execute( + output_pos: i32, + #( #arg_names: i32 ),* + ) -> i32 { + extern "C" { + fn __nodarium_log(ptr: *const u8, len: usize); + fn __nodarium_log_panic(ptr: *const u8, len: usize); + } + + // Setup panic hook + static SET_HOOK: std::sync::Once = std::sync::Once::new(); + SET_HOOK.call_once(|| { + std::panic::set_hook(Box::new(|info| { + let msg = info.to_string(); + unsafe { __nodarium_log_panic(msg.as_ptr(), msg.len()); } + })); + }); + + // Call user function + let result = #fn_name( + #( #arg_names as *const i32 ),* + ); + + // Write result directly to shared memory at output_pos + let len_bytes = result.len() * 4; + unsafe { + let src = result.as_ptr() as *const u8; + let dst = output_pos as *mut u8; + dst.copy_from_nonoverlapping(src, len_bytes); + } + + // Forget the Vec to prevent deallocation (data is in shared memory now) + core::mem::forget(result); + + len_bytes as i32 + } + }; + + TokenStream::from(expanded) +} +``` + +### 2.3 Generated Assembly + +The macro generates: + +```asm +; Input: output_pos in register r0, arg0 in r1, arg1 in r2, arg2 in r3 +execute: + ; Call user function + bl user_execute ; returns pointer to Vec in r0 + + ; Calculate byte length + ldr r4, [r0, #8] ; Vec::len field + lsl r4, r4, #2 ; len * 4 (i32 = 4 bytes) + + ; Copy Vec data to shared memory at output_pos + ldr r5, [r0, #0] ; Vec::ptr field + ldr r6, [r0, #4] ; capacity (unused) + + ; memcpy(dst=output_pos, src=r5, len=r4) + ; (implemented via copy_from_nonoverlapping) + + ; Return length + mov r0, r4 + bx lr +``` + +## Phase 3: Input Reading Helpers + +```rust +// packages/utils/src/accessor.rs + +/// Read i32 from shared memory +#[inline] +pub unsafe fn read_i32(ptr: *const i32) -> i32 { + *ptr +} + +/// Read f32 from shared memory (stored as i32 bits) +#[inline] +pub unsafe fn read_f32(ptr: *const i32) -> f32 { + f32::from_bits(*ptr as u32) +} + +/// Read boolean from shared memory +#[inline] +pub unsafe fn read_bool(ptr: *const i32) -> bool { + *ptr != 0 +} + +/// Read vec3 (3 f32s) from shared memory +#[inline] +pub unsafe fn read_vec3(ptr: *const i32) -> [f32; 3] { + let p = ptr as *const f32; + [p.read(), p.add(1).read(), p.add(2).read()] +} + +/// Read slice from shared memory +#[inline] +pub unsafe fn read_i32_slice(ptr: *const i32, len: usize) -> &[i32] { + std::slice::from_raw_parts(ptr, len) +} + +/// Read f32 slice from shared memory +#[inline] +pub unsafe fn read_f32_slice(ptr: *const i32, len: usize) -> &[f32] { + std::slice::from_raw_parts(ptr as *const f32, len) +} + +/// Read with default value +#[inline] +pub unsafe fn read_f32_default(ptr: *const i32, default: f32) -> f32 { + if ptr.is_null() { default } else { read_f32(ptr) } +} + +#[inline] +pub unsafe fn read_i32_default(ptr: *const i32, default: i32) -> i32 { + if ptr.is_null() { default } else { read_i32(ptr) } +} +``` + +## Phase 4: Node Implementation Examples + +### 4.1 Math Node + +```rust +// nodes/max/plantarium/math/src/lib.rs + +nodarium_definition_file!("src/input.json"); + +#[nodarium_execute] +pub fn execute(op_type: *const i32, a: *const i32, b: *const i32) -> Vec { + use nodarium_utils::{read_i32, read_f32}; + + let op = unsafe { read_i32(op_type) }; + let a_val = unsafe { read_f32(a) }; + let b_val = unsafe { read_f32(b) }; + + let result = match op { + 0 => a_val + b_val, // add + 1 => a_val - b_val, // subtract + 2 => a_val * b_val, // multiply + 3 => a_val / b_val, // divide + _ => 0.0, + }; + + vec![result.to_bits()] +} +``` + +### 4.2 Vec3 Node + +```rust +// nodes/max/plantarium/vec3/src/lib.rs + +nodarium_definition_file!("src/input.json"); + +#[nodarium_execute] +pub fn execute(x: *const i32, y: *const i32, z: *const i32) -> Vec { + use nodarium_utils::read_f32; + + let x_val = unsafe { read_f32(x) }; + let y_val = unsafe { read_f32(y) }; + let z_val = unsafe { read_f32(z) }; + + vec![x_val.to_bits(), y_val.to_bits(), z_val.to_bits()] +} +``` + +### 4.3 Box Node + +```rust +// nodes/max/plantarium/box/src/lib.rs + +nodarium_definition_file!("src/input.json"); + +#[nodarium_execute] +pub fn execute(size: *const i32) -> Vec { + use nodarium_utils::{read_f32, encode_float, calculate_normals}; + + let size = unsafe { read_f32(size) }; + let p = encode_float(size); + let n = encode_float(-size); + + let mut cube_geometry = vec![ + 1, // 1: geometry + 8, // 8 vertices + 12, // 12 faces + + // Face indices + 0, 1, 2, 0, 2, 3, + 0, 3, 4, 4, 5, 0, + 6, 1, 0, 5, 6, 0, + 7, 2, 1, 6, 7, 1, + 2, 7, 3, 3, 7, 4, + 7, 6, 4, 4, 6, 5, + + // Bottom plate + p, n, n, p, n, p, n, n, p, n, n, n, + + // Top plate + n, p, n, p, p, n, p, p, p, n, p, p, + + // Normals + 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]; + + calculate_normals(&mut cube_geometry); + cube_geometry +} +``` + +### 4.4 Stem Node + +```rust +// nodes/max/plantarium/stem/src/lib.rs + +nodarium_definition_file!("src/input.json"); + +#[nodarium_execute] +pub fn execute( + origin: *const i32, + amount: *const i32, + length: *const i32, + thickness: *const i32, + resolution: *const i32, +) -> Vec { + use nodarium_utils::{ + read_vec3, read_i32, read_f32, + geometry::{create_multiple_paths, wrap_multiple_paths}, + }; + + let origin = unsafe { read_vec3(origin) }; + let amount = unsafe { read_i32(amount) } as usize; + let length = unsafe { read_f32(length) }; + let thickness = unsafe { read_f32(thickness) }; + let resolution = unsafe { read_i32(resolution) } as usize; + + let mut stem_data = create_multiple_paths(amount, resolution, 1); + let mut stems = wrap_multiple_paths(&mut stem_data); + + for stem in stems.iter_mut() { + let points = stem.get_points_mut(); + for (i, point) in points.iter_mut().enumerate() { + let t = i as f32 / (resolution as f32 - 1.0); + point.x = origin[0]; + point.y = origin[1] + t * length; + point.z = origin[2]; + point.w = thickness * (1.0 - t); + } + } + + stem_data +} +``` + +## Phase 5: Runtime Implementation + +```typescript +// app/src/lib/runtime/memory-manager.ts + +export const SHARED_MEMORY = new WebAssembly.Memory({ + initial: 1024, // 64MB initial + maximum: 4096, // 256MB maximum +}); + +export class MemoryManager { + private offset: number = 0; + private readonly start: number = 0; + + reset() { + this.offset = this.start; + } + + alloc(bytes: number): number { + const pos = this.offset; + this.offset += bytes; + return pos; + } + + readInt32(pos: number): number { + return new Int32Array(SHARED_MEMORY.buffer)[pos / 4]; + } + + readFloat32(pos: number): number { + return new Float32Array(SHARED_MEMORY.buffer)[pos / 4]; + } + + readBytes(pos: number, length: number): Uint8Array { + return new Uint8Array(SHARED_MEMORY.buffer, pos, length); + } + + getInt32View(): Int32Array { + return new Int32Array(SHARED_MEMORY.buffer); + } + + getFloat32View(): Float32Array { + return new Float32Array(SHARED_MEMORY.buffer); + } + + getRemaining(): number { + return SHARED_MEMORY.buffer.byteLength - this.offset; + } +} +``` + +```typescript +// app/src/lib/runtime/imports.ts + +import { SHARED_MEMORY } from "./memory-manager"; + +export function createImportObject(nodeId: string): WebAssembly.Imports { + return { + env: { + // Import shared memory + memory: SHARED_MEMORY, + + // Logging + __nodarium_log: (ptr: number, len: number) => { + const msg = new TextDecoder().decode( + new Uint8Array(SHARED_MEMORY.buffer, ptr, len), + ); + console.log(`[${nodeId}] ${msg}`); + }, + + __nodarium_log_panic: (ptr: number, len: number) => { + const msg = new TextDecoder().decode( + new Uint8Array(SHARED_MEMORY.buffer, ptr, len), + ); + console.error(`[${nodeId}] PANIC: ${msg}`); + }, + }, + }; +} +``` + +```typescript +// app/src/lib/runtime/executor.ts + +import { SHARED_MEMORY } from "./memory-manager"; +import { createImportObject } from "./imports"; + +export class SharedMemoryRuntimeExecutor implements RuntimeExecutor { + private memory: MemoryManager; + private results: Map = new Map(); + private instances: Map = new Map(); + + constructor(private registry: NodeRegistry) { + this.memory = new MemoryManager(); + } + + async execute(graph: Graph, settings: Record) { + this.memory.reset(); + this.results.clear(); + + const [outputNode, nodes] = await this.addMetaData(graph); + const sortedNodes = nodes.sort((a, b) => b.depth - a.depth); + + for (const node of sortedNodes) { + await this.executeNode(node, settings); + } + + const result = this.results.get(outputNode.id); + const view = this.memory.getInt32View(); + return view.subarray(result.pos / 4, result.pos / 4 + result.len / 4); + } + + private async executeNode( + node: RuntimeNode, + settings: Record, + ) { + const def = this.definitionMap.get(node.type)!; + const inputs = def.inputs || {}; + const inputNames = Object.keys(inputs); + + const outputSize = this.estimateOutputSize(def); + const outputPos = this.memory.alloc(outputSize); + const args: number[] = [outputPos]; + + for (const inputName of inputNames) { + const inputDef = inputs[inputName]; + const inputNode = node.state.inputNodes[inputName]; + if (inputNode) { + const parentResult = this.results.get(inputNode.id)!; + args.push(parentResult.pos); + continue; + } + + const valuePos = this.memory.alloc(16); + this.writeValue( + valuePos, + inputDef, + node.props?.[inputName] ?? + settings[inputDef.setting ?? ""] ?? + inputDef.value, + ); + args.push(valuePos); + } + + let instance = this.instances.get(node.type); + if (!instance) { + instance = await this.instantiateNode(node.type); + this.instances.set(node.type, instance); + } + + const writtenLen = instance.exports.execute(...args); + this.results.set(node.id, { pos: outputPos, len: writtenLen }); + } + + private writeValue(pos: number, inputDef: NodeInput, value: unknown) { + const view = this.memory.getFloat32View(); + const intView = this.memory.getInt32View(); + + switch (inputDef.type) { + case "float": + view[pos / 4] = value as number; + break; + case "integer": + case "select": + case "seed": + intView[pos / 4] = value as number; + break; + case "boolean": + intView[pos / 4] = value ? 1 : 0; + break; + case "vec3": + const arr = value as number[]; + view[pos / 4] = arr[0]; + view[pos / 4 + 1] = arr[1]; + view[pos / 4 + 2] = arr[2]; + break; + } + } + + private estimateOutputSize(def: NodeDefinition): number { + const sizes: Record = { + float: 16, + integer: 16, + boolean: 16, + vec3: 16, + geometry: 8192, + path: 4096, + }; + return sizes[def.outputs?.[0] || "float"] || 64; + } + + private async instantiateNode( + nodeType: string, + ): Promise { + const wasmBytes = await this.fetchWasm(nodeType); + const module = await WebAssembly.compile(wasmBytes); + const importObject = createImportObject(nodeType); + return WebAssembly.instantiate(module, importObject); + } +} +``` + +## Phase 7: Execution Flow Visualization + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Execution Timeline │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Step 1: Setup + SHARED_MEMORY = new WebAssembly.Memory({ initial: 1024 }) + memory.offset = 0 + +Step 2: Execute Node A (math with 3 inputs) + outputPos = memory.alloc(16) = 0 + args = [0, ptr_to_op_type, ptr_to_a, ptr_to_b] + + Node A reads: + *ptr_to_op_type → op + *ptr_to_a → a + *ptr_to_b → b + + Node A returns: vec![result.to_bits()] + + Macro writes result directly to SHARED_MEMORY[0..4] + Returns: 4 + + results['A'] = { pos: 0, len: 4 } + memory.offset = 4 + +Step 3: Execute Node B (stem with 5 inputs, input[0] from A) + outputPos = memory.alloc(4096) = 4 + args = [4, results['A'].pos, ptr_to_amount, ptr_to_length, ...] + + Node B reads: + *results['A'].pos → value from Node A + *ptr_to_amount → amount + ... + + Node B returns: stem_data Vec (1000 elements = 4000 bytes) + + Macro writes stem_data directly to SHARED_MEMORY[4..4004] + Returns: 4000 + + results['B'] = { pos: 4, len: 4000 } + memory.offset = 4004 + +Step 4: Execute Node C (output, 1 input from B) + outputPos = memory.alloc(16) = 4004 + args = [4004, results['B'].pos, results['B'].len] + + Node C reads: + *results['B'].pos → stem geometry + + Node C returns: vec![1] (identity) + Macro writes to SHARED_MEMORY[4004..4008] + + results['C'] = { pos: 4004, len: 4 } + +Final: Return SHARED_MEMORY[4004..4008] as geometry result +``` + +## Phase 6: Memory Growth Strategy + +```typescript +class MemoryManager { + alloc(bytes: number): number { + const required = this.offset + bytes; + const currentBytes = SHARED_MEMORY.buffer.byteLength; + + if (required > currentBytes) { + const pagesNeeded = Math.ceil((required - currentBytes) / 65536); + const success = SHARED_MEMORY.grow(pagesNeeded); + + if (!success) { + throw new Error(`Out of memory: need ${bytes} bytes`); + } + + this.int32View = new Int32Array(SHARED_MEMORY.buffer); + this.float32View = new Float32Array(SHARED_MEMORY.buffer); + } + + const pos = this.offset; + this.offset += bytes; + return pos; + } +} +``` + +## Phase 8: Migration Checklist + +### Build Configuration + +- [ ] Add `--import-memory` to Rust flags in `Cargo.toml` +- [ ] Ensure no nodes export memory + +### Runtime + +- [ ] Create `SHARED_MEMORY` instance +- [ ] Implement `MemoryManager` with alloc/read/write +- [ ] Create import object factory +- [ ] Implement `SharedMemoryRuntimeExecutor` + +### Macro + +- [ ] Parse definition JSON +- [ ] Validate function signature (N params, Vec return) +- [ ] Generate wrapper that writes return value to `output_pos` +- [ ] Add panic hook + +### Utilities + +- [ ] `read_i32(ptr: *const i32) -> i32` +- [ ] `read_f32(ptr: *const i32) -> f32` +- [ ] `read_bool(ptr: *const i32) -> bool` +- [ ] `read_vec3(ptr: *const i32) -> [f32; 3]` +- [ ] `read_i32_slice(ptr: *const i32, len: usize) -> &[i32]` + +### Nodes + +- [ ] `float`, `integer`, `boolean` nodes +- [ ] `vec3` node +- [ ] `math` node +- [ ] `random` node +- [ ] `box` node +- [ ] `stem` node +- [ ] `branch` node +- [ ] `instance` node +- [ ] `output` node + +## Phase 9: Before vs After + +### Before (per-node memory) + +```rust +#[nodarium_execute] +pub fn execute(input: &[i32]) -> Vec { + let args = split_args(input); + let a = evaluate_float(args[0]); + let b = evaluate_float(args[1]); + vec![(a + b).to_bits()] +} +``` + +### After (shared memory) + +```rust +#[nodarium_execute] +pub fn execute(a: *const i32, b: *const i32) -> Vec { + use nodarium_utils::read_f32; + let a_val = unsafe { read_f32(a) }; + let b_val = unsafe { read_f32(b) }; + vec![(a_val + b_val).to_bits()] +} +``` + +**Key differences:** + +- Parameters are input pointers, not a slice +- Use `read_f32` helper instead of `evaluate_float` +- Macro writes result directly to shared memory +- All nodes share the same memory import + +## Phase 10: Benefits + +| Aspect | Before | After | +| ----------------- | -------------- | -------------------- | +| Memory | N × ~1MB heaps | 1 × 64-256MB shared | +| Cross-node access | Copy via JS | Direct read | +| API | `&[i32]` slice | `*const i32` pointer | +| Validation | Runtime | Compile-time | diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..2c472b0 --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,227 @@ +# Nodarium - AI Coding Agent Summary + +## Project Overview + +Nodarium is a WebAssembly-based visual programming language used to build , a procedural 3D plant modeling tool. The system allows users to create visual node graphs where each node is a compiled WebAssembly module. + +## Technology Stack + +**Frontend (SvelteKit):** + +- Framework: SvelteKit with Svelte 5 +- 3D Rendering: Three.js via Threlte +- Styling: Tailwind CSS 4 +- Build Tool: Vite +- State Management: Custom store-client package +- WASM Integration: vite-plugin-wasm, comlink + +**Backend/Core (Rust/WASM):** + +- Language: Rust +- Output: WebAssembly (wasm32-unknown-unknown target) +- Build Tool: cargo +- Procedural Macros: custom macros package + +**Package Management:** + +- Node packages: pnpm workspace (v10.28.1) +- Rust packages: Cargo workspace + +## Directory Structure + +``` +nodarium/ +├── app/ # SvelteKit web application +│ ├── src/ +│ │ ├── lib/ # App-specific components and utilities +│ │ ├── routes/ # SvelteKit routes (pages) +│ │ ├── app.css # Global styles +│ │ └── app.html # HTML template +│ ├── static/ +│ │ └── nodes/ # Compiled WASM node files served statically +│ ├── package.json # App dependencies +│ ├── svelte.config.js # SvelteKit configuration +│ ├── vite.config.ts # Vite configuration +│ └── tsconfig.json # TypeScript configuration +│ +├── packages/ # Shared workspace packages +│ ├── ui/ # Svelte UI component library (published as @nodarium/ui) +│ │ ├── src/ # UI components +│ │ ├── static/ # Static assets for UI +│ │ ├── dist/ # Built output +│ │ └── package.json +│ ├── registry/ # Node registry with IndexedDB persistence (@nodarium/registry) +│ │ └── src/ +│ ├── types/ # Shared TypeScript types (@nodarium/types) +│ │ └── src/ +│ ├── utils/ # Shared utilities (@nodarium/utils) +│ │ └── src/ +│ └── macros/ # Rust procedural macros for node development +│ +├── nodes/ # WebAssembly node packages (Rust) +│ └── max/plantarium/ # Plantarium nodes namespace +│ ├── box/ # Box geometry node +│ ├── branch/ # Branch generation node +│ ├── float/ # Float value node +│ ├── gravity/ # Gravity simulation node +│ ├── instance/ # Geometry instancing node +│ ├── math/ # Math operations node +│ ├── noise/ # Noise generation node +│ ├── output/ # Output node for results +│ ├── random/ # Random value node +│ ├── rotate/ # Rotation transformation node +│ ├── stem/ # Stem geometry node +│ ├── triangle/ # Triangle geometry node +│ ├── vec3/ # Vector3 manipulation node +│ └── .template/ # Node template for creating new nodes +│ +├── docs/ # Documentation +│ ├── ARCHITECTURE.md # System architecture overview +│ ├── DEVELOPING_NODES.md # Guide for creating new nodes +│ ├── NODE_DEFINITION.md # Node definition schema +│ └── PLANTARIUM.md # Plantarium-specific documentation +│ +├── Cargo.toml # Rust workspace configuration +├── package.json # Root npm scripts +├── pnpm-workspace.yaml # pnpm workspace configuration +├── pnpm-lock.yaml # Locked dependency versions +└── README.md # Project readme +``` + +## Node System Architecture + +### What is a Node? + +Nodes are WebAssembly modules that: + +- Have a unique ID (e.g., `max/plantarium/stem`) +- Define inputs with types and default values +- Define outputs they produce +- Execute logic when called with arguments + +### Node Definition Schema + +Nodes are defined via `definition.json` embedded in each WASM module: + +```json +{ + "id": "namespace/category/node-name", + "outputs": ["geometry"], + "inputs": { + "height": { "type": "float", "value": 1.0 }, + "radius": { "type": "float", "value": 0.1 } + } +} +``` + +For now the outputs are limited to a single output. + +### Node Execution + +Nodes receive serialized arguments and return serialized outputs. The `nodarium_utils` Rust crate provides helpers for: + +- Parsing input arguments +- Creating geometry data +- Concatenating output vectors + +### Node Registration + +Nodes are: + +1. Compiled to WASM files in `target/wasm32-unknown-unknown/release/` +2. Copied to `app/static/nodes/` for serving +3. Registered in the browser via IndexedDB using the registry package + +## Key Dependencies + +**Frontend:** + +- `@sveltejs/kit` - Application framework +- `@threlte/core` & `@threlte/extras` - Three.js Svelte integration +- `three` - 3D graphics library +- `tailwindcss` - CSS framework +- `comlink` - WebWorker RPC +- `idb` - IndexedDB wrapper +- `wabt` - WebAssembly binary toolkit + +**Rust/WASM:** + +- Language: Rust (compiled with plain cargo) +- Output: WebAssembly (wasm32-unknown-unknown target) +- Generic WASM wrapper for language-agnostic node development +- `glam` - Math library (Vec2, Vec3, Mat4, etc.) +- `nodarium_macros` - Custom procedural macros +- `nodarium_utils` - Shared node utilities + +## Build Commands + +From root directory: + +```bash +# Install dependencies +pnpm i + +# Build all WASM nodes (compiles Rust, copies to app/static) +pnpm build:nodes + +# Build the app (builds UI library + SvelteKit app) +pnpm build:app + +# Full build (nodes + app) +pnpm build + +# Development +pnpm dev # Run all dev commands in parallel +pnpm dev:nodes # Watch nodes/, auto-rebuild on changes +pnpm dev:app_ui # Watch app and UI package +pnpm dev_ui # Watch UI package only +``` + +## Workspace Packages + +The project uses pnpm workspaces with the following packages: + +| Package | Location | Purpose | +| ------------------ | ------------------ | ------------------------------ | +| @nodarium/app | app/ | Main SvelteKit application | +| @nodarium/ui | packages/ui/ | Reusable UI component library | +| @nodarium/registry | packages/registry/ | Node registry with persistence | +| @nodarium/types | packages/types/ | Shared TypeScript types | +| @nodarium/utils | packages/utils/ | Shared utilities | +| nodarium macros | packages/macros/ | Rust procedural macros | + +## Configuration Files + +- `.dprint.jsonc` - Dprint formatter configuration +- `svelte.config.js` - SvelteKit configuration (app and ui) +- `vite.config.ts` - Vite bundler configuration +- `tsconfig.json` - TypeScript configuration (app and packages) +- `Cargo.toml` - Rust workspace with member packages +- `flake.nix` - Nix development environment + +## Development Workflow + +### Adding a New Node + +1. Copy the `.template` directory in `nodes/max/plantarium/` to create a new node directory +2. Define node in `src/definition.json` +3. Implement logic in `src/lib.rs` +4. Build with `cargo build --release --target wasm32-unknown-unknown` +5. Test by dragging onto the node graph + +### Modifying UI Components + +1. Changes to `packages/ui/` automatically rebuild with watch mode +2. App imports from `@nodarium/ui` +3. Run `pnpm dev:app_ui` for hot reload + +## Important Notes for AI Agents + +1. **WASM Compilation**: Nodes require `wasm32-unknown-unknown` target (`rustup target add wasm32-unknown-unknown`) +2. **Cross-Compilation**: WASM build happens on host, not in containers/VMs +3. **Static Serving**: Compiled WASM files must exist in `app/static/nodes/` before dev server runs +4. **Workspace Dependencies**: Use `workspace:*` protocol for internal packages +5. **Threlte Version**: Uses Threlte 8.x, not 7.x (important for 3D component APIs) +6. **Svelte 5**: Project uses Svelte 5 with runes (`$state`, `$derived`, `$effect`) +7. **Tailwind 4**: Uses Tailwind CSS v4 with `@tailwindcss/vite` plugin +8. **IndexedDB**: Registry uses IDB for persistent node storage in browser diff --git a/SUMMARY_RUNTIME.md b/SUMMARY_RUNTIME.md new file mode 100644 index 0000000..bd8a13b --- /dev/null +++ b/SUMMARY_RUNTIME.md @@ -0,0 +1,294 @@ +# Node Compilation and Runtime Execution + +## Overview + +Nodarium nodes are WebAssembly modules written in Rust. Each node is a compiled WASM binary that exposes a standardized C ABI interface. The system uses procedural macros to generate the necessary boilerplate for node definitions, memory management, and execution. + +## Node Compilation + +### 1. Node Definition (JSON) + +Each node has a `src/input.json` file that defines: + +```json +{ + "id": "max/plantarium/stem", + "meta": { "description": "Creates a stem" }, + "outputs": ["path"], + "inputs": { + "origin": { "type": "vec3", "value": [0, 0, 0], "external": true }, + "amount": { "type": "integer", "value": 1, "min": 1, "max": 64 }, + "length": { "type": "float", "value": 5 }, + "thickness": { "type": "float", "value": 0.2 } + } +} +``` + +### 2. Procedural Macros + +The `nodarium_macros` crate provides two procedural macros: + +#### `#[nodarium_execute]` + +Transforms a Rust function into a WASM-compatible entry point: + +```rust +#[nodarium_execute] +pub fn execute(input: &[i32]) -> Vec { + // Node logic here +} +``` + +The macro generates: +- **C ABI wrapper**: Converts the WASM interface to a standard C FFI +- **`execute` function**: Takes `(ptr: *const i32, len: usize)` and returns `*mut i32` +- **Memory allocation**: `__alloc(len: usize) -> *mut i32` for buffer allocation +- **Memory deallocation**: `__free(ptr: *mut i32, len: usize)` for cleanup +- **Static output buffer**: `OUTPUT_BUFFER` for returning results +- **Panic hook**: Routes panics through `host_log_panic` for debugging +- **Internal logic wrapper**: Wraps the original function + +#### `nodarium_definition_file!("path")` + +Embeds the node definition JSON into the WASM binary: + +```rust +nodarium_definition_file!("src/input.json"); +``` + +Generates: +- **`DEFINITION_DATA`**: Static byte array in `nodarium_definition` section +- **`get_definition_ptr()`**: Returns pointer to definition data +- **`get_definition_len()`**: Returns length of definition data + +### 3. Build Process + +Nodes are compiled with: +```bash +cargo build --release --target wasm32-unknown-unknown +``` + +The resulting `.wasm` files are copied to `app/static/nodes/` for serving. + +## Node Execution Runtime + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ WebWorker Thread │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ WorkerRuntimeExecutor ││ +│ │ ┌───────────────────────────────────────────────────┐ ││ +│ │ │ MemoryRuntimeExecutor ││ +│ │ │ ┌─────────────────────────────────────────────┐ ││ +│ │ │ │ Node Registry (WASM + Definitions) ││ +│ │ │ └─────────────────────────────────────────────┘ ││ +│ │ │ ┌─────────────────────────────────────────────┐ ││ +│ │ │ │ Execution Engine (Bottom-Up Evaluation) ││ +│ │ │ └─────────────────────────────────────────────┘ ││ +│ │ └───────────────────────────────────────────────────┘ ││ +│ └─────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +### 1. MemoryRuntimeExecutor + +The core execution engine in `runtime-executor.ts`: + +#### Metadata Collection (`addMetaData`) + +1. Load node definitions from registry +2. Build parent/child relationships from graph edges +3. Calculate execution depth via reverse BFS from output node + +#### Node Sorting + +Nodes are sorted by depth (highest depth first) for bottom-up execution: + +``` +Depth 3: n3 n6 +Depth 2: n2 n4 n5 +Depth 1: n1 +Depth 0: Output +Execution order: n3, n6, n2, n4, n5, n1, Output +``` + +#### Input Collection + +For each node, inputs are gathered from: +1. **Connected nodes**: Results from parent nodes in the graph +2. **Node props**: Values stored directly on the node instance +3. **Settings**: Global settings mapped via `setting` property +4. **Defaults**: Values from node definition + +#### Input Encoding + +Values are encoded as `Int32Array`: +- **Floats**: IEEE 754 bits cast to i32 +- **Vectors**: `[0, count, v1, v2, v3, 1, 1]` (nested bracket format) +- **Booleans**: `0` or `1` +- **Integers**: Direct i32 value + +#### Caching + +Results are cached using: +```typescript +inputHash = `node-${node.id}-${fastHashArrayBuffer(encoded_inputs)}` +``` + +The cache uses LRU eviction (default size: 50 entries). + +### 2. Execution Flow + +```typescript +async execute(graph: Graph, settings) { + // 1. Load definitions and build node relationships + const [outputNode, nodes] = await this.addMetaData(graph); + + // 2. Sort nodes by depth (bottom-up) + const sortedNodes = nodes.sort((a, b) => b.depth - a.depth); + + // 3. Execute each node + for (const node of sortedNodes) { + const inputs = this.collectInputs(node, settings); + const encoded = concatEncodedArrays(inputs); + const result = nodeType.execute(encoded); + this.results[node.id] = result; + } + + // 4. Return output node result + return this.results[outputNode.id]; +} +``` + +### 3. Worker Isolation + +`WorkerRuntimeExecutor` runs execution in a WebWorker via Comlink: + +```typescript +class WorkerRuntimeExecutor implements RuntimeExecutor { + private worker = new ComlinkWorker(...); + + async execute(graph, settings) { + return this.worker.executeGraph(graph, settings); + } +} +``` + +The worker backend (`worker-runtime-executor-backend.ts`): +- Creates a single `MemoryRuntimeExecutor` instance +- Manages caching state +- Collects performance metrics + +### 4. Remote Execution (Optional) + +`RemoteRuntimeExecutor` can execute graphs on a remote server: + +```typescript +class RemoteRuntimeExecutor implements RuntimeExecutor { + async execute(graph, settings) { + const res = await fetch(this.url, { + method: "POST", + body: JSON.stringify({ graph, settings }) + }); + return new Int32Array(await res.arrayBuffer()); + } +} +``` + +## Data Encoding Format + +### Bracket Notation + +Inputs and outputs use a nested bracket encoding: + +``` +[0, count, item1, item2, ..., 1, 1] + ^ ^ items ^ ^ + | | | | + | | | +-- closing bracket + | +-- number of items + 1 | + +-- opening bracket (0) +-- closing bracket (1) +``` + +### Example Encodings + +**Float (5.0)**: +```typescript +encodeFloat(5.0) // → 1084227584 (IEEE 754 bits as i32) +``` + +**Vec3 ([1, 2, 3])**: +```typescript +[0, 4, encodeFloat(1), encodeFloat(2), encodeFloat(3), 1, 1] +``` + +**Nested Math Expression**: +``` +[0, 3, 0, 2, 0, 3, 0, 0, 0, 3, 7549747, 127, 1, 1, ...] +``` + +### Decoding Utilities + +From `packages/utils/src/tree.rs`: +- `split_args()`: Parses nested bracket arrays into segments +- `evaluate_float()`: Recursively evaluates and decodes float expressions +- `evaluate_int()`: Evaluates integer/math node expressions +- `evaluate_vec3()`: Decodes vec3 arrays + +## Geometry Data Format + +### Path Data + +Paths represent procedural plant structures: + +``` +[0, count, [0, header_size, node_type, depth, x, y, z, w, ...], 1, 1] +``` + +Each point has 4 values: x, y, z position + thickness (w). + +### Geometry Data + +Meshes use a similar format with vertices and face indices. + +## Performance Tracking + +The runtime collects detailed performance metrics: +- `collect-metadata`: Time to build node graph +- `collected-inputs`: Time to gather inputs +- `encoded-inputs`: Time to encode inputs +- `hash-inputs`: Time to compute cache hash +- `cache-hit`: 1 if cache hit, 0 if miss +- `node/{node_type}`: Time per node execution + +## Caching Strategy + +### MemoryRuntimeCache + +LRU cache implementation: +```typescript +class MemoryRuntimeCache { + private map = new Map(); + size: number = 50; + + get(key) { /* move to front */ } + set(key, value) { /* evict oldest if at capacity */ } +} +``` + +### IndexDBCache + +For persistence across sessions, the registry uses IndexedDB caching. + +## Summary + +The Nodarium node system works as follows: + +1. **Compilation**: Rust functions are decorated with macros that generate C ABI WASM exports +2. **Registration**: Node definitions are embedded in WASM and loaded at runtime +3. **Graph Analysis**: Runtime builds node relationships and execution order +4. **Bottom-Up Execution**: Nodes execute from leaves to output +5. **Caching**: Results are cached per-node-inputs hash for performance +6. **Isolation**: Execution runs in a WebWorker to prevent main thread blocking diff --git a/app/src/lib/runtime/runtime-executor.ts b/app/src/lib/runtime/runtime-executor.ts index 28faccc..ebe5336 100644 --- a/app/src/lib/runtime/runtime-executor.ts +++ b/app/src/lib/runtime/runtime-executor.ts @@ -1,3 +1,4 @@ +import { RemoteNodeRegistry } from '@nodarium/registry'; import type { Graph, NodeDefinition, @@ -7,10 +8,9 @@ import type { SyncCache } from '@nodarium/types'; import { - concatEncodedArrays, createLogger, + createWasmWrapper, encodeFloat, - fastHashArrayBuffer, type PerformanceStore } from '@nodarium/utils'; import type { RuntimeNode } from './types'; @@ -18,6 +18,8 @@ import type { RuntimeNode } from './types'; const log = createLogger('runtime-executor'); log.mute(); +const remoteRegistry = new RemoteNodeRegistry(''); + function getValue(input: NodeInput, value?: unknown) { if (value === undefined && 'value' in input) { value = input.value; @@ -52,19 +54,30 @@ function getValue(input: NodeInput, value?: unknown) { return value; } - console.error({ input, value }); throw new Error(`Unknown input type ${input.type}`); } -export class MemoryRuntimeExecutor implements RuntimeExecutor { - private definitionMap: Map = new Map(); +type Pointer = { + start: number; + end: number; +}; - private seed = Math.floor(Math.random() * 100000000); +export class MemoryRuntimeExecutor implements RuntimeExecutor { + private nodes: Map< + string, + { definition: NodeDefinition; execute: (outputPos: number, args: number[]) => number } + > = new Map(); + + private offset = 0; + private memory = new WebAssembly.Memory({ + initial: 1024, + maximum: 8192 + }); + + seed = 123123; perf?: PerformanceStore; - private results: Record = {}; - constructor( private registry: NodeRegistry, public cache?: SyncCache @@ -79,12 +92,20 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor { await this.registry.load(graph.nodes.map((node) => node.type)); - const typeMap = new Map(); + const typeMap = new Map number; + }>(); for (const node of graph.nodes) { if (!typeMap.has(node.type)) { const type = this.registry.getNode(node.type); + const buffer = await remoteRegistry.fetchArrayBuffer('nodes/' + node.type + '.wasm'); + const wrapper = createWasmWrapper(buffer, this.memory); if (type) { - typeMap.set(node.type, type); + typeMap.set(node.type, { + definition: type, + execute: wrapper.execute + }); } } } @@ -93,7 +114,7 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor { private async addMetaData(graph: Graph) { // First, lets check if all nodes have a definition - this.definitionMap = await this.getNodeDefinitions(graph); + this.nodes = await this.getNodeDefinitions(graph); const graphNodes = graph.nodes.map(node => { const n = node as RuntimeNode; @@ -145,16 +166,33 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor { return [outputNode, nodes] as const; } - async execute(graph: Graph, settings: Record) { - this.perf?.addPoint('runtime'); + private writeToMemory(v: number | number[] | Int32Array) { + let length = 1; + const view = new Int32Array(this.memory.buffer); + if (typeof v === 'number') { + view[this.offset] = v; + length = 1; + } else { + view.set(v, this.offset); + length = v.length; + } - let a = performance.now(); + const start = this.offset; + const end = this.offset + length; + + this.offset += length; + + return { + start, + end + }; + } + + async execute(graph: Graph, _settings: Record) { + this.offset = 0; // Then we add some metadata to the graph const [outputNode, nodes] = await this.addMetaData(graph); - let b = performance.now(); - - this.perf?.addPoint('collect-metadata', b - a); /* * Here we sort the nodes into buckets, which we then execute one by one @@ -173,113 +211,81 @@ export class MemoryRuntimeExecutor implements RuntimeExecutor { ); // here we store the intermediate results of the nodes - this.results = {}; - - if (settings['randomSeed']) { - this.seed = Math.floor(Math.random() * 100000000); - } + const results: Record = {}; for (const node of sortedNodes) { - const node_type = this.definitionMap.get(node.type)!; + const node_type = this.nodes.get(node.type)!; + + console.log('EXECUTING NODE', node_type.definition.id); + console.log(node_type.definition.inputs); + const inputs = Object.entries(node_type.definition.inputs || {}).map( + ([key, input]) => { + // We should probably initially write this to memory + if (input.type === 'seed') { + return this.writeToMemory(this.seed); + } + + // We should probably initially write this to memory + // If the input is linked to a setting, we use that value + // if (input.setting) { + // return getValue(input, settings[input.setting]); + // } + + // check if the input is connected to another node + const inputNode = node.state.inputNodes[key]; + if (inputNode) { + if (results[inputNode.id] === undefined) { + throw new Error( + `Node ${node.type} is missing input from node ${inputNode.type}` + ); + } + return results[inputNode.id]; + } + + // If the value is stored in the node itself, we use that value + if (node.props?.[key] !== undefined) { + return this.writeToMemory(getValue(input, node.props[key])); + } + + return this.writeToMemory(getValue(input)); + } + ); if (!node_type || !node.state || !node_type.execute) { log.warn(`Node ${node.id} has no definition`); continue; } - a = performance.now(); - - // Collect the inputs for the node - const inputs = Object.entries(node_type.inputs || {}).map( - ([key, input]) => { - if (input.type === 'seed') { - return this.seed; - } - - // If the input is linked to a setting, we use that value - if (input.setting) { - return getValue(input, settings[input.setting]); - } - - // check if the input is connected to another node - const inputNode = node.state.inputNodes[key]; - if (inputNode) { - if (this.results[inputNode.id] === undefined) { - throw new Error( - `Node ${node.type} is missing input from node ${inputNode.type}` - ); - } - return this.results[inputNode.id]; - } - - // If the value is stored in the node itself, we use that value - if (node.props?.[key] !== undefined) { - return getValue(input, node.props[key]); - } - - return getValue(input); - } - ); - b = performance.now(); - - this.perf?.addPoint('collected-inputs', b - a); + const args = inputs.map(s => [s.start, s.end]).flat(); + console.log('ARGS', args); try { - a = performance.now(); - const encoded_inputs = concatEncodedArrays(inputs); - b = performance.now(); - this.perf?.addPoint('encoded-inputs', b - a); - - a = performance.now(); - let inputHash = `node-${node.id}-${fastHashArrayBuffer(encoded_inputs)}`; - b = performance.now(); - this.perf?.addPoint('hash-inputs', b - a); - - let cachedValue = this.cache?.get(inputHash); - if (cachedValue !== undefined) { - log.log(`Using cached value for ${node_type.id || node.id}`); - this.perf?.addPoint('cache-hit', 1); - this.results[node.id] = cachedValue as Int32Array; - continue; - } - this.perf?.addPoint('cache-hit', 0); - - log.group(`executing ${node_type.id}-${node.id}`); - log.log(`Inputs:`, inputs); - a = performance.now(); - this.results[node.id] = node_type.execute(encoded_inputs); - log.log('Executed', node.type, node.id); - b = performance.now(); - - if (this.cache && node.id !== outputNode.id) { - this.cache.set(inputHash, this.results[node.id]); - } - - this.perf?.addPoint('node/' + node_type.id, b - a); - log.log('Result:', this.results[node.id]); - log.groupEnd(); + const bytesWritten = node_type.execute(this.offset, args); + results[node.id] = { + start: this.offset, + end: this.offset + bytesWritten + }; + this.offset += bytesWritten; + console.log('FINISHED EXECUTION', { + bytesWritten, + offset: this.offset + }); } catch (e) { - log.groupEnd(); - log.error(`Error executing node ${node_type.id || node.id}`, e); + console.error(e); } } - // return the result of the parent of the output node - const res = this.results[outputNode.id]; + const mem = new Int32Array(this.memory.buffer); + console.log('OUT', mem.slice(0, 10)); - if (this.cache) { - this.cache.size = sortedNodes.length * 2; - } + // return the result of the parent of the output node + const res = results[outputNode.id]; this.perf?.endPoint('runtime'); return res as unknown as Int32Array; } - getIntermediateResults() { - return this.results; - } - getPerformanceData() { return this.perf?.get(); } diff --git a/docs/DEVELOPING_NODES.md b/docs/DEVELOPING_NODES.md index 3376151..65a597c 100644 --- a/docs/DEVELOPING_NODES.md +++ b/docs/DEVELOPING_NODES.md @@ -4,20 +4,19 @@ This guide will help you developing your first Nodarium Node written in Rust. As ## Prerequesites -You need to have [Rust](https://www.rust-lang.org/tools/install) and [wasm-pack](https://rustwasm.github.io/docs/wasm-pack/) installed. Rust is the language we are going to develop our node in and wasm-pack helps us compile our rust code into a webassembly file. +You need to have [Rust](https://www.rust-lang.org/tools/install) installed. Rust is the language we are going to develop our node in and cargo compiles our rust code into webassembly. ```bash # install rust curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -# install wasm-pack -cargo install wasm-pack ``` ## Clone Template ```bash -wasm-pack new my-new-node --template https://github.com/jim-fx/nodarium_template -cd my-new-node +# copy the template directory +cp -r nodes/max/plantarium/.template nodes/max/plantarium/my-new-node +cd nodes/max/plantarium/my-new-node ``` ## Setup Definition diff --git a/nodes/max/plantarium/float/Cargo.toml b/nodes/max/plantarium/float/Cargo.toml index 4c570f6..7b24405 100644 --- a/nodes/max/plantarium/float/Cargo.toml +++ b/nodes/max/plantarium/float/Cargo.toml @@ -8,5 +8,5 @@ edition = "2018" crate-type = ["cdylib", "rlib"] [dependencies] -nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" } nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" } +nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" } diff --git a/nodes/max/plantarium/float/src/lib.rs b/nodes/max/plantarium/float/src/lib.rs index 5d3a67f..6872713 100644 --- a/nodes/max/plantarium/float/src/lib.rs +++ b/nodes/max/plantarium/float/src/lib.rs @@ -1,9 +1,11 @@ use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_execute; +use nodarium_utils::log; nodarium_definition_file!("src/input.json"); #[nodarium_execute] -pub fn execute(args: &[i32]) -> Vec { - args.into() +pub fn execute(_value: *const i32) -> Vec { + log!("Duuuude"); + vec![32] } diff --git a/nodes/max/plantarium/math/Cargo.toml b/nodes/max/plantarium/math/Cargo.toml index 7424c08..9998837 100644 --- a/nodes/max/plantarium/math/Cargo.toml +++ b/nodes/max/plantarium/math/Cargo.toml @@ -8,5 +8,5 @@ edition = "2018" crate-type = ["cdylib", "rlib"] [dependencies] -nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" } nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" } +nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" } diff --git a/nodes/max/plantarium/math/src/lib.rs b/nodes/max/plantarium/math/src/lib.rs index 09b36ac..27bd4ce 100644 --- a/nodes/max/plantarium/math/src/lib.rs +++ b/nodes/max/plantarium/math/src/lib.rs @@ -1,13 +1,24 @@ use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_execute; -use nodarium_utils::{ - concat_args, split_args -}; - -#[nodarium_execute] -pub fn execute(args: &[i32]) -> Vec { - let args = split_args(args); - concat_args(vec![&[0], args[0], args[1], args[2]]) -} +use nodarium_utils::{read_f32, read_i32, log}; nodarium_definition_file!("src/input.json"); + +#[nodarium_execute] +pub fn execute(op_type: *const i32, a: *const i32, b: *const i32) -> Vec { + let op = unsafe { read_i32(op_type) }; + let a_val = unsafe { read_f32(a) }; + let b_val = unsafe { read_f32(b) }; + + log!("op_type: {:?}", op); + + let result = match op { + 0 => a_val + b_val, + 1 => a_val - b_val, + 2 => a_val * b_val, + 3 => a_val / b_val, + _ => 0.0, + }; + + vec![result.to_bits() as i32] +} diff --git a/nodes/max/plantarium/output/Cargo.toml b/nodes/max/plantarium/output/Cargo.toml index 010948f..7b84464 100644 --- a/nodes/max/plantarium/output/Cargo.toml +++ b/nodes/max/plantarium/output/Cargo.toml @@ -8,5 +8,5 @@ edition = "2018" crate-type = ["cdylib", "rlib"] [dependencies] -nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" } nodarium_macros = { version = "0.1.0", path = "../../../../packages/macros" } +nodarium_utils = { version = "0.1.0", path = "../../../../packages/utils" } diff --git a/nodes/max/plantarium/output/src/inputs.json b/nodes/max/plantarium/output/src/input.json similarity index 100% rename from nodes/max/plantarium/output/src/inputs.json rename to nodes/max/plantarium/output/src/input.json diff --git a/nodes/max/plantarium/output/src/lib.rs b/nodes/max/plantarium/output/src/lib.rs index e05828d..90d1cd6 100644 --- a/nodes/max/plantarium/output/src/lib.rs +++ b/nodes/max/plantarium/output/src/lib.rs @@ -1,44 +1,9 @@ use nodarium_macros::nodarium_definition_file; use nodarium_macros::nodarium_execute; -use nodarium_utils::{ - concat_args, evaluate_int, - geometry::{extrude_path, wrap_path}, - log, split_args, -}; -nodarium_definition_file!("src/inputs.json"); +nodarium_definition_file!("src/input.json"); #[nodarium_execute] -pub fn execute(input: &[i32]) -> Vec { - log!("WASM(output): input: {:?}", input); - - let args = split_args(input); - - log!("WASM(output) args: {:?}", args); - - assert_eq!(args.len(), 2, "Expected 2 arguments, got {}", args.len()); - let inputs = split_args(args[0]); - - let resolution = evaluate_int(args[1]) as usize; - - log!("inputs: {}, resolution: {}", inputs.len(), resolution); - - let mut output: Vec> = Vec::new(); - for arg in inputs { - let arg_type = arg[2]; - log!("arg_type: {}, \n {:?}", arg_type, arg,); - - if arg_type == 0 { - // if this is path we need to extrude it - output.push(arg.to_vec()); - let path_data = wrap_path(arg); - let geometry = extrude_path(path_data, resolution); - output.push(geometry); - continue; - } - - output.push(arg.to_vec()); - } - - concat_args(output.iter().map(|v| v.as_slice()).collect()) +pub fn execute(_input: *const i32, _res: *const i32) -> Vec { + return vec![0]; } diff --git a/package.json b/package.json index 1495c77..115c7dc 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "build:story": "pnpm -r --filter 'ui' story:build", "build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app' build", "build:nodes": "cargo build --workspace --target wasm32-unknown-unknown --release && rm -rf ./app/static/nodes/max/plantarium/ && mkdir -p ./app/static/nodes/max/plantarium/ && cp -R ./target/wasm32-unknown-unknown/release/*.wasm ./app/static/nodes/max/plantarium/", + "build:node": "cargo build --package float --package output --package math --package nodarium_macros --package nodarium_utils --target wasm32-unknown-unknown --release && rm -rf ./app/static/nodes/max/plantarium/ && mkdir -p ./app/static/nodes/max/plantarium/ && cp -R ./target/wasm32-unknown-unknown/release/*.wasm ./app/static/nodes/max/plantarium/", "build:deploy": "pnpm build", "dev:nodes": "chokidar './nodes/**' --initial -i '/pkg/' -c 'pnpm build:nodes'", "dev:app_ui": "pnpm -r --parallel --filter 'app' --filter './packages/ui' dev", diff --git a/packages/macros/src/lib.rs b/packages/macros/src/lib.rs index fb4ef10..385c63d 100644 --- a/packages/macros/src/lib.rs +++ b/packages/macros/src/lib.rs @@ -6,6 +6,7 @@ use std::env; use std::fs; use std::path::Path; use syn::parse_macro_input; +use syn::spanned::Spanned; fn add_line_numbers(input: String) -> String { return input @@ -16,86 +17,145 @@ fn add_line_numbers(input: String) -> String { .join("\n"); } +fn read_node_definition(file_path: &Path) -> NodeDefinition { + let project_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let full_path = Path::new(&project_dir).join(file_path); + let json_content = fs::read_to_string(&full_path).unwrap_or_else(|err| { + panic!( + "Failed to read JSON file at '{}/{}': {}", + project_dir, + file_path.to_string_lossy(), + err + ) + }); + serde_json::from_str(&json_content).unwrap_or_else(|err| { + panic!( + "JSON file contains invalid JSON: \n{} \n{}", + err, + add_line_numbers(json_content.clone()) + ) + }) +} + #[proc_macro_attribute] pub fn nodarium_execute(_attr: TokenStream, item: TokenStream) -> TokenStream { let input_fn = parse_macro_input!(item as syn::ItemFn); - let _fn_name = &input_fn.sig.ident; - let _fn_vis = &input_fn.vis; + let fn_name = &input_fn.sig.ident; + let fn_vis = &input_fn.vis; let fn_body = &input_fn.block; + let inner_fn_name = syn::Ident::new(&format!("__nodarium_inner_{}", fn_name), fn_name.span()); - let first_arg_ident = if let Some(syn::FnArg::Typed(pat_type)) = input_fn.sig.inputs.first() { - if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { - &pat_ident.ident - } else { - panic!("Expected a simple identifier for the first argument"); - } - } else { - panic!("The execute function must have at least one argument (the input slice)"); - }; + let def: NodeDefinition = read_node_definition(Path::new("src/input.json")); + + let input_count = def.inputs.as_ref().map(|i| i.len()).unwrap_or(0); + + validate_signature(&input_fn.sig, input_count, &def); + + let input_param_names: Vec<_> = input_fn + .sig + .inputs + .iter() + .filter_map(|arg| { + if let syn::FnArg::Typed(pat_type) = arg { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + Some(pat_ident.ident.clone()) + } else { + None + } + } else { + None + } + }) + .collect(); + + let arg_names: Vec<_> = (0..input_count) + .map(|i| syn::Ident::new(&format!("arg{}", i), input_fn.sig.span())) + .collect(); - // We create a wrapper that handles the C ABI and pointer math let expanded = quote! { extern "C" { - fn host_log_panic(ptr: *const u8, len: usize); - fn host_log(ptr: *const u8, len: usize); + fn __nodarium_log(ptr: *const u8, len: usize); + fn __nodarium_log_panic(ptr: *const u8, len: usize); } - fn setup_panic_hook() { - static SET_HOOK: std::sync::Once = std::sync::Once::new(); - SET_HOOK.call_once(|| { + #fn_vis fn #inner_fn_name(#( #input_param_names: *const i32 ),*) -> Vec { + #fn_body + } + + #[no_mangle] + #fn_vis extern "C" fn execute(output_pos: i32, #( #arg_names: i32 ),*) -> i32 { + static PANIC_HOOK_SET: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + + if !PANIC_HOOK_SET.load(std::sync::atomic::Ordering::SeqCst) { std::panic::set_hook(Box::new(|info| { let msg = info.to_string(); - unsafe { host_log_panic(msg.as_ptr(), msg.len()); } + unsafe { __nodarium_log_panic(msg.as_ptr(), msg.len()); } })); - }); - } - - #[no_mangle] - pub extern "C" fn __alloc(len: usize) -> *mut i32 { - let mut buf = Vec::with_capacity(len); - let ptr = buf.as_mut_ptr(); - std::mem::forget(buf); - ptr - } - - #[no_mangle] - pub extern "C" fn __free(ptr: *mut i32, len: usize) { - unsafe { - let _ = Vec::from_raw_parts(ptr, 0, len); + PANIC_HOOK_SET.store(true, std::sync::atomic::Ordering::SeqCst); } - } - static mut OUTPUT_BUFFER: Vec = Vec::new(); + let result = #inner_fn_name( + #( #arg_names as *const i32 ),* + ); - #[no_mangle] - pub extern "C" fn execute(ptr: *const i32, len: usize) -> *mut i32 { - setup_panic_hook(); - // 1. Convert raw pointer to slice - let input = unsafe { core::slice::from_raw_parts(ptr, len) }; - - // 2. Call the logic (which we define below) - let result_data: Vec = internal_logic(input); - - // 3. Use the static buffer for the result - let result_len = result_data.len(); + let len_bytes = result.len() * 4; unsafe { - OUTPUT_BUFFER.clear(); - OUTPUT_BUFFER.reserve(result_len + 1); - OUTPUT_BUFFER.push(result_len as i32); - OUTPUT_BUFFER.extend(result_data); - - OUTPUT_BUFFER.as_mut_ptr() + let src = result.as_ptr() as *const u8; + let dst = output_pos as *mut u8; + dst.copy_from_nonoverlapping(src, len_bytes); } - } - fn internal_logic(#first_arg_ident: &[i32]) -> Vec { - #fn_body + core::mem::forget(result); + + len_bytes as i32 } }; TokenStream::from(expanded) } +fn validate_signature(fn_sig: &syn::Signature, expected_inputs: usize, def: &NodeDefinition) { + let param_count = fn_sig.inputs.len(); + if param_count != expected_inputs { + panic!( + "Execute function has {} parameters but definition has {} inputs\n\ + Definition inputs: {:?}\n\ + Expected signature:\n\ + pub fn execute({}) -> Vec", + param_count, + expected_inputs, + def.inputs + .as_ref() + .map(|i| i.keys().collect::>()) + .unwrap_or_default(), + (0..expected_inputs) + .map(|i| format!("arg{}: *const i32", i)) + .collect::>() + .join(", ") + ); + } + + match &fn_sig.output { + syn::ReturnType::Type(_, ty) => { + let is_vec = match &**ty { + syn::Type::Path(tp) => tp + .path + .segments + .first() + .map(|seg| seg.ident == "Vec") + .unwrap_or(false), + _ => false, + }; + if !is_vec { + panic!("Execute function must return Vec"); + } + } + syn::ReturnType::Default => { + panic!("Execute function must return Vec"); + } + } +} + #[proc_macro] pub fn nodarium_definition_file(input: TokenStream) -> TokenStream { let path_lit = syn::parse_macro_input!(input as syn::LitStr); @@ -105,30 +165,26 @@ pub fn nodarium_definition_file(input: TokenStream) -> TokenStream { let full_path = Path::new(&project_dir).join(&file_path); let json_content = fs::read_to_string(&full_path).unwrap_or_else(|err| { - panic!("Failed to read JSON file at '{}/{}': {}", project_dir, file_path, err) + panic!( + "Failed to read JSON file at '{}/{}': {}", + project_dir, file_path, err + ) }); let _: NodeDefinition = serde_json::from_str(&json_content).unwrap_or_else(|err| { - panic!("JSON file contains invalid JSON: \n{} \n{}", err, add_line_numbers(json_content.clone())) + panic!( + "JSON file contains invalid JSON: \n{} \n{}", + err, + add_line_numbers(json_content.clone()) + ) }); - // We use the span from the input path literal let bytes = syn::LitByteStr::new(json_content.as_bytes(), path_lit.span()); let len = json_content.len(); let expanded = quote! { #[link_section = "nodarium_definition"] static DEFINITION_DATA: [u8; #len] = *#bytes; - - #[no_mangle] - pub extern "C" fn get_definition_ptr() -> *const u8 { - DEFINITION_DATA.as_ptr() - } - - #[no_mangle] - pub extern "C" fn get_definition_len() -> usize { - DEFINITION_DATA.len() - } }; TokenStream::from(expanded) diff --git a/packages/registry/src/node-registry-client.ts b/packages/registry/src/node-registry-client.ts index 10ad6a4..21fae37 100644 --- a/packages/registry/src/node-registry-client.ts +++ b/packages/registry/src/node-registry-client.ts @@ -13,6 +13,7 @@ log.mute(); export class RemoteNodeRegistry implements NodeRegistry { status: 'loading' | 'ready' | 'error' = 'loading'; private nodes: Map = new Map(); + private memory = new WebAssembly.Memory({ initial: 1024, maximum: 8192 }); constructor( private url: string, @@ -127,7 +128,10 @@ export class RemoteNodeRegistry implements NodeRegistry { } async register(wasmBuffer: ArrayBuffer) { - const wrapper = createWasmWrapper(wasmBuffer); + const wrapper = createWasmWrapper( + wasmBuffer, + this.memory + ); const definition = NodeDefinitionSchema.safeParse(wrapper.get_definition()); @@ -139,10 +143,7 @@ export class RemoteNodeRegistry implements NodeRegistry { this.cache.set(definition.data.id, wasmBuffer); } - let node = { - ...definition.data, - execute: wrapper.execute - }; + let node = { ...definition.data, execute: wrapper.execute }; this.nodes.set(definition.data.id, node); diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 2a9167b..0203104 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -65,7 +65,7 @@ export const NodeSchema = z.object({ export type SerializedNode = z.infer; export type NodeDefinition = z.infer & { - execute(input: Int32Array): Int32Array; + execute(outputPos: number, args: number[]): number; }; export type Socket = { diff --git a/packages/utils/Cargo.toml b/packages/utils/Cargo.toml index 4e81b1e..a2af226 100644 --- a/packages/utils/Cargo.toml +++ b/packages/utils/Cargo.toml @@ -9,6 +9,10 @@ description = "A collection of utilities for Nodarium" [lib] crate-type = ["rlib"] +[features] +default = ["std"] +std = [] + [dependencies] glam = "0.30.10" noise = "0.9.0" diff --git a/packages/utils/src/encoding.rs b/packages/utils/src/encoding.rs index 98ba5c3..4b7f7c9 100644 --- a/packages/utils/src/encoding.rs +++ b/packages/utils/src/encoding.rs @@ -10,6 +10,55 @@ pub fn decode_float(bits: i32) -> f32 { f32::from_bits(bits) } +#[inline] +pub unsafe fn read_i32(ptr: *const i32) -> i32 { + *ptr +} + +#[inline] +pub unsafe fn read_f32(ptr: *const i32) -> f32 { + f32::from_bits(*ptr as u32) +} + +#[inline] +pub unsafe fn read_bool(ptr: *const i32) -> bool { + *ptr != 0 +} + +#[inline] +pub unsafe fn read_vec3(ptr: *const i32) -> [f32; 3] { + let p = ptr as *const f32; + [p.read(), p.add(1).read(), p.add(2).read()] +} + +#[inline] +pub unsafe fn read_i32_slice(ptr: *const i32, len: usize) -> Vec { + std::slice::from_raw_parts(ptr, len).to_vec() +} + +#[inline] +pub unsafe fn read_f32_slice(ptr: *const i32, len: usize) -> Vec { + std::slice::from_raw_parts(ptr as *const f32, len).to_vec() +} + +#[inline] +pub unsafe fn read_f32_default(ptr: *const i32, default: f32) -> f32 { + if ptr.is_null() { + default + } else { + read_f32(ptr) + } +} + +#[inline] +pub unsafe fn read_i32_default(ptr: *const i32, default: i32) -> i32 { + if ptr.is_null() { + default + } else { + read_i32(ptr) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/utils/src/lib.rs b/packages/utils/src/lib.rs index 5809ecb..3fb32f7 100644 --- a/packages/utils/src/lib.rs +++ b/packages/utils/src/lib.rs @@ -8,30 +8,30 @@ pub mod geometry; extern "C" { #[cfg(target_arch = "wasm32")] - pub fn host_log(ptr: *const u8, len: usize); + pub fn __nodarium_log(ptr: *const u8, len: usize); } -#[cfg(debug_assertions)] +// #[cfg(debug_assertions)] #[macro_export] macro_rules! log { ($($t:tt)*) => {{ let msg = std::format!($($t)*); #[cfg(target_arch = "wasm32")] unsafe { - $crate::host_log(msg.as_ptr(), msg.len()); + $crate::__nodarium_log(msg.as_ptr(), msg.len()); } #[cfg(not(target_arch = "wasm32"))] println!("{}", msg); }} } -#[cfg(not(debug_assertions))] -#[macro_export] -macro_rules! log { - ($($arg:tt)*) => {{ - // This will expand to nothing in release builds - }}; -} +// #[cfg(not(debug_assertions))] +// #[macro_export] +// macro_rules! log { +// ($($arg:tt)*) => {{ +// // This will expand to nothing in release builds +// }}; +// } #[allow(dead_code)] #[rustfmt::skip] diff --git a/packages/utils/src/wasm-wrapper.ts b/packages/utils/src/wasm-wrapper.ts index 6327d73..0a56ea8 100644 --- a/packages/utils/src/wasm-wrapper.ts +++ b/packages/utils/src/wasm-wrapper.ts @@ -1,24 +1,23 @@ interface NodariumExports extends WebAssembly.Exports { memory: WebAssembly.Memory; - execute: (ptr: number, len: number) => number; - __free: (ptr: number, len: number) => void; - __alloc: (len: number) => number; + execute: (outputPos: number, ...args: number[]) => number; } -export function createWasmWrapper(buffer: ArrayBuffer) { +export function createWasmWrapper(buffer: ArrayBuffer, memory: WebAssembly.Memory) { let exports: NodariumExports; const importObject = { env: { - host_log_panic: (ptr: number, len: number) => { + memory: memory, + __nodarium_log_panic: (ptr: number, len: number) => { if (!exports) return; - const view = new Uint8Array(exports.memory.buffer, ptr, len); - console.error("RUST PANIC:", new TextDecoder().decode(view)); + const view = new Uint8Array(memory.buffer, ptr, len); + console.error('WASM PANIC:', new TextDecoder().decode(view)); }, - host_log: (ptr: number, len: number) => { + __nodarium_log: (ptr: number, len: number) => { if (!exports) return; - const view = new Uint8Array(exports.memory.buffer, ptr, len); - console.log("RUST:", new TextDecoder().decode(view)); + const view = new Uint8Array(memory.buffer, ptr, len); + console.log('WASM:', new TextDecoder().decode(view)); } } }; @@ -27,23 +26,13 @@ export function createWasmWrapper(buffer: ArrayBuffer) { const instance = new WebAssembly.Instance(module, importObject); exports = instance.exports as NodariumExports; - function execute(args: Int32Array) { - const inPtr = exports.__alloc(args.length); - new Int32Array(exports.memory.buffer).set(args, inPtr / 4); - - const outPtr = exports.execute(inPtr, args.length); - - const i32Result = new Int32Array(exports.memory.buffer); - const outLen = i32Result[outPtr / 4]; - const out = i32Result.slice(outPtr / 4 + 1, outPtr / 4 + 1 + outLen); - - exports.__free(inPtr, args.length); - - return out; + function execute(outputPos: number, args: number[]): number { + console.log('WASM_WRAPPER', { outputPos, args }); + return exports.execute(outputPos, ...args); } function get_definition() { - const sections = WebAssembly.Module.customSections(module, "nodarium_definition"); + const sections = WebAssembly.Module.customSections(module, 'nodarium_definition'); if (sections.length > 0) { const decoder = new TextDecoder(); const jsonString = decoder.decode(sections[0]);