notedeck

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

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 }