feat: detailed building models across the city (Kenney City Kit, CC0)

Boxes with lines can't reach the requested fidelity — the city now uses
real modeled architecture, toonified into the cel style:

- Kenney City Kit Commercial (CC0, 41 GLBs) installed at
  assets/props/citykit: 13 mid-rise buildings + 5 glass skyscrapers with
  actual modeled window insets, doors, storefronts, awnings, trim and
  rooftop detail, plus parasol/awning props
- Standard + market blocks: street edges fill with seeded kit buildings
  (measured catalog drives placement, x13 scale -> 11-32m facades) over
  box colliders; Akiba signage layer (corner sign columns, glowing
  segments, roof billboards) attaches aligned to each model's bounds
- Tower blocks: kit skyscrapers (up to ~70m curtain-wall glass) on the
  walkable podium, seeded rotation, crown billboards kept
- Market blocks add cafe parasols among the stalls
- SSAO enabled + exposure recalibrated for the kit's bright albedo
  (sun 2.2 / ambient 1.7); lighter asphalt/paving to match; lighter haze

All kit meshes run through apply_toon_recursive: toon banding over the
kit textures, matching characters/weapons/VFX.

Verified: street + aerial captures (detailed facades at eye level, dense
believable skyline); movement 11/11, smoke 0, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-18 14:59:52 -04:00
co-authored by Claude Fable 5
parent b0af2ea9ee
commit 67040ba12d
44 changed files with 188 additions and 46 deletions
+160 -46
View File
@@ -42,8 +42,8 @@ const LAYOUT: Array[String] = [
]
# Palette
const ASPHALT := Color(0.3, 0.3, 0.37)
const PAVING := Color(0.47, 0.45, 0.51)
const ASPHALT := Color(0.38, 0.38, 0.45)
const PAVING := Color(0.56, 0.54, 0.6)
const STRIPE := Color(0.72, 0.72, 0.75)
const FACADES: Array[Color] = [
Color(0.74, 0.71, 0.66), Color(0.62, 0.63, 0.67), Color(0.68, 0.6, 0.5),
@@ -69,6 +69,117 @@ const SIGN_COLORS: Array[Color] = [
var _spawn_points: Array[Vector3] = []
var _rng := RandomNumberGenerator.new()
# ── Kenney City Kit catalog (CC0) — base sizes measured at import scale ─────
const KIT_DIR := "res://assets/props/citykit/"
const KIT_SCALE := 13.0
## name -> Vector3(facade_width, height, depth) at import scale
const KIT_BUILDINGS := {
"building-a": Vector3(0.884, 1.293, 0.94), "building-b": Vector3(0.97, 1.293, 0.94),
"building-c": Vector3(0.884, 0.893, 1.09), "building-d": Vector3(0.84, 1.293, 0.9),
"building-e": Vector3(1.64, 0.893, 1.008), "building-f": Vector3(0.84, 1.693, 1.03),
"building-g": Vector3(0.97, 1.693, 0.922), "building-h": Vector3(0.884, 1.293, 1.008),
"building-i": Vector3(1.24, 1.68, 1.302), "building-j": Vector3(2.084, 1.693, 1.34),
"building-k": Vector3(2.084, 1.47, 0.942), "building-l": Vector3(1.37, 2.27, 1.402),
"building-n": Vector3(2.32, 2.48, 1.82),
}
const KIT_SKYSCRAPERS := {
"building-skyscraper-a": Vector3(1.36, 2.88, 1.36), "building-skyscraper-b": Vector3(1.36, 4.48, 1.36),
"building-skyscraper-c": Vector3(1.28, 4.08, 1.388), "building-skyscraper-d": Vector3(1.28, 5.47, 1.388),
"building-skyscraper-e": Vector3(1.295, 4.08, 1.242),
}
var _kit_cache: Dictionary = {}
func _kit_scene(key: String) -> PackedScene:
if not _kit_cache.has(key):
_kit_cache[key] = load(KIT_DIR + key + ".glb")
return _kit_cache[key]
## Small visual-only kit prop (parasols, awnings): no collider needed.
func _kit_prop(key: String, pos: Vector3, yaw_deg: float, scale_f: float) -> void:
var inst: Node3D = _kit_scene(key).instantiate()
inst.scale = Vector3.ONE * scale_f
add_child(inst)
inst.global_position = pos
inst.rotation_degrees.y = yaw_deg
LevelMaterials.apply_toon_recursive(inst, 0.02)
## Place a kit model with a box collider matching its scaled bounds.
## front_pos: centre of the facade at ground level. yaw_deg: 0 faces +Z.
func _kit_building(key: String, front_pos: Vector3, yaw_deg: float, node_name: String,
scale_f: float = KIT_SCALE) -> float:
var dims: Vector3 = KIT_BUILDINGS.get(key, KIT_SKYSCRAPERS.get(key, Vector3.ONE))
var w := dims.x * scale_f
var h := dims.y * scale_f
var d := dims.z * scale_f
var fwd := Vector3(sin(deg_to_rad(yaw_deg)), 0, cos(deg_to_rad(yaw_deg)))
var center := front_pos - fwd * d * 0.5
var body := StaticBody3D.new()
body.name = node_name
body.set_meta("acoustic_material", "concrete")
add_child(body)
body.global_position = center
body.rotation_degrees.y = yaw_deg
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = Vector3(w, h, d)
shape.shape = bs
shape.position = Vector3(0, h * 0.5, 0)
body.add_child(shape)
var inst: Node3D = _kit_scene(key).instantiate()
inst.scale = Vector3.ONE * scale_f
body.add_child(inst)
LevelMaterials.apply_toon_recursive(inst, 0.0)
return w
## Fill one street-facing edge of a lot with kit buildings + Akiba signage.
func _kit_fill_side(front_x: float, dir: int, z0: float, z1: float, seed_id: String) -> void:
var yaw := -90.0 if dir > 0 else 90.0 # face the street (-x for east row)
var cursor := z0
var idx := 0
var keys := KIT_BUILDINGS.keys()
while z1 - cursor > 10.0:
var key: String = keys[_rng.randi() % keys.size()]
var w: float = KIT_BUILDINGS[key].x * KIT_SCALE
if w > z1 - cursor:
# find any model that still fits, else stop
var fits := false
for k2 in keys:
if KIT_BUILDINGS[k2].x * KIT_SCALE <= z1 - cursor:
key = k2
w = KIT_BUILDINGS[k2].x * KIT_SCALE
fits = true
break
if not fits:
break
var zc := cursor + w * 0.5
var h: float = KIT_BUILDINGS[key].y * KIT_SCALE
_kit_building(key, Vector3(front_x, 0, zc), yaw, "K%s_%d" % [seed_id, idx])
# Akiba signage layer, aligned to the model's measured bounds
if h >= 14.0:
var zs := cursor + 1.2
var col_h := h * 0.55
_box_static(Vector3(front_x - float(dir) * 0.45, h * 0.35, zs), Vector3(0.7, col_h, 1.4),
METAL, "K%s_%d_sc" % [seed_id, idx], "metal")
var segs := maxi(2, int(col_h / 3.4))
for s2 in segs:
var sy := h * 0.35 - col_h * 0.5 + col_h * (float(s2) + 0.5) / float(segs)
_deco_box(Vector3(front_x - float(dir) * 0.85, sy, zs), Vector3(0.12, 2.2, 1.2),
SIGN_COLORS[(s2 + idx + seed_id.hash()) % SIGN_COLORS.size()], true)
if h >= 20.0:
var bw := minf(w - 4.0, 10.0)
_box_static(Vector3(front_x + float(dir) * 2.0, h + 1.9, zc), Vector3(0.5, 3.0, bw),
METAL, "K%s_%d_bf" % [seed_id, idx], "metal")
_deco_box(Vector3(front_x + float(dir) * 1.7, h + 1.9, zc), Vector3(0.12, 2.4, bw - 0.6),
SIGN_COLORS[(idx + seed_id.hash()) % SIGN_COLORS.size()], true)
cursor += w
idx += 1
# ── Cel-styled statics + render-only decoration ──────────────────────────────
@@ -106,13 +217,18 @@ func _deco_box(pos: Vector3, size: Vector3, color: Color, emissive: bool = false
func _build_environment() -> void:
var env := LevelEnvironment.add_to(self, "sunset")
# Calibrated for the kit's bright albedo textures (they clip to white at
# the energies the old dark flat palette needed).
env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.environment.ambient_light_color = Color(0.58, 0.54, 0.66)
env.environment.ambient_light_energy = 3.0
env.environment.fog_density = 0.0011 # city haze for depth over 600m
env.environment.ambient_light_energy = 1.7
env.environment.fog_density = 0.0007 # light city haze for depth over 600m
# Ambient occlusion grounds the modeled facade detail (ZZZ-style depth)
env.environment.ssao_enabled = true
env.environment.ssao_intensity = 2.0
var sun := get_node_or_null("Sun")
if sun:
sun.light_energy = 3.0
sun.light_energy = 2.2
sun.rotation_degrees = Vector3(-38, 55, 0)
sun.directional_shadow_max_distance = 220.0
@@ -241,22 +357,11 @@ func _building(front_x: float, dir: int, z0: float, z1: float, depth: float,
## escapes (wallrun + climb route).
func _standard_block(c: Vector3, bx: int, bz: int) -> void:
var half := BLOCK * 0.5
var alley := _rng.randi() % 2 == 0
var gap := 4.0 if alley else 0.0
var depth := (BLOCK - gap) * 0.5
for side in 2:
var dirv := 1 if side == 0 else -1 # 0: faces west street, body +x
var front := c.x - float(dirv) * half
# Two buildings along z per face
var split := c.z + _rng.randf_range(-6.0, 6.0)
var f1 := 2 + _rng.randi() % 4
var f2 := 2 + _rng.randi() % 4
var col1 := FACADES[_rng.randi() % FACADES.size()]
var col2 := FACADES[_rng.randi() % FACADES.size()]
_building(front, dirv, c.z - half, split, depth, f1, col1, "%d_%d_%d0" % [bx, bz, side])
_building(front, dirv, split, c.z + half, depth, f2, col2, "%d_%d_%d1" % [bx, bz, side])
if alley:
# Fire escapes climbing into the mid-block alley
# Detailed kit buildings fill both street-facing edges
_kit_fill_side(c.x - half, 1, c.z - half, c.z + half, "%d_%dW" % [bx, bz])
_kit_fill_side(c.x + half, -1, c.z - half, c.z + half, "%d_%dE" % [bx, bz])
# Fire escapes into the mid-block gap on some blocks
if _rng.randi() % 2 == 0:
for f in range(1, 3):
var y := float(f) * FLOOR_H - 0.4
_box_static(c + Vector3(-2.0 + float(f) * 1.3, y, 4.0), Vector3(2.2, 0.16, 2.2), METAL,
@@ -267,29 +372,36 @@ func _standard_block(c: Vector3, bx: int, bz: int) -> void:
## Landmark: an 8-10 floor tower with a retail podium and mega-billboards.
func _tower_block(c: Vector3, bx: int, bz: int) -> void:
var floors := 8 + _rng.randi() % 3
var h := float(floors) * FLOOR_H
var col := SEGA_RED if _rng.randi() % 3 == 0 else (ELEC_BLUE if _rng.randi() % 2 == 0 else FACADES[_rng.randi() % FACADES.size()])
var n := "Tower_%d_%d" % [bx, bz]
# Podium (1 floor, walkable roof ring)
var col := SEGA_RED if _rng.randi() % 3 == 0 else (ELEC_BLUE if _rng.randi() % 2 == 0 else FACADES[_rng.randi() % FACADES.size()])
# Podium (1 floor, walkable roof ring) with glass front
_box_static(c + Vector3(0, FLOOR_H * 0.5, 0), Vector3(BLOCK - 4.0, FLOOR_H, BLOCK - 4.0), col.darkened(0.15), n + "_Podium", "concrete")
_box_static(c + Vector3(0, 2.0, -(BLOCK - 4.0) * 0.5 + 0.1), Vector3(BLOCK - 8.0, 3.0, 0.25), GLASS, n + "_Glass", "glass")
# Tower shaft
var tw := BLOCK - 20.0
_box_static(c + Vector3(0, FLOOR_H + (h - FLOOR_H) * 0.5, 0), Vector3(tw, h - FLOOR_H, tw), col, n, "concrete")
# Window + sign bands on two faces
for f in range(2, floors):
var y := float(f) * FLOOR_H
_deco_box(c + Vector3(-tw * 0.5 - 0.1, y + 1.7, 0), Vector3(0.14, 2.0, tw - 3.0), WINDOW)
_deco_box(c + Vector3(tw * 0.5 + 0.1, y + 1.7, 0), Vector3(0.14, 2.0, tw - 3.0), WINDOW)
# Mega vertical sign on the tower corner
_box_static(c + Vector3(-tw * 0.5 - 0.5, FLOOR_H + (h - FLOOR_H) * 0.45, -tw * 0.5 + 1.5),
Vector3(0.8, (h - FLOOR_H) * 0.8, 1.6), METAL, n + "_SignCol", "metal")
for s2 in floors - 2:
var sy := FLOOR_H * 2.0 + (h - FLOOR_H * 3.0) * float(s2) / float(maxi(floors - 3, 1))
_deco_box(c + Vector3(-tw * 0.5 - 0.95, sy, -tw * 0.5 + 1.5), Vector3(0.14, 2.4, 1.4),
SIGN_COLORS[s2 % SIGN_COLORS.size()], true)
# Rooftop crown billboard both directions
# Detailed kit skyscraper on the podium
var keys := KIT_SKYSCRAPERS.keys()
var key: String = keys[_rng.randi() % keys.size()]
var dims: Vector3 = KIT_SKYSCRAPERS[key]
var s := (BLOCK - 22.0) / dims.x # shaft footprint ~26m
var h := dims.y * s + FLOOR_H
var yaw := float(90 * (_rng.randi() % 4))
var body := StaticBody3D.new()
body.name = n
body.set_meta("acoustic_material", "concrete")
add_child(body)
body.global_position = c + Vector3(0, FLOOR_H, 0)
body.rotation_degrees.y = yaw
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = Vector3(dims.x * s, dims.y * s, dims.z * s)
shape.shape = bs
shape.position = Vector3(0, dims.y * s * 0.5, 0)
body.add_child(shape)
var inst: Node3D = _kit_scene(key).instantiate()
inst.scale = Vector3.ONE * s
body.add_child(inst)
LevelMaterials.apply_toon_recursive(inst, 0.0)
# Rooftop crown billboard both directions (skyline wayfinding)
var tw := dims.x * s
_box_static(c + Vector3(0, h + 2.4, 0), Vector3(0.6, 4.0, tw - 4.0), METAL, n + "_Crown", "metal")
_deco_box(c + Vector3(0.45, h + 2.4, 0), Vector3(0.12, 3.4, tw - 4.6), SIGN_COLORS[(bx + bz) % SIGN_COLORS.size()], true)
_deco_box(c + Vector3(-0.45, h + 2.4, 0), Vector3(0.12, 3.4, tw - 4.6), SIGN_COLORS[(bx + bz + 2) % SIGN_COLORS.size()], true)
@@ -330,11 +442,13 @@ func _shrine_block(c: Vector3, bx: int, bz: int) -> void:
## Market street: two dense stall rows with lantern strings — low cover maze.
func _market_block(c: Vector3, bx: int, bz: int) -> void:
var n := "Mkt_%d_%d" % [bx, bz]
# Back walls: modest 2-floor buildings on east/west faces
_building(c.x - BLOCK * 0.5, 1, c.z - BLOCK * 0.5, c.z + BLOCK * 0.5, 10.0, 2,
FACADES[_rng.randi() % FACADES.size()], "%d_%dmw" % [bx, bz])
_building(c.x + BLOCK * 0.5, -1, c.z - BLOCK * 0.5, c.z + BLOCK * 0.5, 10.0, 2,
FACADES[_rng.randi() % FACADES.size()], "%d_%dme" % [bx, bz])
# Back walls: detailed kit buildings on east/west faces
_kit_fill_side(c.x - BLOCK * 0.5, 1, c.z - BLOCK * 0.5, c.z + BLOCK * 0.5, "%d_%dmw" % [bx, bz])
_kit_fill_side(c.x + BLOCK * 0.5, -1, c.z - BLOCK * 0.5, c.z + BLOCK * 0.5, "%d_%dme" % [bx, bz])
# Cafe parasols scattered among the stalls
for pi in 3:
var pp := c + Vector3(_rng.randf_range(-9.0, 9.0), 0.1, _rng.randf_range(-12.0, 12.0))
_kit_prop(["detail-parasol-a", "detail-parasol-b"][pi % 2], pp, _rng.randf_range(0.0, 360.0), 6.0)
# Stall rows down the middle
for r in 2:
var x := c.x + (-5.0 if r == 0 else 5.0)