Draw hill shading as a smooth overlay
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:37 UTC
- commit
- 4ee3e12f1395426aa682b0d09d69b4b1b600b9d5
- parent
- 2bf37c74db
- tree
- browse at this commit
3 files changed +81 -5
| CLAUDE.md | +1 -1 | |
| src/tilemap.rs | +78 -2 | |
| tasks.md | +2 -2 |
CLAUDE.md +1 -1
| @@ -17,7 +17,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 17 | 17 | - `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable; Gravel = mined floor, Path/Paved from traffic, never generated), `Plant` enum (Tree, BerryBush, Crop; separate layer with per-tile growth 0..1, adds move cost; `ripe_plant`). Worldgen scatters berry bushes (2%) with random growth. `WorldMap` resource: per-tile terrain, `Option<Plant>`, wall flag (blocks movement), roof flag, plant growth, traffic wear, height in meters (0..`MAX_HEIGHT`=100); row-major, (0,0)=bottom-left, y up. `move_cost(pos)` = terrain + plant cost (None = impassable); `step_cost(from,to)` adds `UPHILL_COST_PER_METER` × climb — use step_cost for anything that moves. `TILE_SIZE`=16 world units, `hash2` deterministic int hash. |
| 18 | 18 | - `src/noise.rs` seeded 2D gradient (Perlin-style, quintic fade) noise + `fbm` (octave i uses seed+i). Range [-1,1]. |
| 19 | 19 | - `src/worldgen.rs` `WorldGenPlugin` inserts `WorldSeed` + `WorldMap` (256x256) at build time and writes `WorldGenerated` message at Startup; R → seed+1, regenerate, write message. `generate(size, seed)`: elevation fbm → height (min-max normalised to meters) and deep/shallow/sand/rock by **quantile** thresholds (fixed terrain shares per seed); ore = quantiles of two ore noises among rock tiles (gold 3%, iron 10%); fertility fbm top 20% → soil; rivers; trees (forest noise top 35% → 45% tree chance, else 3%; only Grass/Soil, placed after rivers). Rivers: `FlowField` = priority flood from water + non-rock edge tiles over non-rock tiles, each tile gets a downstream neighbour (spills out of depressions at lowest rim); `pick_rivers` traces 40 random mountain-foot sources, keeps the 4 longest spaced ≥40 apart; `carve_river` → ShallowWater, wider downstream, never over rock/deep water. `largest_region` (4-conn flood of walkable), `spawn_tiles(map, n)` = BFS from largest-region tile nearest map center. Everything reacting to a new world runs `.run_if(on_message::<WorldGenerated>)`: tilemap refresh, colonist respawn, camera recenter, selection clear. |
| 20 | -- `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Chunk layers (`Layer::Terrain` z=0, `Layer::Zones` z=0.3, `Layer::Plants` z=0.5 (tileset per `PlantLook`: tree, bush, ripe bush, sprout, young crop, ripe crop), `Layer::Roofs` z=0.85 translucent striped overlay, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (full on WorldGenerated, per-chunk on `TileChanged`) (bevy's `update_tilemap_chunk_indices` re-uploads via change detection). | |
| 20 | +- `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Chunk layers (`Layer::Terrain` z=0, `Layer::Zones` z=0.3, `Layer::Plants` z=0.5 (tileset per `PlantLook`: tree, bush, ripe bush, sprout, young crop, ripe crop), `Layer::Roofs` z=0.85 translucent striped overlay, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × SHADE_FLAT × height brightness; hill shading is a separate `HillShade` mesh over the whole map (z 0.1) with a one-texel-per-tile black-alpha texture (`shadow_alpha(hillshade)`, light from NW, `RELIEF`), linearly sampled so slopes look smooth, water transparent, rebuilt on WorldGenerated; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (full on WorldGenerated, per-chunk on `TileChanged`) (bevy's `update_tilemap_chunk_indices` re-uploads via change detection). | |
| 21 | 21 | - `src/camera.rs` `CameraController` on Camera2d: WASD/arrows pan (shift = fast, speed scaled by ortho scale), middle/right drag pan (from cursor delta, not AccumulatedMouseMotion — that doesn't fire for web), scroll zoom toward cursor (`zoom_towards`), camera center clamped to world rect. |
| 22 | 22 | - `src/selection.rs` (`update_cursor` is pub; other modules order `.after(update_cursor)`) `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, colonist count, material and food stock-group/learned-goal lines, recent colony events, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Progress bars for Work/ExpandStockpile/Build (`progress_fraction`). Clock shows day/dawn/dusk/night; hover shows structures with remaining fuel, plant growth, farm, animals. `describe_colonist(&ColonistView)` includes rest and food. Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`. |
| 23 | 23 | - `src/pathfinding.rs` generic `astar(map, start, is_goal, heuristic)` (start may itself be blocked so movers can leave a tile that became a wall; `follow_path` also drops a route whose next tile became impassable), 8-dir, no corner cutting, cost = (10|14) * `map.step_cost` (100 = normal flat grass), octile heuristic. `find_path(start, goal)`, `find_path_adjacent(start, target)` (ends on a tile Chebyshev-adjacent to target; works for impassable targets; empty if already adjacent), `nearest_tile(start, max_tiles, pred)` BFS over walkable tiles (bitmap visited set). `nearest_of(start, max_tiles, &HashSet targets)` = nearest_tile over a precollected target set, returning None at once when no target is within `search_reach(max_tiles)` Chebyshev — use it instead of predicates that scan all items per visited tile (that was the dominant per-tick cost). Paths exclude start, include goal. |
src/tilemap.rs +78 -2
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | use bevy::{ |
| 2 | 2 | asset::RenderAssetUsages, |
| 3 | + image::ImageSampler, | |
| 3 | 4 | prelude::*, |
| 4 | 5 | render::render_resource::{Extent3d, TextureDimension, TextureFormat}, |
| 5 | 6 | sprite_render::{AlphaMode2d, TileData, TilemapChunk, TilemapChunkTileData}, |
| @@ -16,6 +17,9 @@ pub const CHUNK_SIZE: u32 = 64; | ||
| 16 | 17 | const TEXTURE_SIZE: u32 = 16; |
| 17 | 18 | const RELIEF: f32 = 0.35; |
| 18 | 19 | const LIGHT: Vec3 = Vec3::new(-1.0, 1.0, 1.5); |
| 20 | +const SHADE_FLAT: f32 = 1.1; | |
| 21 | +const MAX_SHADOW: f32 = 0.5; | |
| 22 | +const HILL_SHADE_Z: f32 = 0.1; | |
| 19 | 23 | |
| 20 | 24 | type Texture = Vec<[u8; 4]>; |
| 21 | 25 | |
| @@ -29,6 +33,7 @@ impl Plugin for TilemapPlugin { | ||
| 29 | 33 | refresh_chunks.run_if(world_or_light_changed), |
| 30 | 34 | refresh_changed_chunks, |
| 31 | 35 | refresh_zone_chunks.run_if(resource_changed::<Zones>), |
| 36 | + refresh_hill_shade.run_if(on_message::<WorldGenerated>), | |
| 32 | 37 | ), |
| 33 | 38 | ); |
| 34 | 39 | } |
| @@ -74,13 +79,75 @@ struct Chunk { | ||
| 74 | 79 | layer: Layer, |
| 75 | 80 | } |
| 76 | 81 | |
| 82 | +#[derive(Component)] | |
| 83 | +struct HillShade; | |
| 84 | + | |
| 85 | +pub fn shadow_alpha(shade: f32) -> u8 { | |
| 86 | + ((SHADE_FLAT - shade).clamp(0.0, MAX_SHADOW) * 255.0) as u8 | |
| 87 | +} | |
| 88 | + | |
| 89 | +fn hill_shade_image(map: &WorldMap) -> Image { | |
| 90 | + let mut data = Vec::with_capacity((map.size.element_product() * 4) as usize); | |
| 91 | + for row in 0..map.size.y { | |
| 92 | + let y = map.size.y - 1 - row; | |
| 93 | + for x in 0..map.size.x { | |
| 94 | + let pos = UVec2::new(x, y); | |
| 95 | + let water = map.get(pos.as_ivec2()).is_some_and(|t| t.is_water()); | |
| 96 | + let alpha = if water { | |
| 97 | + 0 | |
| 98 | + } else { | |
| 99 | + shadow_alpha(hillshade(map, pos)) | |
| 100 | + }; | |
| 101 | + data.extend_from_slice(&[0, 0, 0, alpha]); | |
| 102 | + } | |
| 103 | + } | |
| 104 | + let mut image = Image::new( | |
| 105 | + Extent3d { | |
| 106 | + width: map.size.x, | |
| 107 | + height: map.size.y, | |
| 108 | + depth_or_array_layers: 1, | |
| 109 | + }, | |
| 110 | + TextureDimension::D2, | |
| 111 | + data, | |
| 112 | + TextureFormat::Rgba8UnormSrgb, | |
| 113 | + RenderAssetUsages::default(), | |
| 114 | + ); | |
| 115 | + image.sampler = ImageSampler::linear(); | |
| 116 | + image | |
| 117 | +} | |
| 118 | + | |
| 119 | +fn refresh_hill_shade( | |
| 120 | + map: Res<WorldMap>, | |
| 121 | + shade: Single<&MeshMaterial2d<ColorMaterial>, With<HillShade>>, | |
| 122 | + materials: Res<Assets<ColorMaterial>>, | |
| 123 | + mut images: ResMut<Assets<Image>>, | |
| 124 | +) { | |
| 125 | + if let Some(texture) = materials.get(&shade.0).and_then(|m| m.texture.clone()) { | |
| 126 | + let _ = images.insert(&texture, hill_shade_image(&map)); | |
| 127 | + } | |
| 128 | +} | |
| 129 | + | |
| 130 | +#[allow(clippy::too_many_arguments)] | |
| 77 | 131 | fn spawn_chunks( |
| 78 | 132 | mut commands: Commands, |
| 79 | 133 | map: Res<WorldMap>, |
| 80 | 134 | zones: Res<Zones>, |
| 81 | 135 | light: Res<LightMap>, |
| 82 | 136 | mut images: ResMut<Assets<Image>>, |
| 137 | + mut meshes: ResMut<Assets<Mesh>>, | |
| 138 | + mut materials: ResMut<Assets<ColorMaterial>>, | |
| 83 | 139 | ) { |
| 140 | + let shade = images.add(hill_shade_image(&map)); | |
| 141 | + commands.spawn(( | |
| 142 | + HillShade, | |
| 143 | + Name::new("Hill shade"), | |
| 144 | + Mesh2d(meshes.add(Rectangle::from_size(map.world_size()))), | |
| 145 | + MeshMaterial2d(materials.add(ColorMaterial { | |
| 146 | + texture: Some(shade), | |
| 147 | + ..default() | |
| 148 | + })), | |
| 149 | + Transform::from_translation((map.world_size() / 2.0).extend(HILL_SHADE_Z)), | |
| 150 | + )); | |
| 84 | 151 | let terrain = images.add(stack_tileset( |
| 85 | 152 | Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(), |
| 86 | 153 | )); |
| @@ -213,7 +280,7 @@ pub fn plant_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Optio | ||
| 213 | 280 | .map(|pos| { |
| 214 | 281 | map.plant(pos.as_ivec2()).map(|plant| TileData { |
| 215 | 282 | tileset_index: PlantLook::of(plant, map.growth(pos.as_ivec2())) as u16, |
| 216 | - color: tint(jitter(pos) * hillshade(map, pos), light.color(pos)), | |
| 283 | + color: tint(jitter(pos), light.color(pos)), | |
| 217 | 284 | ..default() |
| 218 | 285 | }) |
| 219 | 286 | }) |
| @@ -231,7 +298,7 @@ fn jitter(pos: UVec2) -> f32 { | ||
| 231 | 298 | fn shade(map: &WorldMap, pos: UVec2) -> f32 { |
| 232 | 299 | match map.get(pos.as_ivec2()) { |
| 233 | 300 | Some(t) if t.is_water() => 1.0, |
| 234 | - _ => hillshade(map, pos) * height_brightness(map, pos), | |
| 301 | + _ => SHADE_FLAT * height_brightness(map, pos), | |
| 235 | 302 | } |
| 236 | 303 | } |
| 237 | 304 | |
| @@ -571,6 +638,15 @@ mod tests { | ||
| 571 | 638 | // wgpu's GLES backend (WebGL2) guesses the texture view dimension from the layer count: |
| 572 | 639 | // 1 layer => D2, 6 => Cube, multiples of 6 => CubeArray. The tilemap shader needs D2Array, |
| 573 | 640 | // so the tileset is padded with an unused layer to dodge those counts. |
| 641 | + // Shading is drawn as a smooth, linearly filtered overlay instead of per-tile tints, which | |
| 642 | + // looked blocky up close; slopes facing away from the light get the darkest shadow. | |
| 643 | + #[test] | |
| 644 | + fn shadows_darken_slopes_facing_away_from_the_light() { | |
| 645 | + assert_eq!(shadow_alpha(1.25), 0); | |
| 646 | + assert!(shadow_alpha(0.7) > shadow_alpha(1.0)); | |
| 647 | + assert_eq!(shadow_alpha(0.0), (MAX_SHADOW * 255.0) as u8); | |
| 648 | + } | |
| 649 | + | |
| 574 | 650 | #[test] |
| 575 | 651 | fn tileset_layer_count_avoids_webgl_view_dimension_heuristics() { |
| 576 | 652 | for n in 0..50 { |
tasks.md +2 -2
| @@ -34,9 +34,9 @@ | ||
| 34 | 34 | - [x] Mood (needs, meals, partner, home, sickness, grief), food poisoning from raw food, unhappy colonists leave, ageing and death of old age |
| 35 | 35 | - [x] Colonist panel: roster with mood faces (click to select) and per-colonist work type toggles |
| 36 | 36 | - [x] Stockpile filters and priority per connected stockpile; stock target overrides in the panel |
| 37 | -- [ ] Re-haul items from low to high priority stockpiles | |
| 37 | +- [x] Smooth hill shading overlay | |
| 38 | 38 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 39 | 39 | |
| 40 | 40 | ## Planned |
| 41 | +- [ ] Re-haul items from low to high priority stockpiles | |
| 41 | 42 | - [ ] Sim performance: per-tile scans in `BuildContext::buildable` and site searches; wasm ticks still ~1 ms each with a full colony |
| 42 | -- [ ] Smoother hill shading at close zoom (per-tile shading looks blocky) | |