Integrate the Blender character, gear and prop library

NpcAvatar
- select one of 8 library character variants by role/seed (with npc_base and the
  procedural rig as fallbacks), keep Rig/Head and the existing bone animation
- tint only skin/cloth/pants/accessory/hair; fixed detail materials keep their
  colour, npc_vest keeps its hi-vis vest, firefighter/guard use dark uniform tints
- attach caps, hoods, helmets, backpacks, SCBA tanks, lanyards and hand items at
  the documented head/back/chest/hand points (primitive fallbacks retained)

WorldBuilder
- prefer assets/models/blender/props/ for the six replacements and the new
  street dressing (street lamp/tree, palms, planters, A-frame, cone, barrier,
  stanchion, podium, handrail, wall lantern, double door, marquee, sedan)
- fit the same colliders/navigation obstacles and keep the placement validator;
  collect library emissives (bulbs, marquee face, wall lanterns, door LED, sedan
  lights) for set_night_mode()
- add a library triangle counter + 60k budget check (current ~27,986); toggle
  prop gallery to the library models; fix the marquee label placement

Tests/smoke
- character selection/gear attach/fixed-material tint tests; library prop load,
  emissive and triangle-budget tests; smoke_props routes a guest around a
  stanchion in open sidewalk (the old bench route is now crowded by trees)
- headless 222/222, Python 68/68, all smokes pass, compare 11 good / 0 bad

Docs: TODO Phase 3.5 moved to DONE, README/architecture/blender-assets/plan
describe the integrated runtime, MEMORY records the integration rules.
This commit is contained in:
zwitschi committed 2026-11-01 14:57:02 +01:00
1 parent d513e0b82c
commit d8cfddbf89
23 files changed
+531 -165

No files matched your search

+154 -56
View File
@@ -14,8 +14,14 @@ const T_SKYLINE := preload("res://assets/textures/skyline.png")
## Generated prop models, one `.glb` per street-furniture piece.
const MODEL_DIR := "res://assets/models/generated/"
const BLENDER_PROP_DIR := "res://assets/models/blender/props/"
const MODEL_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant"]
## Library props loaded in place of the generated set, plus new street dressing.
const LIBRARY_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
"newspaper_box", "fire_hydrant", "street_lamp", "street_tree", "traffic_cone",
"palm_tree", "round_planter", "a_frame_sign", "crowd_barrier", "stanchion",
"security_podium"]
var root: Node3D = null
## Door lock indicator mesh near the handle; toggled by GameManager.
@@ -33,6 +39,11 @@ var _window_mats: Array[StandardMaterial3D] = []
## not listed), so tests and the debug overlay can report real model usage.
var loaded_models: Array[String] = []
var fallback_props: Array[String] = []
## Props that came from the Blender library rather than the generated set.
var library_props: Array[String] = []
var library_nodes: Array[Node3D] = []
## Total triangle budget for the Blender library pieces placed in the world.
const LIBRARY_TRIANGLE_BUDGET := 60000
func _init(root_node: Node3D) -> void:
root = root_node
@@ -48,6 +59,7 @@ func build() -> void:
_build_crosswalk()
_build_street_furniture()
_build_street_detail()
_build_library_dressing()
_build_guard_spawn()
_build_navmesh()
set_night_mode(false)
@@ -106,6 +118,16 @@ func _build_building() -> void:
# Door lock indicator (green = open, red = locked), placed beside the handle
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
# Blender glazed double door in front of the recessed frame.
var door_model := _load_prop_scene("double_door")
if door_model != null:
door_model.name = "LibraryDoubleDoor"
door_model.position = Vector3(0, 0.0, -3.84)
building.add_child(door_model)
library_nodes.append(door_model)
for material in _emissive_materials(door_model):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
_build_facade_windows(building)
var col := BoxShape3D.new()
col.size = Vector3(40, 18, 4)
@@ -257,21 +279,32 @@ func _build_skyline_backdrop() -> void:
var _skyline_mat: StandardMaterial3D = null
func _build_marquee() -> void:
## Striped awning above the door, then the neon sign strip + label.
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), true, T_AWNING, Vector2(4, 1), "awning")
awning.name = "Awning"
## Neon-style marquee strip above the door: emissive bars + a label.
var strip_parent := Node3D.new()
strip_parent.name = "Marquee"
for offset in [-2.2, 2.2]:
var bar := _add_box(strip_parent, Vector3(0.25, 0.5, 0.5), Vector3(offset, 7.4, -3.6), Color(1, 0.35, 0.75))
var mat := _emissive_material(Color(1.0, 0.35, 0.75))
bar.material_override = mat
_marquee_emissive.append(mat)
## Blender canopy + sign box above the door; the procedural striped awning and
## neon bars remain only as a fallback when the library asset is missing.
var library := _load_prop_scene("marquee")
if library != null:
library.name = "LibraryMarquee"
library.position = Vector3(0, 5.62, -3.9)
root.add_child(library)
library_nodes.append(library)
for material in _emissive_materials(library):
if not _marquee_emissive.has(material):
_marquee_emissive.append(material)
else:
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), true, T_AWNING, Vector2(4, 1), "awning")
awning.name = "Awning"
var strip_parent := Node3D.new()
strip_parent.name = "Marquee"
for offset in [-2.2, 2.2]:
var bar := _add_box(strip_parent, Vector3(0.25, 0.5, 0.5), Vector3(offset, 7.4, -3.6), Color(1, 0.35, 0.75))
var mat := _emissive_material(Color(1.0, 0.35, 0.75))
bar.material_override = mat
_marquee_emissive.append(mat)
root.add_child(strip_parent)
marquee_label = Label3D.new()
marquee_label.name = "MarqueeLabel"
marquee_label.text = "GIG LINE"
marquee_label.position = Vector3(0, 7.3, -3.55)
marquee_label.position = Vector3(0, 6.45, -2.12)
marquee_label.modulate = Color(1, 0.6, 0.9)
marquee_label.font_size = 160
marquee_label.pixel_size = 0.004
@@ -279,61 +312,110 @@ func _build_marquee() -> void:
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
marquee_label.no_depth_test = false
strip_parent.add_child(marquee_label)
root.add_child(strip_parent)
root.add_child(marquee_label)
## Venue dressing that is not part of the sidewalk prop table: a stair handrail,
## a parked sedan, and wall lanterns. Kept separate so the placement validator
## only sees grounded sidewalk furniture.
func _build_library_dressing() -> void:
if ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"):
var rail := _load_prop_scene("handrail")
if rail != null:
rail.name = "LibraryHandrail"
rail.position = Vector3(3.35, 0.2, 3.95)
rail.rotation_degrees.y = 90.0
root.add_child(rail)
library_nodes.append(rail)
if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"):
var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0)
sedan.name = "ParkedSedan"
if not library_nodes.has(sedan):
library_nodes.append(sedan)
for material in _emissive_materials(sedan):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
if ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"):
for side in [-4.9, 4.9]:
var lantern := _load_prop_scene("wall_lantern")
if lantern == null:
continue
lantern.name = "WallLantern"
lantern.position = Vector3(side, 3.3, -3.78)
root.add_child(lantern)
library_nodes.append(lantern)
for material in _emissive_materials(lantern):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
## Emissive materials under a prop, used to tie library bulbs/LEDs to night mode.
func _emissive_materials(holder: Node3D) -> Array[StandardMaterial3D]:
var out: Array[StandardMaterial3D] = []
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
for index in mi.mesh.get_surface_count():
var standard := mi.mesh.surface_get_material(index) as StandardMaterial3D
if standard != null and standard.emission_enabled \
and standard.emission_energy_multiplier > 0.0:
out.append(standard)
return out
func _build_street_furniture() -> void:
# Street lamps along the sidewalk (bulbs + omni lights, off by day).
# Blender library street lamps (bulbs + omni lights, off by day). The lamp
# arm points to local -X, so the left side is rotated 180 degrees to face
# the sidewalk.
for side in [-7.5, 7.5]:
var lamp := Node3D.new()
lamp.name = "Lamp_%d" % int(side)
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
bulb.material_override = bulb_mat
bulb.material_override.emission_energy_multiplier = 1.5
_lamp_bulb_mats.append(bulb_mat)
var holder := _add_prop_model(root, "street_lamp",
Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0)
holder.name = "Lamp_%d" % int(side)
var omni := OmniLight3D.new()
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
omni.position = Vector3(-0.85, 4.65, 0)
omni.omni_range = 12.0
omni.light_energy = 0.0
omni.light_color = Color(1, 0.93, 0.75)
lamp.add_child(omni)
holder.add_child(omni)
_lamp_omni.append(omni)
root.add_child(lamp)
_street_lamps.append(bulb)
# Planters beside the steps were flat boxes that clipped the treads; the
# generated planter_box props replace them in _build_street_detail().
# Low-poly trees along the street front.
var trunk_color := Color(0.35, 0.25, 0.18)
var leaf_color := Color(0.16, 0.42, 0.2)
# Flank the walkway; keep the sight line to the door clear.
_collect_emissive(holder)
# Blender library street trees replace the cone trees. Flank the walkway and
# keep the sight line to the door clear.
for i in 6:
var side := -1.0 if i % 2 == 0 else 1.0
var x := side * (6.5 + float(int(i / 2)) * 3.0)
var z := 4.0 + float((i * 7) % 5)
var tree := Node3D.new()
tree.name = "Tree_%d" % (i + 1)
var trunk := MeshInstance3D.new()
var trunk_mesh := CylinderMesh.new()
trunk_mesh.top_radius = 0.09
trunk_mesh.bottom_radius = 0.14
trunk_mesh.height = 1.4
trunk.mesh = trunk_mesh
trunk.material_override = _plain_material(trunk_color, "wood")
trunk.position = Vector3(x, 0.7, z)
tree.add_child(trunk)
var crown := MeshInstance3D.new()
var crown_mesh := CylinderMesh.new()
crown_mesh.top_radius = 0.05
crown_mesh.bottom_radius = 0.85
crown_mesh.height = 2.2
crown.mesh = crown_mesh
crown.material_override = _plain_material(leaf_color, "foliage")
crown.position = Vector3(x, 2.1, z)
tree.add_child(crown)
root.add_child(tree)
_add_prop_model(root, "street_tree", Vector3(x, 0.18, z),
float((i * 37) % 360))
## Total triangles across the placed Blender library pieces, for the draw budget
## test. Counts indices when present, otherwise the vertex stream.
func library_triangle_count() -> int:
var total := 0
for holder in library_nodes:
if not is_instance_valid(holder):
continue
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi.mesh == null:
continue
for index in mi.mesh.get_surface_count():
var arrays := mi.mesh.surface_get_arrays(index)
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
if indices.size() > 0:
total += indices.size() / 3
else:
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
total += vertices.size() / 3
return total
## Register a library prop's emissive surfaces so night mode can switch them on.
func _collect_emissive(holder: Node3D) -> void:
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if not _street_lamps.has(mi):
_street_lamps.append(mi)
for material in _emissive_materials(holder):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
## Street-furniture placement table. Positions are in world space on the
## sidewalk top (y = 0.18) and grouped by zone, so a cluster reads as street
@@ -359,6 +441,17 @@ const PROP_PLACEMENTS := [
"accessory": "crate"},
{"zone": "curb", "prop": "fire_hydrant", "x": 8.4, "z": 11.2,
"accessory": "cone"},
# Blender library dressing: palms frame the block, planters and a queue
# barrier sit outside the guest lane.
{"zone": "mid_block", "prop": "palm_tree", "x": -13.0, "z": 10.2, "rot": 10.0},
{"zone": "mid_block", "prop": "palm_tree", "x": 13.0, "z": 10.2, "rot": -10.0},
{"zone": "mid_block", "prop": "round_planter", "x": -12.6, "z": 6.2},
{"zone": "mid_block", "prop": "round_planter", "x": 12.6, "z": 6.2},
{"zone": "curb", "prop": "a_frame_sign", "x": -6.9, "z": 6.5, "rot": 15.0},
{"zone": "curb", "prop": "traffic_cone", "x": 9.6, "z": 9.8},
{"zone": "curb", "prop": "crowd_barrier", "x": 3.0, "z": 10.8},
{"zone": "curb", "prop": "stanchion", "x": -2.6, "z": 10.8},
{"zone": "entrance", "prop": "security_podium", "x": 3.2, "z": 5.6, "rot": -15.0},
]
## The door, steps, and the lane guests walk in through stay clear of props.
@@ -449,6 +542,9 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
if model != null:
holder.add_child(model)
loaded_models.append(prop)
if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"):
library_props.append(prop)
library_nodes.append(holder)
else:
var fallback := _fallback_prop(prop)
holder.add_child(fallback)
@@ -542,7 +638,9 @@ func _add_prop_accessory(holder: Node3D, kind: String) -> void:
Color(0.92, 0.92, 0.9), "painted_metal")
func _load_prop_scene(prop: String) -> Node3D:
var path := MODEL_DIR + prop + ".glb"
var path := BLENDER_PROP_DIR + prop + ".glb"
if not ResourceLoader.exists(path):
path = MODEL_DIR + prop + ".glb"
if not ResourceLoader.exists(path):
return null
var res: Resource = ResourceLoader.load(path)