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