Load and animate the Blender-authored NPC base mesh

Wire the skinned model from ADR-0013 into NpcAvatar:

- _build_skinned_body() instantiates the imported npc_base.glb, binds the 14
  armature bones, and keeps the procedural _build_body() as the offline
  fallback (use_skinned_model can force it in tests)
- outfit_for()/layer_for() tint the five material slots through the new
  SurfaceMaterials.npc_slot(); accessories and hand items attach under Rig/Head
- the existing limb pivots (Hip/Knee/Shoulder/Elbow/Hand + Head marker) keep the
  old animation math; _sync_skeleton() copies their rotations to bones. Bone
  pose rotation is absolute local orientation, so it is composed with the rest
  basis (otherwise the whole body snaps into a broken pose)
- skinned rigs scale uniformly (non-uniform parent scale shears the skin) and
  keep Rig/Head for the focus cone, nametag, and reaction tag
- commit the editable npc_base.blend plus a .gdignore: Godot's editor otherwise
  tries to import the blend and hangs during --import
- npc_pipeline audit tests (contract, textures, clean GLB, committed source)

Verification: headless 212/212, Python tooling 60/60, NPC budget 16 avatars =
144 meshes at ~6.9 ms, movement/focus/props smokes pass, compare 11 good / 0 bad.
TODO Phases 0-3 checked; environment lift and release remain.
This commit is contained in:
zwitschi committed 2026-11-01 13:26:37 +01:00
1 parent 3989d7b2e5
commit a231f7588a
20 files changed
+316 -37

No files matched your search

+9
View File
@@ -31,6 +31,15 @@ const PROFILES := {
"rubber": {"roughness": 0.95, "metallic": 0.0},
}
## NPC material slot: keep the baked albedo/normal maps from the imported model
## and tint them with the seed-driven outfit colour.
static func npc_slot(source: Material, tint: Color) -> StandardMaterial3D:
var material := source.duplicate() as StandardMaterial3D
if material == null:
material = StandardMaterial3D.new()
material.albedo_color = tint
return material
static func profile(role: String) -> Dictionary:
return PROFILES.get(role, PROFILES["concrete"])
+156 -2
View File
@@ -70,7 +70,9 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
npc.fsm.state_changed.connect(_on_state_changed)
add_to_group("npc")
_idle_style = idle_style_for(profile.npc_type, seed_value)
_build_body(profile.npc_type)
if not use_skinned_model or not _build_skinned_body(profile.npc_type):
_build_body(profile.npc_type)
fallback_avatar = true
_build_impostor()
_nav_usable = false
_nav_try_frames = 0
@@ -126,6 +128,13 @@ var _lod_tier := 0
var _lod_check_s := 0.0
var _queue_offset := Vector3.ZERO
var _height_scale := 1.0
var loaded_model := false
var fallback_avatar := true
## Tests can force the offline rig; the game always tries the committed model.
var use_skinned_model := true
var _skeleton: Skeleton3D = null
var _bone_indices := {}
var _skinned_mesh: MeshInstance3D = null
var _shirt_color := Color(0.7, 0.7, 0.7)
var _skin_color := Color(0.88, 0.72, 0.60)
var _avoidance_active := false
@@ -280,6 +289,145 @@ static func reaction_label(mode: String, escalation: int) -> String:
return "?"
return ""
## Instantiate the Blender-authored skinned base mesh. Returns false when the
## GLB is missing/not imported, so setup falls back to the procedural rig.
func _build_skinned_body(npc_type: int) -> bool:
var path := "res://assets/models/generated/npc_base.glb"
if not ResourceLoader.exists(path):
return false
var packed := ResourceLoader.load(path) as PackedScene
if packed == null:
return false
var model := packed.instantiate() as Node3D
if model == null:
return false
var skeleton := model.find_child("Skeleton3D", true, false) as Skeleton3D
var mesh_instance := model.find_child("npc_base", true, false) as MeshInstance3D
if skeleton == null or mesh_instance == null:
model.queue_free()
return false
_rig = Node3D.new()
_rig.name = "Rig"
add_child(_rig)
_rig.add_child(model)
_skeleton = skeleton
_skinned_mesh = mesh_instance
var outfit := outfit_for(npc_type, _visual_seed)
var cloth: Color = outfit["shirt"]
match str(outfit["layer"]):
"vest":
cloth = Color(0.92, 0.72, 0.12)
"coat":
cloth = cloth.darkened(0.25)
"jacket":
cloth = cloth.darkened(0.12)
_height_scale = float(outfit["height_scale"])
_rig.scale = Vector3.ONE * _height_scale
_shirt_color = cloth
_skin_color = outfit["skin"]
_apply_outfit_materials(npc_type, outfit, cloth)
# Animation proxies: the existing _animate()/idle code drives these Node3D
# pivots; _sync_skeleton() copies their rotations onto the armature bones.
_head = Node3D.new()
_head.name = "Head"
_head.position = Vector3(0, 1.62, 0)
_rig.add_child(_head)
for side in [-1.0, 1.0]:
var suffix := "L" if side < 0.0 else "R"
var hip := Node3D.new()
hip.name = "Hip" + suffix
hip.position = Vector3(0.13 * side, 0.64, 0)
_rig.add_child(hip)
var knee := Node3D.new()
knee.name = "Knee"
knee.position = Vector3(0, -0.34, 0)
hip.add_child(knee)
_legs.append(hip)
_knees.append(knee)
var shoulder := Node3D.new()
shoulder.name = "Shoulder" + suffix
shoulder.position = Vector3(0.27 * side, 1.38, 0)
_rig.add_child(shoulder)
var elbow := Node3D.new()
elbow.name = "Elbow"
elbow.position = Vector3(0, -0.30, 0)
shoulder.add_child(elbow)
var hand := Node3D.new()
hand.name = "Hand"
hand.position = Vector3(0, -0.30, 0.02)
elbow.add_child(hand)
_arms.append(shoulder)
_elbows.append(elbow)
_hands.append(hand)
_bind_bones()
_add_accessory(str(outfit["accessory"]), cloth)
_add_hand_item(str(outfit["hand_item"]), outfit["skin"])
_build_focus_circle()
_add_props(npc_type)
loaded_model = true
fallback_avatar = false
return true
## Tint the imported material slots per avatar. Each avatar gets its own copied
## materials so the shared imported scene is never mutated.
func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color) -> void:
var mesh: Mesh = _skinned_mesh.mesh
var tints := {
"skin": outfit["skin"],
"cloth": cloth,
"pants": outfit["pants"],
"hair": outfit["hair"],
"accessory": Color(0.10, 0.11, 0.14),
}
for index in mesh.get_surface_count():
var source := mesh.surface_get_material(index)
if source == null:
continue
var slot := str(source.resource_name)
_skinned_mesh.set_surface_override_material(index,
Materials.npc_slot(source, tints.get(slot, Color.WHITE)))
func _bind_bones() -> void:
_bone_indices.clear()
if _skeleton == null:
return
for index in _skeleton.get_bone_count():
_bone_indices[_skeleton.get_bone_name(index)] = index
## Copy the proxy pivot rotations onto armature bones. Limb bones point down, so
## an X rotation swings them forward/back; the Head bone points up, so a Y
## rotation is the look yaw.
func _sync_skeleton() -> void:
if _skeleton == null or _bone_indices.is_empty():
return
var names := ["HipL", "HipR"]
for i in 2:
_pose_bone(names[i], _legs[i].rotation.x, Vector3.RIGHT)
names = ["KneeL", "KneeR"]
for i in 2:
_pose_bone(names[i], _knees[i].rotation.x, Vector3.RIGHT)
names = ["ShoulderL", "ShoulderR"]
for i in 2:
_pose_bone(names[i], _arms[i].rotation.x, Vector3.RIGHT)
names = ["ElbowL", "ElbowR"]
for i in 2:
_pose_bone(names[i], _elbows[i].rotation.x, Vector3.RIGHT)
if _head != null:
_pose_bone("Head", _head.rotation.y, Vector3.UP)
func _pose_bone(bone_name: String, angle: float, axis: Vector3) -> void:
if not _bone_indices.has(bone_name):
return
var index: int = _bone_indices[bone_name]
# Bone pose rotation is the absolute local orientation, not an offset from
# rest; compose with the rest rotation so identity preserves the bind pose.
var rest := _skeleton.get_bone_rest(index).basis.get_rotation_quaternion()
_skeleton.set_bone_pose_rotation(index, rest * Quaternion(axis, angle))
func _build_body(npc_type: int) -> void:
_rig = Node3D.new()
_rig.name = "Rig"
@@ -813,6 +961,7 @@ func _animate(delta: float) -> void:
# Idle look-around, unless the person is already engaged.
_head.rotation.y = lerpf(_head.rotation.y, sin(_idle_t * 0.45) * 0.3, 0.05)
_update_reaction_label(escalation)
_sync_skeleton()
## Idle behaviour layered on top of the breathing pose: phone, clipboard,
## crossed arms, or looking around, plus escalation body language.
@@ -821,7 +970,12 @@ func _idle_layer(pose: String, escalation: int) -> void:
return
# Breathing and a slow weight shift keep idle people from looking frozen.
var breath := 1.0 + sin(_idle_t * 1.8) * 0.006
_rig.scale.y = lerpf(_rig.scale.y, _height_scale * breath, 0.15)
if _skeleton == null:
_rig.scale.y = lerpf(_rig.scale.y, _height_scale * breath, 0.15)
else:
# Skinned meshes must scale uniformly; non-uniform parent scale shears
# the skeleton's skin matrices.
_rig.scale = Vector3.ONE * lerpf(_rig.scale.x, _height_scale * breath, 0.15)
_rig.position.x = lerpf(_rig.position.x, sin(_idle_t * 0.7) * 0.015, 0.1)
_rig.rotation.z = lerpf(_rig.rotation.z, sin(_idle_t * 0.7) * 0.015, 0.1)
if pose != "idle" or _arms.size() < 2: