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:
1 parent
3989d7b2e5
commit
a231f7588a
20 files changed
+316
-37
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -125,6 +125,55 @@ func test_walk_to_idle_animation_transition() -> bool:
|
||||
avatar.free()
|
||||
return a and b and c
|
||||
|
||||
func test_skinned_model_loads_and_keeps_the_head_contract() -> bool:
|
||||
var avatar := _avatar(Rules.NpcType.TOURIST, 33)
|
||||
var a := TestUtils.check(avatar.loaded_model and not avatar.fallback_avatar,
|
||||
"committed npc_base.glb is used")
|
||||
var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
|
||||
"Rig/Head survives the skinned path")
|
||||
var c := TestUtils.check(avatar._skeleton != null and avatar._bone_indices.has("Head")
|
||||
and avatar._bone_indices.has("HipL") and avatar._bone_indices.has("HandR"),
|
||||
"armature bones are bound")
|
||||
avatar.free()
|
||||
return a and b and c
|
||||
|
||||
func test_skinned_outfit_tints_the_material_slots() -> bool:
|
||||
var avatar := _avatar(Rules.NpcType.TOURIST, 44)
|
||||
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D
|
||||
var outfit := NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 44)
|
||||
var found := 0
|
||||
var tinted := 0
|
||||
for index in mi.mesh.get_surface_count():
|
||||
var material := mi.get_surface_override_material(index) as StandardMaterial3D
|
||||
if material == null:
|
||||
continue
|
||||
found += 1
|
||||
if material.albedo_color != Color.WHITE:
|
||||
tinted += 1
|
||||
var a := TestUtils.check(found == 5, "five material slots stay separate")
|
||||
var b := TestUtils.check(tinted == found, "every slot carries the outfit tint")
|
||||
var c := TestUtils.check(outfit["shirt"] != Color.WHITE, "seed gives a non-white outfit")
|
||||
avatar.free()
|
||||
return a and b and c
|
||||
|
||||
func test_forced_fallback_still_builds_the_procedural_rig() -> bool:
|
||||
var profile := NpcProfile.new()
|
||||
profile.id = "fallback_test"
|
||||
profile.npc_type = Rules.NpcType.TOURIST
|
||||
profile.display_name = "Fallback"
|
||||
var avatar := NpcAvatar.new()
|
||||
avatar.use_skinned_model = false
|
||||
avatar.setup(profile, "enter", 5, Vector3(0, 0.5, 6),
|
||||
Vector3(0, 0.5, 1.5), Vector3(0, 0.5, 1.2), "fallback_test")
|
||||
var a := TestUtils.check(not avatar.loaded_model and avatar.fallback_avatar,
|
||||
"fallback is recorded when the model is disabled")
|
||||
var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
|
||||
"fallback still exposes Rig/Head")
|
||||
var c := TestUtils.check(avatar.find_child("HipL", true, false) != null,
|
||||
"fallback limb pivots exist")
|
||||
avatar.free()
|
||||
return a and b and c
|
||||
|
||||
func test_rig_is_articulated() -> bool:
|
||||
var avatar := _avatar(Rules.NpcType.TOURIST, 77)
|
||||
var a := TestUtils.check(avatar.find_child("HipL", true, false) != null
|
||||
|
||||
Reference in new issue
Block a user