notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 4ebcbedb49541c59f3e947dbe7b23e446ffd4c16
parent 8a454b8e63aa90ecd69e43264ec304ac735083db
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 26 Feb 2026 10:06:07 -0800

nostrverse: add tilemap support with texture atlas rendering

Add tilemap as a first-class protoverse cell type with compact
s-expression format: (tilemap (width N) (height N) (tileset "name" ...)
(data "indices")). Tiles are 1 world unit, matching the existing grid.

Protoverse: CellType::Tilemap, Attribute::Tileset/Data with parser,
serializer, and roundtrip tests. Renderbud: add insert_model() and
create_material() for procedural geometry, switch all index buffers
from u16 to u32 removing the vertex count limitation. Nostrverse:
TilemapData on SpaceInfo (not a separate field), SpaceData struct
replacing the fragile 3-tuple return from convert_space, and
tilemap mesh generation via texture atlas with nearest filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
MCargo.lock | 1+
Mcrates/notedeck_nostrverse/Cargo.toml | 1+
Mcrates/notedeck_nostrverse/src/convert.rs | 236++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/notedeck_nostrverse/src/lib.rs | 54+++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/notedeck_nostrverse/src/room_state.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck_nostrverse/src/room_view.rs | 4++++
Acrates/notedeck_nostrverse/src/tilemap.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protoverse/src/ast.rs | 20++++++++++++++++++++
Mcrates/protoverse/src/describe.rs | 1+
Mcrates/protoverse/src/parser.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protoverse/src/serializer.rs | 25+++++++++++++++++++++++++
Mcrates/renderbud/src/lib.rs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/renderbud/src/model.rs | 11+++++------
13 files changed, 651 insertions(+), 76 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4722,6 +4722,7 @@ dependencies = [ name = "notedeck_nostrverse" version = "0.7.1" dependencies = [ + "bytemuck", "egui", "egui-wgpu", "ehttp", diff --git a/crates/notedeck_nostrverse/Cargo.toml b/crates/notedeck_nostrverse/Cargo.toml @@ -17,3 +17,4 @@ uuid = { workspace = true } ehttp = { workspace = true } sha2 = { workspace = true } poll-promise = { workspace = true } +bytemuck = { workspace = true } diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs @@ -1,20 +1,27 @@ //! Convert protoverse Space AST to renderer space state. -use crate::room_state::{ObjectLocation, RoomObject, RoomObjectType, SpaceInfo}; +use crate::room_state::{ + ObjectLocation, RoomObject, RoomObjectType, SpaceData, SpaceInfo, TilemapData, +}; use glam::{Quat, Vec3}; use protoverse::{Attribute, Cell, CellId, CellType, Location, ObjectType, Space}; -/// Convert a parsed protoverse Space into a SpaceInfo and its objects. -pub fn convert_space(space: &Space) -> (SpaceInfo, Vec<RoomObject>) { - let info = extract_space_info(space, space.root); +/// Convert a parsed protoverse Space into a SpaceData (info + objects). +pub fn convert_space(space: &Space) -> SpaceData { + let mut info = extract_space_info(space, space.root); let mut objects = Vec::new(); - collect_objects(space, space.root, &mut objects); - (info, objects) + let mut tilemap = None; + collect_objects(space, space.root, &mut objects, &mut tilemap); + info.tilemap = tilemap; + SpaceData { info, objects } } fn extract_space_info(space: &Space, id: CellId) -> SpaceInfo { let name = space.name(id).unwrap_or("Untitled Space").to_string(); - SpaceInfo { name } + SpaceInfo { + name, + tilemap: None, + } } fn location_from_protoverse(loc: &Location) -> ObjectLocation { @@ -50,10 +57,32 @@ fn object_type_from_cell(obj_type: &ObjectType) -> RoomObjectType { } } -fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) { +fn collect_objects( + space: &Space, + id: CellId, + objects: &mut Vec<RoomObject>, + tilemap: &mut Option<TilemapData>, +) { let cell = space.cell(id); - if let CellType::Object(ref obj_type) = cell.cell_type { + if cell.cell_type == CellType::Tilemap { + let width = space.width(id).unwrap_or(10.0) as u32; + let height = space.height(id).unwrap_or(10.0) as u32; + let tileset = space + .tileset(id) + .cloned() + .unwrap_or_else(|| vec!["grass".to_string()]); + let data_str = space.data(id).unwrap_or("0"); + let tiles = TilemapData::decode_data(data_str); + *tilemap = Some(TilemapData { + width, + height, + tileset, + tiles, + scene_object_id: None, + model_handle: None, + }); + } else if let CellType::Object(ref obj_type) = cell.cell_type { let obj_id = space.id_str(id).unwrap_or("").to_string(); // Generate a fallback id if none specified @@ -101,14 +130,15 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) { // Recurse into children for &child_id in space.children(id) { - collect_objects(space, child_id, objects); + collect_objects(space, child_id, objects, tilemap); } } -/// Build a protoverse Space from SpaceInfo and objects (reverse of convert_space). +/// Build a protoverse Space from SpaceInfo and objects. /// -/// Produces: (space (name ...) (group <objects...>)) +/// Produces: (space (name ...) (group [tilemap] <objects...>)) pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space { + let tilemap = info.tilemap.as_ref(); let mut cells = Vec::new(); let mut attributes = Vec::new(); let mut child_ids = Vec::new(); @@ -130,21 +160,31 @@ pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space { parent: None, }); - // Group cell (index 1), children = objects at indices 2.. + // Group cell (index 1), children start at index 2 + let tilemap_offset: u32 = if tilemap.is_some() { 1 } else { 0 }; let group_child_start = child_ids.len() as u32; + if tilemap.is_some() { + child_ids.push(CellId(2)); + } for i in 0..objects.len() { - child_ids.push(CellId(2 + i as u32)); + child_ids.push(CellId(2 + tilemap_offset + i as u32)); } + let total_children = tilemap_offset as u16 + objects.len() as u16; cells.push(Cell { cell_type: CellType::Group, first_attr: attributes.len() as u32, attr_count: 0, first_child: group_child_start, - child_count: objects.len() as u16, + child_count: total_children, parent: Some(CellId(0)), }); - // Object cells (indices 2..) + // Tilemap cell (index 2, if present) + if let Some(tm) = tilemap { + build_tilemap_cell(tm, &mut cells, &mut attributes, &child_ids); + } + + // Object cells for obj in objects { build_object_cell(obj, &mut cells, &mut attributes, &child_ids); } @@ -157,6 +197,28 @@ pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space { } } +fn build_tilemap_cell( + tm: &TilemapData, + cells: &mut Vec<Cell>, + attributes: &mut Vec<Attribute>, + child_ids: &[CellId], +) { + let attr_start = attributes.len() as u32; + attributes.push(Attribute::Width(tm.width as f64)); + attributes.push(Attribute::Height(tm.height as f64)); + attributes.push(Attribute::Tileset(tm.tileset.clone())); + attributes.push(Attribute::Data(tm.encode_data())); + + cells.push(Cell { + cell_type: CellType::Tilemap, + first_attr: attr_start, + attr_count: (attributes.len() as u32 - attr_start) as u16, + first_child: child_ids.len() as u32, + child_count: 0, + parent: Some(CellId(1)), + }); +} + fn object_type_to_cell(obj_type: &RoomObjectType) -> CellType { CellType::Object(match obj_type { RoomObjectType::Table => ObjectType::Table, @@ -234,21 +296,21 @@ mod tests { ) .unwrap(); - let (info, objects) = convert_space(&space); + let data = convert_space(&space); - assert_eq!(info.name, "Test Room"); + assert_eq!(data.info.name, "Test Room"); - assert_eq!(objects.len(), 2); + assert_eq!(data.objects.len(), 2); - assert_eq!(objects[0].id, "desk"); - assert_eq!(objects[0].name, "My Desk"); - assert_eq!(objects[0].position, Vec3::new(1.0, 0.0, 2.0)); - assert!(matches!(objects[0].object_type, RoomObjectType::Table)); + assert_eq!(data.objects[0].id, "desk"); + assert_eq!(data.objects[0].name, "My Desk"); + assert_eq!(data.objects[0].position, Vec3::new(1.0, 0.0, 2.0)); + assert!(matches!(data.objects[0].object_type, RoomObjectType::Table)); - assert_eq!(objects[1].id, "chair1"); - assert_eq!(objects[1].name, "Office Chair"); - assert_eq!(objects[1].position, Vec3::ZERO); - assert!(matches!(objects[1].object_type, RoomObjectType::Chair)); + assert_eq!(data.objects[1].id, "chair1"); + assert_eq!(data.objects[1].name, "Office Chair"); + assert_eq!(data.objects[1].position, Vec3::ZERO); + assert!(matches!(data.objects[1].object_type, RoomObjectType::Chair)); } #[test] @@ -262,9 +324,12 @@ mod tests { ) .unwrap(); - let (_, objects) = convert_space(&space); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].model_url.as_deref(), Some("/models/table.glb")); + let data = convert_space(&space); + assert_eq!(data.objects.len(), 1); + assert_eq!( + data.objects[0].model_url.as_deref(), + Some("/models/table.glb") + ); } #[test] @@ -276,16 +341,17 @@ mod tests { ) .unwrap(); - let (_, objects) = convert_space(&space); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].id, "p1"); - assert_eq!(objects[0].name, "Water Bottle"); + let data = convert_space(&space); + assert_eq!(data.objects.len(), 1); + assert_eq!(data.objects[0].id, "p1"); + assert_eq!(data.objects[0].name, "Water Bottle"); } #[test] fn test_build_space_roundtrip() { let info = SpaceInfo { name: "My Space".to_string(), + tilemap: None, }; let objects = vec![ RoomObject::new( @@ -306,29 +372,32 @@ mod tests { let reparsed = parse(&serialized).unwrap(); // Convert back - let (info2, objects2) = convert_space(&reparsed); + let data = convert_space(&reparsed); - assert_eq!(info2.name, "My Space"); + assert_eq!(data.info.name, "My Space"); - assert_eq!(objects2.len(), 2); - assert_eq!(objects2[0].id, "desk"); - assert_eq!(objects2[0].name, "Office Desk"); - assert_eq!(objects2[0].model_url.as_deref(), Some("/models/desk.glb")); - assert_eq!(objects2[0].position, Vec3::new(2.0, 0.0, 3.0)); - assert!(matches!(objects2[0].object_type, RoomObjectType::Table)); + assert_eq!(data.objects.len(), 2); + assert_eq!(data.objects[0].id, "desk"); + assert_eq!(data.objects[0].name, "Office Desk"); + assert_eq!( + data.objects[0].model_url.as_deref(), + Some("/models/desk.glb") + ); + assert_eq!(data.objects[0].position, Vec3::new(2.0, 0.0, 3.0)); + assert!(matches!(data.objects[0].object_type, RoomObjectType::Table)); - assert_eq!(objects2[1].id, "lamp"); - assert_eq!(objects2[1].name, "Floor Lamp"); - assert!(matches!(objects2[1].object_type, RoomObjectType::Light)); + assert_eq!(data.objects[1].id, "lamp"); + assert_eq!(data.objects[1].name, "Floor Lamp"); + assert!(matches!(data.objects[1].object_type, RoomObjectType::Light)); } #[test] fn test_convert_defaults() { let space = parse("(space)").unwrap(); - let (info, objects) = convert_space(&space); + let data = convert_space(&space); - assert_eq!(info.name, "Untitled Space"); - assert!(objects.is_empty()); + assert_eq!(data.info.name, "Untitled Space"); + assert!(data.objects.is_empty()); } #[test] @@ -340,11 +409,11 @@ mod tests { ) .unwrap(); - let (_, objects) = convert_space(&space); - assert_eq!(objects.len(), 2); - assert_eq!(objects[0].location, None); + let data = convert_space(&space); + assert_eq!(data.objects.len(), 2); + assert_eq!(data.objects[0].location, None); assert_eq!( - objects[1].location, + data.objects[1].location, Some(ObjectLocation::TopOf("obj1".to_string())) ); } @@ -353,6 +422,7 @@ mod tests { fn test_build_space_always_emits_position() { let info = SpaceInfo { name: "Test".to_string(), + tilemap: None, }; let objects = vec![RoomObject::new( "a".to_string(), @@ -371,6 +441,7 @@ mod tests { fn test_build_space_location_roundtrip() { let info = SpaceInfo { name: "Test".to_string(), + tilemap: None, }; let objects = vec![ RoomObject::new("obj1".to_string(), "Table".to_string(), Vec3::ZERO) @@ -386,12 +457,67 @@ mod tests { let space = build_space(&info, &objects); let serialized = protoverse::serialize(&space); let reparsed = parse(&serialized).unwrap(); - let (_, objects2) = convert_space(&reparsed); + let data = convert_space(&reparsed); assert_eq!( - objects2[1].location, + data.objects[1].location, Some(ObjectLocation::TopOf("obj1".to_string())) ); - assert_eq!(objects2[1].position, Vec3::new(0.0, 1.5, 0.0)); + assert_eq!(data.objects[1].position, Vec3::new(0.0, 1.5, 0.0)); + } + + #[test] + fn test_convert_tilemap() { + let space = parse( + r#"(space (name "Test") (group + (tilemap (width 5) (height 5) (tileset "grass" "stone") (data "0")) + (table (id t1) (name "Table") (position 0 0 0))))"#, + ) + .unwrap(); + + let data = convert_space(&space); + assert_eq!(data.info.name, "Test"); + assert_eq!(data.objects.len(), 1); + assert_eq!(data.objects[0].id, "t1"); + + let tm = data.info.tilemap.unwrap(); + assert_eq!(tm.width, 5); + assert_eq!(tm.height, 5); + assert_eq!(tm.tileset, vec!["grass", "stone"]); + assert_eq!(tm.tiles, vec![0]); // fill-all + } + + #[test] + fn test_build_space_tilemap_roundtrip() { + let info = SpaceInfo { + name: "Test".to_string(), + tilemap: Some(TilemapData { + width: 8, + height: 8, + tileset: vec!["grass".to_string(), "water".to_string()], + tiles: vec![0], // fill-all + scene_object_id: None, + model_handle: None, + }), + }; + let objects = vec![RoomObject::new( + "a".to_string(), + "Thing".to_string(), + Vec3::ZERO, + )]; + + let space = build_space(&info, &objects); + let serialized = protoverse::serialize(&space); + let reparsed = parse(&serialized).unwrap(); + let data = convert_space(&reparsed); + + assert_eq!(data.objects.len(), 1); + assert_eq!(data.objects[0].id, "a"); + + let tm = data.info.tilemap.unwrap(); + assert_eq!(tm.width, 8); + assert_eq!(tm.height, 8); + assert_eq!(tm.tileset, vec!["grass", "water"]); + assert_eq!(tm.tiles, vec![0]); } } diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -13,9 +13,11 @@ mod presence; mod room_state; mod room_view; mod subscriptions; +mod tilemap; pub use room_state::{ - NostrverseAction, NostrverseState, RoomObject, RoomObjectType, RoomUser, SpaceInfo, SpaceRef, + NostrverseAction, NostrverseState, RoomObject, RoomObjectType, RoomUser, SpaceData, SpaceInfo, + SpaceRef, }; pub use room_view::{NostrverseResponse, render_editing_panel, show_room_view}; @@ -51,6 +53,9 @@ const MAX_EXTRAPOLATION_DISTANCE: f32 = 10.0; /// Demo space in protoverse .space format const DEMO_SPACE: &str = r#"(space (name "Demo Space") (group + (tilemap (width 10) (height 10) + (tileset "grass") + (data "0")) (table (id obj1) (name "Ironwood Table") (model-url "/home/jb55/var/models/ironwood/ironwood.glb") (position 0 0 0)) @@ -288,31 +293,47 @@ impl NostrverseApp { /// Preserves renderer scene handles for objects that still exist by ID, /// and removes orphaned scene objects from the renderer. fn apply_space(&mut self, space: &protoverse::Space) { - let (info, mut objects) = convert::convert_space(space); - self.state.space = Some(info); + let mut data = convert::convert_space(space); // Transfer scene/model handles from existing objects with matching IDs - for new_obj in &mut objects { + for new_obj in &mut data.objects { if let Some(old_obj) = self.state.objects.iter().find(|o| o.id == new_obj.id) { new_obj.scene_object_id = old_obj.scene_object_id; new_obj.model_handle = old_obj.model_handle; } } + // Transfer tilemap handles before overwriting state + let old_tilemap_handles = self + .state + .tilemap() + .map(|tm| (tm.scene_object_id, tm.model_handle)); + if let (Some(new_tm), Some((scene_id, model_handle))) = + (&mut data.info.tilemap, old_tilemap_handles) + { + new_tm.scene_object_id = scene_id; + new_tm.model_handle = model_handle; + } + // Remove orphaned scene objects (old objects not in the new set) if let Some(renderer) = &self.renderer { let mut r = renderer.renderer.lock().unwrap(); for old_obj in &self.state.objects { if let Some(scene_id) = old_obj.scene_object_id - && !objects.iter().any(|o| o.id == old_obj.id) + && !data.objects.iter().any(|o| o.id == old_obj.id) { r.remove_object(scene_id); } } + // Remove old tilemap scene object if being replaced + if let Some((Some(scene_id), _)) = old_tilemap_handles { + r.remove_object(scene_id); + } } - self.load_object_models(&mut objects); - self.state.objects = objects; + self.load_object_models(&mut data.objects); + self.state.space = Some(data.info); + self.state.objects = data.objects; self.state.dirty = false; } @@ -550,6 +571,25 @@ impl NostrverseApp { sync_objects_to_scene(&mut self.state.objects, &mut r); + // Build + place tilemap if needed + if let Some(tm) = self.state.tilemap_mut() { + if tm.model_handle.is_none() + && let (Some(device), Some(queue)) = (&self.device, &self.queue) + { + tm.model_handle = Some(tilemap::build_tilemap_model(tm, &mut r, device, queue)); + } + if tm.scene_object_id.is_none() + && let Some(model) = tm.model_handle + { + let transform = renderbud::Transform { + translation: glam::Vec3::ZERO, + rotation: glam::Quat::IDENTITY, + scale: glam::Vec3::ONE, + }; + tm.scene_object_id = Some(r.place_object(model, transform)); + } + } + // Update self-user's position from the camera controller if let Some(pos) = r.avatar_position() && let Some(self_user) = self.state.self_user_mut() diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs @@ -43,20 +43,30 @@ impl SpaceRef { } } -/// Parsed space data from event +/// Parsed space definition from event #[derive(Clone, Debug)] pub struct SpaceInfo { pub name: String, + /// Tilemap ground plane (if present) + pub tilemap: Option<TilemapData>, } impl Default for SpaceInfo { fn default() -> Self { Self { name: "Untitled Space".to_string(), + tilemap: None, } } } +/// Converted space data: space info + objects. +/// Used as the return type from convert_space to avoid fragile tuples. +pub struct SpaceData { + pub info: SpaceInfo, + pub objects: Vec<RoomObject>, +} + /// Spatial location relative to the room or another object. /// Mirrors protoverse::Location for decoupling. #[derive(Clone, Debug, PartialEq)] @@ -146,6 +156,62 @@ impl RoomObject { } } +/// Parsed tilemap data — compact tile grid representation. +#[derive(Clone, Debug)] +pub struct TilemapData { + /// Grid width in tiles + pub width: u32, + /// Grid height in tiles + pub height: u32, + /// Tile type names (index 0 = first name, etc.) + pub tileset: Vec<String>, + /// Tile indices, row-major. Length == 1 means fill-all with that value. + pub tiles: Vec<u8>, + /// Runtime: renderbud scene object handle for the tilemap mesh + pub scene_object_id: Option<ObjectId>, + /// Runtime: loaded model handle for the tilemap mesh + pub model_handle: Option<Model>, +} + +impl TilemapData { + /// Get the tile index at grid position (x, y). + pub fn tile_at(&self, x: u32, y: u32) -> u8 { + if self.tiles.len() == 1 { + return self.tiles[0]; + } + let idx = (y * self.width + x) as usize; + self.tiles.get(idx).copied().unwrap_or(0) + } + + /// Encode tiles back to the compact data string. + /// If all tiles are the same value, returns just that value. + pub fn encode_data(&self) -> String { + if self.tiles.len() == 1 { + return self.tiles[0].to_string(); + } + if self.tiles.iter().all(|&t| t == self.tiles[0]) { + return self.tiles[0].to_string(); + } + self.tiles + .iter() + .map(|t| t.to_string()) + .collect::<Vec<_>>() + .join(" ") + } + + /// Parse the compact data string into tile indices. + pub fn decode_data(data: &str) -> Vec<u8> { + let parts: Vec<&str> = data.split_whitespace().collect(); + if parts.len() == 1 { + // Fill-all mode: single value + let val = parts[0].parse::<u8>().unwrap_or(0); + vec![val] + } else { + parts.iter().map(|s| s.parse::<u8>().unwrap_or(0)).collect() + } + } +} + /// A user present in a room (for rendering) #[derive(Clone, Debug)] pub struct RoomUser { @@ -292,6 +358,16 @@ impl NostrverseState { self.objects.iter_mut().find(|o| o.id == id) } + /// Get the tilemap (if present in the space info) + pub fn tilemap(&self) -> Option<&TilemapData> { + self.space.as_ref()?.tilemap.as_ref() + } + + /// Get the tilemap mutably (if present in the space info) + pub fn tilemap_mut(&mut self) -> Option<&mut TilemapData> { + self.space.as_mut()?.tilemap.as_mut() + } + /// Get the local user pub fn self_user(&self) -> Option<&RoomUser> { self.users.iter().find(|u| u.is_self) diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -952,12 +952,16 @@ fn is_sexp_keyword(word: &str) -> bool { matches!( word, "room" + | "space" | "group" | "table" | "chair" | "door" | "light" | "prop" + | "tilemap" + | "tileset" + | "data" | "name" | "id" | "shape" diff --git a/crates/notedeck_nostrverse/src/tilemap.rs b/crates/notedeck_nostrverse/src/tilemap.rs @@ -0,0 +1,187 @@ +//! Tilemap mesh generation — builds a single textured quad mesh from TilemapData. + +use crate::room_state::TilemapData; +use egui_wgpu::wgpu; +use glam::Vec3; +use renderbud::{Aabb, MaterialUniform, Mesh, ModelData, ModelDraw, Vertex}; +use wgpu::util::DeviceExt; + +/// Size of each tile in the atlas, in pixels. +const TILE_PX: u32 = 32; + +/// Generate a deterministic color for a tile name. +fn tile_color(name: &str) -> [u8; 4] { + let mut h: u32 = 5381; + for b in name.bytes() { + h = h.wrapping_mul(33).wrapping_add(b as u32); + } + // Clamp channels to a pleasant range (64..224) so tiles aren't too dark or bright + let r = 64 + (h & 0xFF) as u8 % 160; + let g = 64 + ((h >> 8) & 0xFF) as u8 % 160; + let b = 64 + ((h >> 16) & 0xFF) as u8 % 160; + [r, g, b, 255] +} + +/// Build the atlas RGBA texture data. +/// Atlas is a 1-tile-wide vertical strip (TILE_PX x (TILE_PX * N)). +fn build_atlas(tileset: &[String]) -> (u32, u32, Vec<u8>) { + let n = tileset.len().max(1) as u32; + let atlas_w = TILE_PX; + let atlas_h = TILE_PX * n; + let mut rgba = vec![0u8; (atlas_w * atlas_h * 4) as usize]; + + for (i, name) in tileset.iter().enumerate() { + let color = tile_color(name); + let y_start = i as u32 * TILE_PX; + for y in y_start..y_start + TILE_PX { + for x in 0..TILE_PX { + let offset = ((y * atlas_w + x) * 4) as usize; + rgba[offset..offset + 4].copy_from_slice(&color); + } + } + } + + (atlas_w, atlas_h, rgba) +} + +/// Build a tilemap model (mesh + atlas material) and register it in the renderer. +pub fn build_tilemap_model( + tm: &TilemapData, + renderer: &mut renderbud::Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, +) -> renderbud::Model { + let w = tm.width; + let h = tm.height; + let n_tiles = (w * h) as usize; + let n_tileset = tm.tileset.len().max(1) as f32; + + // Build atlas texture + let (atlas_w, atlas_h, atlas_rgba) = build_atlas(&tm.tileset); + let atlas_view = renderbud::upload_rgba8_texture_2d( + device, + queue, + atlas_w, + atlas_h, + &atlas_rgba, + wgpu::TextureFormat::Rgba8UnormSrgb, + "tilemap_atlas", + ); + + // Build mesh: one quad per tile + let mut verts: Vec<Vertex> = Vec::with_capacity(n_tiles * 4); + let mut indices: Vec<u32> = Vec::with_capacity(n_tiles * 6); + let mut bounds = Aabb::empty(); + + // Center the tilemap so origin is in the middle + let offset_x = -(w as f32) / 2.0; + let offset_z = -(h as f32) / 2.0; + let y = 0.001_f32; // Just above ground to avoid z-fighting with grid + + let normal = [0.0_f32, 1.0, 0.0]; // Facing up + let tangent = [1.0_f32, 0.0, 0.0, 1.0]; // Tangent along +X + + for ty in 0..h { + for tx in 0..w { + let tile_idx = tm.tile_at(tx, ty) as f32; + let base_vert = verts.len() as u32; + + // Quad corners in world space + let x0 = offset_x + tx as f32; + let x1 = x0 + 1.0; + let z0 = offset_z + ty as f32; + let z1 = z0 + 1.0; + + // UV coords: map to the tile's strip in the atlas + let v0 = tile_idx / n_tileset; + let v1 = (tile_idx + 1.0) / n_tileset; + + verts.push(Vertex { + pos: [x0, y, z0], + normal, + uv: [0.0, v0], + tangent, + }); + verts.push(Vertex { + pos: [x1, y, z0], + normal, + uv: [1.0, v0], + tangent, + }); + verts.push(Vertex { + pos: [x1, y, z1], + normal, + uv: [1.0, v1], + tangent, + }); + verts.push(Vertex { + pos: [x0, y, z1], + normal, + uv: [0.0, v1], + tangent, + }); + + // Two triangles (CCW winding when viewed from above) + indices.push(base_vert); + indices.push(base_vert + 2); + indices.push(base_vert + 1); + indices.push(base_vert); + indices.push(base_vert + 3); + indices.push(base_vert + 2); + + bounds.include_point(Vec3::new(x0, y, z0)); + bounds.include_point(Vec3::new(x1, y, z1)); + } + } + + // Upload buffers + let vert_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("tilemap_verts"), + contents: bytemuck::cast_slice(&verts), + usage: wgpu::BufferUsages::VERTEX, + }); + let ind_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("tilemap_indices"), + contents: bytemuck::cast_slice(&indices), + usage: wgpu::BufferUsages::INDEX, + }); + + // Create material with Nearest filtering for crisp tiles + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("tilemap_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + let material = renderer.create_material( + device, + queue, + &sampler, + &atlas_view, + MaterialUniform { + base_color_factor: glam::Vec4::ONE, + metallic_factor: 0.0, + roughness_factor: 1.0, + ao_strength: 1.0, + _pad0: 0.0, + }, + ); + + let model_data = ModelData { + draws: vec![ModelDraw { + mesh: Mesh { + num_indices: indices.len() as u32, + vert_buf, + ind_buf, + }, + material_index: 0, + }], + materials: vec![material], + bounds, + }; + + renderer.insert_model(model_data) +} diff --git a/crates/protoverse/src/ast.rs b/crates/protoverse/src/ast.rs @@ -39,6 +39,7 @@ pub enum CellType { Room, Space, Group, + Tilemap, Object(ObjectType), } @@ -68,6 +69,10 @@ pub enum Attribute { /// Euler rotation in degrees (X, Y, Z), applied in YXZ order. Rotation(f64, f64, f64), ModelUrl(String), + /// Tilemap tile type names, e.g. ["grass", "stone", "water"]. + Tileset(Vec<String>), + /// Compact tile data string, e.g. "0" (fill-all) or "0 0 1 1 0 0". + Data(String), } /// Spatial location relative to the room or another object. @@ -121,6 +126,7 @@ impl fmt::Display for CellType { CellType::Room => write!(f, "room"), CellType::Space => write!(f, "space"), CellType::Group => write!(f, "group"), + CellType::Tilemap => write!(f, "tilemap"), CellType::Object(o) => write!(f, "{}", o), } } @@ -257,4 +263,18 @@ impl Space { _ => None, }) } + + pub fn tileset(&self, id: CellId) -> Option<&Vec<String>> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Tileset(v) => Some(v), + _ => None, + }) + } + + pub fn data(&self, id: CellId) -> Option<&str> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Data(s) => Some(s.as_str()), + _ => None, + }) + } } diff --git a/crates/protoverse/src/describe.rs b/crates/protoverse/src/describe.rs @@ -43,6 +43,7 @@ fn describe_cell(space: &Space, id: CellId, buf: &mut String) -> bool { CellType::Room => describe_area(space, id, "room", buf), CellType::Space => describe_area(space, id, "space", buf), CellType::Group => describe_group(space, id, buf), + CellType::Tilemap => false, CellType::Object(_) => false, // unimplemented in C reference } } diff --git a/crates/protoverse/src/parser.rs b/crates/protoverse/src/parser.rs @@ -228,6 +228,18 @@ impl<'a> Parser<'a> { "model-url" => self .eat_string() .map(|s| Attribute::ModelUrl(s.to_string())), + "tileset" => { + let mut names = Vec::new(); + while let Some(s) = self.eat_string() { + names.push(s.to_string()); + } + if names.is_empty() { + None + } else { + Some(Attribute::Tileset(names)) + } + } + "data" => self.eat_string().map(|s| Attribute::Data(s.to_string())), _ => None, }; @@ -364,6 +376,10 @@ impl<'a> Parser<'a> { Some(id) } + fn try_parse_tilemap(&mut self) -> Option<CellId> { + self.try_parse_named_cell("tilemap", CellType::Tilemap) + } + fn try_parse_object(&mut self) -> Option<CellId> { let cp = self.checkpoint(); @@ -398,6 +414,7 @@ impl<'a> Parser<'a> { .try_parse_group() .or_else(|| self.try_parse_room()) .or_else(|| self.try_parse_space()) + .or_else(|| self.try_parse_tilemap()) .or_else(|| self.try_parse_object()); match id { @@ -509,6 +526,35 @@ mod tests { } #[test] + fn test_parse_tilemap() { + let input = r#"(tilemap (width 10) (height 10) (tileset "grass" "stone") (data "0"))"#; + let space = parse(input).unwrap(); + let root = space.cell(space.root); + assert_eq!(root.cell_type, CellType::Tilemap); + assert_eq!(space.width(space.root), Some(10.0)); + assert_eq!(space.height(space.root), Some(10.0)); + assert_eq!( + space.tileset(space.root), + Some(&vec!["grass".to_string(), "stone".to_string()]) + ); + assert_eq!(space.data(space.root), Some("0")); + } + + #[test] + fn test_parse_tilemap_in_group() { + let input = r#"(space (name "Test") (group (tilemap (width 5) (height 5) (tileset "grass") (data "0")) (table (id t1))))"#; + let space = parse(input).unwrap(); + let group_id = space.children(space.root)[0]; + let children = space.children(group_id); + assert_eq!(children.len(), 2); + assert_eq!(space.cell(children[0]).cell_type, CellType::Tilemap); + assert_eq!( + space.cell(children[1]).cell_type, + CellType::Object(ObjectType::Table) + ); + } + + #[test] fn test_location_roundtrip() { use crate::serializer::serialize; diff --git a/crates/protoverse/src/serializer.rs b/crates/protoverse/src/serializer.rs @@ -116,6 +116,16 @@ fn write_attr(attr: &Attribute, out: &mut String) { Attribute::ModelUrl(s) => { let _ = write!(out, "(model-url \"{}\")", s); } + Attribute::Tileset(names) => { + out.push_str("(tileset"); + for name in names { + let _ = write!(out, " \"{}\"", name); + } + out.push(')'); + } + Attribute::Data(s) => { + let _ = write!(out, "(data \"{}\")", s); + } } } @@ -134,6 +144,21 @@ mod tests { } #[test] + fn test_tilemap_roundtrip() { + let input = + r#"(tilemap (width 10) (height 10) (tileset "grass" "stone") (data "0 0 1 1"))"#; + let space = parse(input).unwrap(); + let serialized = serialize(&space); + let reparsed = parse(&serialized).unwrap(); + assert_eq!(reparsed.cell(reparsed.root).cell_type, CellType::Tilemap); + assert_eq!( + reparsed.tileset(reparsed.root), + Some(&vec!["grass".to_string(), "stone".to_string()]) + ); + assert_eq!(reparsed.data(reparsed.root), Some("0 0 1 1")); + } + + #[test] fn test_format_number_strips_float_noise() { // Integers assert_eq!(format_number(10.0), "10"); diff --git a/crates/renderbud/src/lib.rs b/crates/renderbud/src/lib.rs @@ -1,8 +1,6 @@ use glam::{Mat4, Vec2, Vec3, Vec4}; -use crate::material::{MaterialUniform, make_material_gpudata}; -use crate::model::ModelData; -use crate::model::Vertex; +use crate::material::make_material_gpudata; use std::collections::HashMap; use std::num::NonZeroU64; @@ -17,7 +15,9 @@ mod world; pub mod egui; pub use camera::{ArcballController, Camera, FlyController, ThirdPersonController}; -pub use model::{Aabb, Model}; +pub use material::{MaterialGpu, MaterialUniform}; +pub use model::{Aabb, Mesh, Model, ModelData, ModelDraw, Vertex}; +pub use texture::upload_rgba8_texture_2d; pub use world::{Node, NodeId, ObjectId, Transform, World}; /// Active camera controller mode. @@ -703,6 +703,55 @@ impl Renderer { Ok(id) } + /// Register a procedurally-generated model. Returns a handle that can + /// be placed in the scene with [`place_object`]. + pub fn insert_model(&mut self, model_data: ModelData) -> Model { + self.model_ids += 1; + let id = Model { id: self.model_ids }; + self.models.insert(id, model_data); + id + } + + /// Create a PBR material from a base color texture view. + /// Used for procedural geometry (tilemaps, etc.). + pub fn create_material( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + sampler: &wgpu::Sampler, + basecolor: &wgpu::TextureView, + uniform: MaterialUniform, + ) -> MaterialGpu { + let default_mr = texture::upload_rgba8_texture_2d( + device, + queue, + 1, + 1, + &[0, 255, 0, 255], + wgpu::TextureFormat::Rgba8Unorm, + "tilemap_mr", + ); + let default_normal = texture::upload_rgba8_texture_2d( + device, + queue, + 1, + 1, + &[128, 128, 255, 255], + wgpu::TextureFormat::Rgba8Unorm, + "tilemap_normal", + ); + model::make_material_gpu( + device, + queue, + &self.material_bgl, + sampler, + basecolor, + &default_mr, + &default_normal, + uniform, + ) + } + /// Place a loaded model in the scene with the given transform. pub fn place_object(&mut self, model: Model, transform: Transform) -> ObjectId { self.world.add_object(model, transform) @@ -988,7 +1037,7 @@ impl Renderer { for d in &model_data.draws { shadow_pass.set_vertex_buffer(0, d.mesh.vert_buf.slice(..)); - shadow_pass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint16); + shadow_pass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint32); shadow_pass.draw_indexed(0..d.mesh.num_indices, 0, 0..1); } } @@ -1063,7 +1112,7 @@ impl Renderer { for d in &model_data.draws { rpass.set_bind_group(2, &model_data.materials[d.material_index].bindgroup, &[]); rpass.set_vertex_buffer(0, d.mesh.vert_buf.slice(..)); - rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint16); + rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint32); rpass.draw_indexed(0..d.mesh.num_indices, 0, 0..1); } } @@ -1086,7 +1135,7 @@ impl Renderer { for d in &model_data.draws { rpass.set_vertex_buffer(0, d.mesh.vert_buf.slice(..)); - rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint16); + rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint32); rpass.draw_indexed(0..d.mesh.num_indices, 0, 0..1); } } diff --git a/crates/renderbud/src/model.rs b/crates/renderbud/src/model.rs @@ -345,11 +345,10 @@ pub fn load_gltf_model( .map(|tc| tc.into_f32().collect()) .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]); - // TODO(jb55): switch to u32 indices - let indices: Vec<u16> = if let Some(read) = reader.read_indices() { - read.into_u32().map(|i| i as u16).collect() + let indices: Vec<u32> = if let Some(read) = reader.read_indices() { + read.into_u32().collect() } else { - (0..positions.len() as u16).collect() + (0..positions.len() as u32).collect() }; /* @@ -464,7 +463,7 @@ fn map_mag_filter(f: Option<gltf::texture::MagFilter>) -> wgpu::FilterMode { } #[allow(clippy::too_many_arguments)] -fn make_material_gpu( +pub(crate) fn make_material_gpu( device: &wgpu::Device, queue: &wgpu::Queue, material_bgl: &wgpu::BindGroupLayout, @@ -518,7 +517,7 @@ fn make_material_gpu( } } -fn compute_tangents(verts: &mut [Vertex], indices: &[u16]) { +fn compute_tangents(verts: &mut [Vertex], indices: &[u32]) { use glam::{Vec2, Vec3}; let n = verts.len();