commit 529add5669b1576530c16ae91abe3f891cfe68fe
parent a961a1aa999ad189092c9bb6917ac68a4c6cbdf1
Author: William Casarin <jb55@jb55.com>
Date: Thu, 26 Feb 2026 15:49:02 -0800
nostrverse: refactor tilemap textures to data-driven TileStyle
Replace opaque per-tile match arms with inline pixel loops with a
clean separation: TileStyle describes the look (base color, noise
amount, detail pattern), tile_style() is a pure lookup table, and
apply_detail() handles all pattern rendering. Adding a new tile
type is now just adding a match arm returning a struct.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
1 file changed, 174 insertions(+), 13 deletions(-)
diff --git a/crates/notedeck_nostrverse/src/tilemap.rs b/crates/notedeck_nostrverse/src/tilemap.rs
@@ -9,19 +9,186 @@ 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] {
+/// Per-pixel detail pattern applied on top of the base color + noise.
+enum TileDetail {
+ /// No extra detail, just base color with noise variation.
+ None,
+ /// Dark clumps (coarse noise) + sparse bright specks on green channel.
+ /// Used for grass-like tiles.
+ Clumpy,
+ /// Dark lines where coarse noise crosses zero — looks like cracks.
+ Cracked,
+ /// Sinusoidal horizontal wave highlights on green+blue channels.
+ Wavy,
+ /// Sparse bright specks across all channels — looks like pebbles.
+ Speckled,
+ /// Coarse blue-shifted shadows — looks like snow/ice.
+ Frosty,
+ /// Horizontal sinusoidal grain lines — looks like wood.
+ Grainy,
+}
+
+/// Describes how to procedurally generate a tile texture.
+struct TileStyle {
+ base: [u8; 3],
+ variation: i16,
+ detail: TileDetail,
+}
+
+/// Look up the style for a tile name.
+fn tile_style(name: &str) -> TileStyle {
+ match name {
+ "grass" => TileStyle {
+ base: [76, 140, 56],
+ variation: 20,
+ detail: TileDetail::Clumpy,
+ },
+ "stone" | "rock" => TileStyle {
+ base: [140, 136, 128],
+ variation: 18,
+ detail: TileDetail::Cracked,
+ },
+ "water" => TileStyle {
+ base: [48, 100, 160],
+ variation: 12,
+ detail: TileDetail::Wavy,
+ },
+ "sand" => TileStyle {
+ base: [194, 178, 128],
+ variation: 15,
+ detail: TileDetail::None,
+ },
+ "dirt" | "earth" | "mud" => TileStyle {
+ base: [120, 85, 58],
+ variation: 16,
+ detail: TileDetail::Speckled,
+ },
+ "snow" | "ice" => TileStyle {
+ base: [220, 225, 235],
+ variation: 10,
+ detail: TileDetail::Frosty,
+ },
+ "wood" | "plank" | "floor" => TileStyle {
+ base: [156, 110, 68],
+ variation: 12,
+ detail: TileDetail::Grainy,
+ },
+ _ => TileStyle {
+ base: hash_name_to_color(name),
+ variation: 18,
+ detail: TileDetail::None,
+ },
+ }
+}
+
+/// Deterministic color from an unknown tile name.
+fn hash_name_to_color(name: &str) -> [u8; 3] {
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;
+ [
+ 80 + (h & 0xFF) as u8 % 120,
+ 80 + ((h >> 8) & 0xFF) as u8 % 120,
+ 80 + ((h >> 16) & 0xFF) as u8 % 120,
+ ]
+}
+
+/// Simple deterministic hash for procedural noise.
+fn hash(x: u32, y: u32, seed: u32) -> u32 {
+ let mut h = seed;
+ h = h
+ .wrapping_mul(374761393)
+ .wrapping_add(x.wrapping_mul(668265263));
+ h = h
+ .wrapping_mul(374761393)
+ .wrapping_add(y.wrapping_mul(2654435761));
+ h ^= h >> 13;
+ h = h.wrapping_mul(1274126177);
+ h ^= h >> 16;
+ h
+}
+
+/// Noise value in -1.0..1.0 for pixel (x, y).
+fn noise(x: u32, y: u32, seed: u32) -> f32 {
+ (hash(x, y, seed) & 0xFFFF) as f32 / 32768.0 - 1.0
+}
+
+/// Base color + random per-pixel brightness variation.
+fn vary(base: [u8; 3], amount: i16, x: u32, y: u32, seed: u32) -> [u8; 4] {
+ let n = noise(x, y, seed);
+ let delta = (n * amount as f32) as i16;
+ let r = (base[0] as i16 + delta).clamp(0, 255) as u8;
+ let g = (base[1] as i16 + delta).clamp(0, 255) as u8;
+ let b = (base[2] as i16 + delta).clamp(0, 255) as u8;
[r, g, b, 255]
}
+/// Apply detail pattern to a pixel. `lx`/`ly` are tile-local coordinates.
+fn apply_detail(px: &mut [u8; 4], detail: &TileDetail, x: u32, lx: u32, ly: u32, seed: u32) {
+ match detail {
+ TileDetail::None => {}
+ TileDetail::Clumpy => {
+ if noise(lx / 4, ly / 4, seed ^ 0xBEEF) > 0.4 {
+ px[0] = px[0].saturating_sub(15);
+ px[1] = px[1].saturating_sub(8);
+ px[2] = px[2].saturating_sub(12);
+ }
+ if noise(x, ly, seed ^ 0xCAFE) > 0.85 {
+ px[1] = px[1].saturating_add(30);
+ }
+ }
+ TileDetail::Cracked => {
+ if noise(lx / 3, ly / 3, seed ^ 0xD00D).abs() < 0.08 {
+ px[0] = px[0].saturating_sub(35);
+ px[1] = px[1].saturating_sub(35);
+ px[2] = px[2].saturating_sub(35);
+ }
+ }
+ TileDetail::Wavy => {
+ let wave = ((ly as f32 * 0.4 + lx as f32 * 0.15).sin() * 0.5 + 0.5) * 20.0;
+ px[1] = px[1].saturating_add(wave as u8);
+ px[2] = px[2].saturating_add(wave as u8);
+ }
+ TileDetail::Speckled => {
+ if noise(x, ly, seed ^ 0xF00D) > 0.88 {
+ px[0] = px[0].saturating_add(25);
+ px[1] = px[1].saturating_add(20);
+ px[2] = px[2].saturating_add(15);
+ }
+ }
+ TileDetail::Frosty => {
+ if noise(lx / 5, ly / 5, seed ^ 0x1CE) > 0.3 {
+ px[2] = px[2].saturating_add(8);
+ px[0] = px[0].saturating_sub(5);
+ }
+ }
+ TileDetail::Grainy => {
+ if (ly as f32 * 1.2).sin().abs() < 0.15 {
+ px[0] = px[0].saturating_sub(20);
+ px[1] = px[1].saturating_sub(15);
+ px[2] = px[2].saturating_sub(10);
+ }
+ }
+ }
+}
+
+/// Generate a procedural tile texture into the atlas at the given row.
+fn fill_tile(rgba: &mut [u8], atlas_w: u32, y_start: u32, name: &str, tile_idx: u32) {
+ let seed = tile_idx.wrapping_mul(2654435761);
+ let style = tile_style(name);
+
+ for y in y_start..y_start + TILE_PX {
+ for x in 0..TILE_PX {
+ let off = ((y * atlas_w + x) * 4) as usize;
+ let ly = y - y_start;
+ let mut px = vary(style.base, style.variation, x, y, seed);
+ apply_detail(&mut px, &style.detail, x, x, ly, seed);
+ rgba[off..off + 4].copy_from_slice(&px);
+ }
+ }
+}
+
/// 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>) {
@@ -31,14 +198,8 @@ fn build_atlas(tileset: &[String]) -> (u32, u32, Vec<u8>) {
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);
- }
- }
+ fill_tile(&mut rgba, atlas_w, y_start, name, i as u32);
}
(atlas_w, atlas_h, rgba)