tilemap.rs (11121B)
1 //! Tilemap mesh generation — builds a single textured quad mesh from TilemapData. 2 3 use crate::room_state::TilemapData; 4 use egui_wgpu::wgpu; 5 use glam::Vec3; 6 use renderbud::{Aabb, MaterialUniform, Mesh, ModelData, ModelDraw, Vertex}; 7 use wgpu::util::DeviceExt; 8 9 /// Size of each tile in the atlas, in pixels. 10 const TILE_PX: u32 = 32; 11 12 /// Per-pixel detail pattern applied on top of the base color + noise. 13 enum TileDetail { 14 /// No extra detail, just base color with noise variation. 15 None, 16 /// Dark clumps (coarse noise) + sparse bright specks on green channel. 17 /// Used for grass-like tiles. 18 Clumpy, 19 /// Dark lines where coarse noise crosses zero — looks like cracks. 20 Cracked, 21 /// Sinusoidal horizontal wave highlights on green+blue channels. 22 Wavy, 23 /// Sparse bright specks across all channels — looks like pebbles. 24 Speckled, 25 /// Coarse blue-shifted shadows — looks like snow/ice. 26 Frosty, 27 /// Horizontal sinusoidal grain lines — looks like wood. 28 Grainy, 29 } 30 31 /// Describes how to procedurally generate a tile texture. 32 struct TileStyle { 33 base: [u8; 3], 34 variation: i16, 35 detail: TileDetail, 36 } 37 38 /// Look up the style for a tile name. 39 fn tile_style(name: &str) -> TileStyle { 40 match name { 41 "grass" => TileStyle { 42 base: [76, 140, 56], 43 variation: 20, 44 detail: TileDetail::Clumpy, 45 }, 46 "stone" | "rock" => TileStyle { 47 base: [140, 136, 128], 48 variation: 18, 49 detail: TileDetail::Cracked, 50 }, 51 "water" => TileStyle { 52 base: [48, 100, 160], 53 variation: 12, 54 detail: TileDetail::Wavy, 55 }, 56 "sand" => TileStyle { 57 base: [194, 178, 128], 58 variation: 15, 59 detail: TileDetail::None, 60 }, 61 "dirt" | "earth" | "mud" => TileStyle { 62 base: [120, 85, 58], 63 variation: 16, 64 detail: TileDetail::Speckled, 65 }, 66 "snow" | "ice" => TileStyle { 67 base: [220, 225, 235], 68 variation: 10, 69 detail: TileDetail::Frosty, 70 }, 71 "wood" | "plank" | "floor" => TileStyle { 72 base: [156, 110, 68], 73 variation: 12, 74 detail: TileDetail::Grainy, 75 }, 76 _ => TileStyle { 77 base: hash_name_to_color(name), 78 variation: 18, 79 detail: TileDetail::None, 80 }, 81 } 82 } 83 84 /// Deterministic color from an unknown tile name. 85 fn hash_name_to_color(name: &str) -> [u8; 3] { 86 let mut h: u32 = 5381; 87 for b in name.bytes() { 88 h = h.wrapping_mul(33).wrapping_add(b as u32); 89 } 90 [ 91 80 + (h & 0xFF) as u8 % 120, 92 80 + ((h >> 8) & 0xFF) as u8 % 120, 93 80 + ((h >> 16) & 0xFF) as u8 % 120, 94 ] 95 } 96 97 /// Simple deterministic hash for procedural noise. 98 fn hash(x: u32, y: u32, seed: u32) -> u32 { 99 let mut h = seed; 100 h = h 101 .wrapping_mul(374761393) 102 .wrapping_add(x.wrapping_mul(668265263)); 103 h = h 104 .wrapping_mul(374761393) 105 .wrapping_add(y.wrapping_mul(2654435761)); 106 h ^= h >> 13; 107 h = h.wrapping_mul(1274126177); 108 h ^= h >> 16; 109 h 110 } 111 112 /// Noise value in -1.0..1.0 for pixel (x, y). 113 fn noise(x: u32, y: u32, seed: u32) -> f32 { 114 (hash(x, y, seed) & 0xFFFF) as f32 / 32768.0 - 1.0 115 } 116 117 /// Base color + random per-pixel brightness variation. 118 fn vary(base: [u8; 3], amount: i16, x: u32, y: u32, seed: u32) -> [u8; 4] { 119 let n = noise(x, y, seed); 120 let delta = (n * amount as f32) as i16; 121 let r = (base[0] as i16 + delta).clamp(0, 255) as u8; 122 let g = (base[1] as i16 + delta).clamp(0, 255) as u8; 123 let b = (base[2] as i16 + delta).clamp(0, 255) as u8; 124 [r, g, b, 255] 125 } 126 127 /// Apply detail pattern to a pixel. `lx`/`ly` are tile-local coordinates. 128 fn apply_detail(px: &mut [u8; 4], detail: &TileDetail, x: u32, lx: u32, ly: u32, seed: u32) { 129 match detail { 130 TileDetail::None => {} 131 TileDetail::Clumpy => { 132 if noise(lx / 4, ly / 4, seed ^ 0xBEEF) > 0.4 { 133 px[0] = px[0].saturating_sub(15); 134 px[1] = px[1].saturating_sub(8); 135 px[2] = px[2].saturating_sub(12); 136 } 137 if noise(x, ly, seed ^ 0xCAFE) > 0.85 { 138 px[1] = px[1].saturating_add(30); 139 } 140 } 141 TileDetail::Cracked => { 142 if noise(lx / 3, ly / 3, seed ^ 0xD00D).abs() < 0.08 { 143 px[0] = px[0].saturating_sub(35); 144 px[1] = px[1].saturating_sub(35); 145 px[2] = px[2].saturating_sub(35); 146 } 147 } 148 TileDetail::Wavy => { 149 let wave = ((ly as f32 * 0.4 + lx as f32 * 0.15).sin() * 0.5 + 0.5) * 20.0; 150 px[1] = px[1].saturating_add(wave as u8); 151 px[2] = px[2].saturating_add(wave as u8); 152 } 153 TileDetail::Speckled => { 154 if noise(x, ly, seed ^ 0xF00D) > 0.88 { 155 px[0] = px[0].saturating_add(25); 156 px[1] = px[1].saturating_add(20); 157 px[2] = px[2].saturating_add(15); 158 } 159 } 160 TileDetail::Frosty => { 161 if noise(lx / 5, ly / 5, seed ^ 0x1CE) > 0.3 { 162 px[2] = px[2].saturating_add(8); 163 px[0] = px[0].saturating_sub(5); 164 } 165 } 166 TileDetail::Grainy => { 167 if (ly as f32 * 1.2).sin().abs() < 0.15 { 168 px[0] = px[0].saturating_sub(20); 169 px[1] = px[1].saturating_sub(15); 170 px[2] = px[2].saturating_sub(10); 171 } 172 } 173 } 174 } 175 176 /// Generate a procedural tile texture into the atlas at the given row. 177 fn fill_tile(rgba: &mut [u8], atlas_w: u32, y_start: u32, name: &str, tile_idx: u32) { 178 let seed = tile_idx.wrapping_mul(2654435761); 179 let style = tile_style(name); 180 181 for y in y_start..y_start + TILE_PX { 182 for x in 0..TILE_PX { 183 let off = ((y * atlas_w + x) * 4) as usize; 184 let ly = y - y_start; 185 let mut px = vary(style.base, style.variation, x, y, seed); 186 apply_detail(&mut px, &style.detail, x, x, ly, seed); 187 rgba[off..off + 4].copy_from_slice(&px); 188 } 189 } 190 } 191 192 /// Build the atlas RGBA texture data. 193 /// Atlas is a 1-tile-wide vertical strip (TILE_PX x (TILE_PX * N)). 194 fn build_atlas(tileset: &[String]) -> (u32, u32, Vec<u8>) { 195 let n = tileset.len().max(1) as u32; 196 let atlas_w = TILE_PX; 197 let atlas_h = TILE_PX * n; 198 let mut rgba = vec![0u8; (atlas_w * atlas_h * 4) as usize]; 199 200 for (i, name) in tileset.iter().enumerate() { 201 let y_start = i as u32 * TILE_PX; 202 fill_tile(&mut rgba, atlas_w, y_start, name, i as u32); 203 } 204 205 (atlas_w, atlas_h, rgba) 206 } 207 208 /// Build a tilemap model (mesh + atlas material) and register it in the renderer. 209 pub fn build_tilemap_model( 210 tm: &TilemapData, 211 renderer: &mut renderbud::Renderer, 212 device: &wgpu::Device, 213 queue: &wgpu::Queue, 214 ) -> renderbud::Model { 215 let w = tm.width; 216 let h = tm.height; 217 let n_tiles = (w * h) as usize; 218 let n_tileset = tm.tileset.len().max(1) as f32; 219 220 // Build atlas texture 221 let (atlas_w, atlas_h, atlas_rgba) = build_atlas(&tm.tileset); 222 let atlas_view = renderbud::upload_rgba8_texture_2d( 223 device, 224 queue, 225 atlas_w, 226 atlas_h, 227 &atlas_rgba, 228 wgpu::TextureFormat::Rgba8UnormSrgb, 229 "tilemap_atlas", 230 ); 231 232 // Build mesh: one quad per tile 233 let mut verts: Vec<Vertex> = Vec::with_capacity(n_tiles * 4); 234 let mut indices: Vec<u32> = Vec::with_capacity(n_tiles * 6); 235 let mut bounds = Aabb::empty(); 236 237 // Center the tilemap so origin is in the middle 238 let offset_x = -(w as f32) / 2.0; 239 let offset_z = -(h as f32) / 2.0; 240 let y = 0.01_f32; // Above ground plane to avoid z-fighting with grid 241 242 let normal = [0.0_f32, 1.0, 0.0]; // Facing up 243 let tangent = [1.0_f32, 0.0, 0.0, 1.0]; // Tangent along +X 244 245 for ty in 0..h { 246 for tx in 0..w { 247 let tile_idx = tm.tile_at(tx, ty) as f32; 248 let base_vert = verts.len() as u32; 249 250 // Quad corners in world space 251 let x0 = offset_x + tx as f32; 252 let x1 = x0 + 1.0; 253 let z0 = offset_z + ty as f32; 254 let z1 = z0 + 1.0; 255 256 // UV coords: map to the tile's strip in the atlas 257 let v0 = tile_idx / n_tileset; 258 let v1 = (tile_idx + 1.0) / n_tileset; 259 260 verts.push(Vertex { 261 pos: [x0, y, z0], 262 normal, 263 uv: [0.0, v0], 264 tangent, 265 }); 266 verts.push(Vertex { 267 pos: [x1, y, z0], 268 normal, 269 uv: [1.0, v0], 270 tangent, 271 }); 272 verts.push(Vertex { 273 pos: [x1, y, z1], 274 normal, 275 uv: [1.0, v1], 276 tangent, 277 }); 278 verts.push(Vertex { 279 pos: [x0, y, z1], 280 normal, 281 uv: [0.0, v1], 282 tangent, 283 }); 284 285 // Two triangles (CCW winding when viewed from above) 286 indices.push(base_vert); 287 indices.push(base_vert + 2); 288 indices.push(base_vert + 1); 289 indices.push(base_vert); 290 indices.push(base_vert + 3); 291 indices.push(base_vert + 2); 292 293 bounds.include_point(Vec3::new(x0, y, z0)); 294 bounds.include_point(Vec3::new(x1, y, z1)); 295 } 296 } 297 298 // Upload buffers 299 let vert_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { 300 label: Some("tilemap_verts"), 301 contents: bytemuck::cast_slice(&verts), 302 usage: wgpu::BufferUsages::VERTEX, 303 }); 304 let ind_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { 305 label: Some("tilemap_indices"), 306 contents: bytemuck::cast_slice(&indices), 307 usage: wgpu::BufferUsages::INDEX, 308 }); 309 310 // Create material with Nearest filtering for crisp tiles 311 let sampler = device.create_sampler(&wgpu::SamplerDescriptor { 312 label: Some("tilemap_sampler"), 313 address_mode_u: wgpu::AddressMode::ClampToEdge, 314 address_mode_v: wgpu::AddressMode::ClampToEdge, 315 mag_filter: wgpu::FilterMode::Nearest, 316 min_filter: wgpu::FilterMode::Nearest, 317 ..Default::default() 318 }); 319 320 let material = renderer.create_material( 321 device, 322 queue, 323 &sampler, 324 &atlas_view, 325 MaterialUniform { 326 base_color_factor: glam::Vec4::ONE, 327 metallic_factor: 0.0, 328 roughness_factor: 1.0, 329 ao_strength: 1.0, 330 _pad0: 0.0, 331 }, 332 ); 333 334 let model_data = ModelData { 335 draws: vec![ModelDraw { 336 mesh: Mesh { 337 num_indices: indices.len() as u32, 338 vert_buf, 339 ind_buf, 340 }, 341 material_index: 0, 342 }], 343 materials: vec![material], 344 bounds, 345 }; 346 347 renderer.insert_model(model_data) 348 }