Draw hill shading as a smooth overlay

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

@@ -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)