Active development diary for Urban Sprawl
After the terrain refactor and the road/vehicle realism work, one piece of the city kept reading as cartoony from the isometric view: the buildings themselves. The first-person camera looked great because POM, interior mapping, full SSAO, and short-range detail systems were all active. But from above, every building was a flat-colored prism with a bright-cardboard top. We pulled the thread on why FPV looked so much better, then mapped that diagnosis onto a structured fix.
Three things were active in FPV but disabled in isometric:
On top of that, the base look itself was stylized: 12-vertex cube meshes, saturated zone-coded colors (LEGO-blue commercial, terracotta residential), no per-building variation, no weathering. Every brick building looked exactly like every other brick building.
Five tiers, ordered cheapest-first so each is independently shippable. The user picked all three plus a palette dirty-down, with a "weather it instead of changing the colors" preference (later upgraded to "actually do both"). Phase 1 was the shader-only pass.
Single shader file (building_instanced.wgsl) plus a small palette tweak in
building_instances.rs. No new uniforms, no new bind groups, no new textures — just
a per-fragment math block that runs after texture sampling and before lighting.
lot_index (already in extra.y) via PCG-style integer mix. Drives ±8% brightness jitter and a small warm/cool hue shift — two adjacent buildings drawn from the same palette entry now look measurably differentworld_normal.y > 0.85), darkening to ~50% with a procedural mottle and warm tar tint. The single biggest win for the isometric view — kills the bright-color-on-top effect that screamed "1990 graphics"
Phase 1 was good, but the roof darkening fix exposed the real problem: the visible "roofs" weren't actually
the building bodies' top faces. They were roof cap entities — separate flat slabs spawned
by rooftop_details.rs with their own StandardMaterial. The Phase 1 building shader
never touched them. They were getting rendered with bright tan/cream StandardMaterial base colors
and lit head-on by the midday sun, looking like sheets of cardboard laid on top of every building.
The fix: a dedicated RoofMaterial = ExtendedMaterial<StandardMaterial, RoofExtension> with
a custom WGSL fragment shader, modeled on the existing asphalt material pattern.
roof.wgsl: tar/membrane base color (linear-space, dramatically darker than the previous palette) · per-instance brightness jitter · high-frequency gravel speckle · sparse pebble highlights · large weather stains (water shadow / soot / mildew) · sun-bleached patches · membrane seam lines every 3.5m in both axes · equipment scuff zones · industrial corrugation ribs (per-archetype) · wet response with Schlick Fresnel sky reflection and rain ripples · winter snow accumulation with drift variation and heat-loss melt zones around equipment.
The shader pulls live WeatherState wetness, rain intensity, and a season-derived snow factor
each frame via two sync systems. Sky reflection colors come from the same SkyReflectionColors
resource the asphalt material already uses, so wet roofs and wet roads pick up the same sky tint.
Once RoofMaterial was working, the next round of testing immediately exposed three deeper bugs.
The user sent screenshots showing rowhouses, low-density commercial, industrial, and high-density commercial
towers all still looking flat. Different reasons in each case:
Building entity for houses spawned by house_factory has no Mesh3d component. The actual roof slab is a child entity inside spawn_rowhouse_body. The rooftop_details system queries for (With<Building>, With<Mesh3d>), so the parent never matches and the child never gets touched. Fixed by tagging the slab with a RowhouseRoofSlab marker and adding a replace_rowhouse_roof_slabs system that swaps the MeshMaterial3d<StandardMaterial> for a MeshMaterial3d<RoofMaterial> after spawn. Marker-based instead of threading the palette through five layers of non-system functionscommercial_crowns.rs as separate entities with their own StandardMaterial. The sides should keep the building facade material (limestone/concrete), but the tops needed roof material. Fixed by spawning a thin sibling RoofMaterial cap entity on top of each tier inside the same spawn loopis_roof block to mirror the RoofMaterial vocabulary: gravel speckle, seam lines, industrial corrugation ribs — same look without needing separate cap entities
All three fixes share a new RoofMaterialPalette resource: 18 pre-built materials (6 variants
× 3 archetypes) accessible from any spawn site as Res<RoofMaterialPalette>. Picks via
palette.pick(archetype, lot_index). Keeps the city to ~18 draw calls for all roof caps
instead of one-material-per-building.
While testing, the user hit a panic mid-session:
Graph::add_edge: node indices out of bounds. Took some digging to track down — turned out
to be a long-standing latent bug in RoadGraph::remove_node that the Phase 1 work had nothing
to do with.
petgraph's Graph::remove_node is a swap-remove: when you remove the node at index idx,
the node previously at the last index gets moved into idx's slot. Our RoadGraph kept
a separate spatial index (node_positions: Vec<(NodeIndex, Vec2)>) to make
find_nearest() fast, and remove_node was correctly dropping the entry for the
removed node — but never updating the entry that pointed at the now-invalid last_idx.
merge_collinear_around collapses a degree-2 pass-through node → remove_node swap-removes → spatial index entry for old last_idx is now stale → next find_nearest() returns garbage NodeIndex → add_edge panics with "node indices out of bounds". Only triggered when the removed node wasn't already at the end of the node list, which is why it had been hiding for a while.
Fixed by snapshotting last_idx_before before the petgraph call and re-tagging the spatial entry
afterward. Also wrapped TiltShiftConfig in Option in the dev panel because
TiltShiftPlugin had been disabled at the plugin level but the dev panel still hard-required
it, spamming Bevy 0.18's stricter resource validation warnings every frame.
The dominant LEGO effect from isometric is now gone. Adjacent buildings of the same archetype look meaningfully different from each other, roofs read as actual tar and gravel instead of bright cardboard, and storms make the whole city look genuinely wet. Time for a break.
This is the biggest single change to Urban Sprawl's renderer since the project began. We ripped out the entire procedural generation pipeline, rebuilt the terrain system from scratch, and added real geographic features — rolling hills, coastal shores, river valleys, islands, and plateaus. The city no longer floats on a flat plane; it sits on the land.
After the initial implementation, we had a nasty periodic stutter — the game would freeze for ~200ms every couple of seconds. The root cause was terrain mesh regeneration. Every time a building spawned (which sets the terrain dirty flag), the system would regenerate the entire terrain mesh: 1,001,001 vertices, each requiring ~27 Perlin noise evaluations plus an O(N) scan through every terrain modification region.
Three changes eliminated the stutter entirely:
apply_at() now only checks nearby regions instead of scanning every region for every vertex
After shipping the terrain shader, every map looked brown instead of green. The culprit: WGSL's smoothstep()
is undefined when the low edge ≥ high edge. Several of our smoothstep calls had reversed arguments
(e.g., smoothstep(0.25, 0.15, x)), which returned garbage values on most GPUs.
The grass slope weight was always ~0, so dirt dominated everywhere. Fixed by rewriting all weight calculations
with correct argument ordering.
src/procgen/ directory (14 files) — the old procedural city generation pipelinesrc/world/HeightfieldCache resource patternrebuild_heightfield_cache_on_config_change system to keep cache synchronized when map size changesA 4-week focused sprint to push Urban Sprawl's visuals from "tech demo" to "cinematic." Added quality tier presets (Low/Medium/High via Shift+F10), a first-person walk camera, procedural sky atmosphere, wet surface reflections, PBR glass, and a full cinematic polish pass with SSAO, film grain, vignette, and auto-exposure.
A 7-phase performance initiative to scale the city from a single 1km² sandbox to Cities: Skylines 2-scale maps. Introduced world chunk streaming (250m chunks), entity pooling, macro simulation for dormant chunks, traffic CA partitioning, and configurable map sizes from 500m to 7.5km.