Establish the 0.9.4 NPC visual baseline
Add a neutral 48-frame character contact sheet, acceptance criteria and ranked baseline gaps, plus repeatable sheet/comparison tooling. No character GLB or runtime mesh changes.
This commit is contained in:
1 parent
c1cd4898e8
commit
e7ee177c31
13 files changed
+628
-11
No files matched your search
@@ -0,0 +1,194 @@
|
||||
extends SceneTree
|
||||
## Neutral-studio contact-sheet baseline for the 0.9.4 NPC visual pass.
|
||||
##
|
||||
## Stages all eight library characters at a fixed camera height against a
|
||||
## mid-grey studio backdrop: front, three-quarter, profile and back portraits, then a
|
||||
## full-body front and a crowd-distance silhouette. No venue, crowd, HUD,
|
||||
## flashlight or phase lighting is involved, so the same frames are directly
|
||||
## comparable before and after a model change.
|
||||
##
|
||||
## Usage (windowed):
|
||||
## godot --path . --script res://src/test/capture_npc_baseline.gd -- \
|
||||
## --out=res://tmp/npc-baseline
|
||||
##
|
||||
## The outputs are local QA art. Build the contact sheet with:
|
||||
## python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
|
||||
const VARIANTS := [
|
||||
{"name": "npc_casual", "type": Rules.NpcType.TOURIST},
|
||||
{"name": "npc_jacket", "type": Rules.NpcType.AUTHORIZED_GUEST},
|
||||
{"name": "npc_coat", "type": Rules.NpcType.INFLUENCER},
|
||||
{"name": "npc_vest", "type": Rules.NpcType.DELIVERY},
|
||||
{"name": "npc_dress", "type": Rules.NpcType.PERFORMER},
|
||||
{"name": "npc_hoodie", "type": Rules.NpcType.VIOLENT_THREAT},
|
||||
{"name": "npc_firefighter", "type": Rules.NpcType.RESPONDER},
|
||||
{"name": "npc_guard", "type": Rules.NpcType.CONFUSED_VISITOR},
|
||||
]
|
||||
## Camera orbits the subject at yaw 0 (front = Godot +Z). Portrait views keep
|
||||
## the eye/head in frame; the last two show the full body and crowd silhouette.
|
||||
const VIEWS := [
|
||||
{"name": "front", "yaw": 0.0, "distance": 1.85, "height": 1.55,
|
||||
"target_y": 1.52, "fov": 32.0},
|
||||
{"name": "three_quarter", "yaw": 35.0, "distance": 1.85, "height": 1.55,
|
||||
"target_y": 1.52, "fov": 32.0},
|
||||
{"name": "profile", "yaw": 90.0, "distance": 1.85, "height": 1.55,
|
||||
"target_y": 1.52, "fov": 32.0},
|
||||
{"name": "back", "yaw": 180.0, "distance": 1.85, "height": 1.55,
|
||||
"target_y": 1.52, "fov": 32.0},
|
||||
{"name": "full_body", "yaw": 0.0, "distance": 4.2, "height": 0.95,
|
||||
"target_y": 0.95, "fov": 38.0},
|
||||
{"name": "crowd", "yaw": 0.0, "distance": 8.0, "height": 1.45,
|
||||
"target_y": 1.0, "fov": 45.0},
|
||||
]
|
||||
const SETTLE_FRAMES := 4
|
||||
const MAX_FRAMES := 4000
|
||||
|
||||
var out_dir := "user://screenshots/npc_baseline"
|
||||
var frames := 0
|
||||
var _phase := "stage_variant"
|
||||
var _variant_i := 0
|
||||
var _view_i := 0
|
||||
var _wait := 0
|
||||
var _avatar: NpcAvatar = null
|
||||
var _camera: Camera3D = null
|
||||
var _stage: Node3D = null
|
||||
|
||||
func _initialize() -> void:
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if str(arg).begins_with("--out="):
|
||||
out_dir = str(arg).substr(6)
|
||||
root.size = Vector2i(1280, 720)
|
||||
_build_stage()
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frames += 1
|
||||
if frames > MAX_FRAMES:
|
||||
push_error("capture_npc_baseline timed out")
|
||||
quit(1)
|
||||
return true
|
||||
match _phase:
|
||||
"stage_variant":
|
||||
_stage_variant()
|
||||
_wait = SETTLE_FRAMES
|
||||
_phase = "settle"
|
||||
"settle":
|
||||
_wait -= 1
|
||||
if _wait <= 0:
|
||||
_frame_view()
|
||||
_phase = "shoot"
|
||||
"shoot":
|
||||
_shoot()
|
||||
_view_i += 1
|
||||
if _view_i >= VIEWS.size():
|
||||
_view_i = 0
|
||||
_variant_i += 1
|
||||
if _variant_i >= VARIANTS.size():
|
||||
print("capture_npc_baseline done -> %s" % out_dir)
|
||||
quit(0)
|
||||
return true
|
||||
_phase = "stage_variant"
|
||||
else:
|
||||
_wait = 1
|
||||
_phase = "settle"
|
||||
return false
|
||||
|
||||
func _build_stage() -> void:
|
||||
_stage = Node3D.new()
|
||||
_stage.name = "NpcBaselineStage"
|
||||
root.add_child(_stage)
|
||||
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.56, 0.59, 0.63)
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.72, 0.75, 0.80)
|
||||
env.ambient_light_energy = 0.55
|
||||
env.tonemap_mode = Environment.TONE_MAPPER_ACES
|
||||
var world_env := WorldEnvironment.new()
|
||||
world_env.environment = env
|
||||
_stage.add_child(world_env)
|
||||
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.name = "KeyLight"
|
||||
sun.rotation_degrees = Vector3(-38.0, -32.0, 0.0)
|
||||
sun.light_energy = 1.35
|
||||
sun.light_color = Color(1.0, 0.97, 0.92)
|
||||
sun.shadow_enabled = true
|
||||
_stage.add_child(sun)
|
||||
var fill := DirectionalLight3D.new()
|
||||
fill.name = "FillLight"
|
||||
fill.rotation_degrees = Vector3(-12.0, 145.0, 0.0)
|
||||
fill.light_energy = 0.45
|
||||
fill.light_color = Color(0.75, 0.82, 1.0)
|
||||
_stage.add_child(fill)
|
||||
|
||||
var ground := MeshInstance3D.new()
|
||||
var plane := PlaneMesh.new()
|
||||
plane.size = Vector2(24.0, 24.0)
|
||||
ground.mesh = plane
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(0.30, 0.32, 0.35)
|
||||
mat.roughness = 0.92
|
||||
ground.material_override = mat
|
||||
_stage.add_child(ground)
|
||||
|
||||
_camera = Camera3D.new()
|
||||
_camera.name = "BaselineCamera"
|
||||
_camera.current = true
|
||||
_stage.add_child(_camera)
|
||||
|
||||
func _stage_variant() -> void:
|
||||
if _avatar != null and is_instance_valid(_avatar):
|
||||
_avatar.queue_free()
|
||||
_avatar = null
|
||||
var variant: Dictionary = VARIANTS[_variant_i]
|
||||
var profile := NpcProfile.new()
|
||||
profile.id = "baseline_" + str(variant["name"])
|
||||
profile.display_name = str(variant["name"]).to_upper()
|
||||
profile.npc_type = int(variant["type"])
|
||||
var avatar := NpcAvatar.new()
|
||||
avatar.name = "BaselineSubject"
|
||||
avatar.character_override = str(variant["name"])
|
||||
avatar.setup(profile, "roam", 4242, Vector3.ZERO, Vector3(0, 0, 2),
|
||||
Vector3(0, 0.5, 1.2), str(profile.id))
|
||||
_stage.add_child(avatar)
|
||||
avatar.global_position = Vector3.ZERO
|
||||
avatar.rotation.y = 0.0
|
||||
avatar.set_physics_process(false)
|
||||
avatar.set_process(false)
|
||||
for node in avatar.find_children("*", "Label3D", true, false):
|
||||
(node as Label3D).visible = false
|
||||
for node in avatar.find_children("FocusCircle", "MeshInstance3D", true, false):
|
||||
(node as MeshInstance3D).visible = false
|
||||
_avatar = avatar
|
||||
|
||||
func _frame_view() -> void:
|
||||
if _avatar == null or not is_instance_valid(_avatar):
|
||||
return
|
||||
var view: Dictionary = VIEWS[_view_i]
|
||||
var yaw := deg_to_rad(float(view["yaw"]))
|
||||
var distance := float(view["distance"])
|
||||
var target := Vector3(0.0, float(view["target_y"]), 0.0)
|
||||
_camera.position = target + Vector3(sin(yaw) * distance, float(view["height"]) - target.y,
|
||||
cos(yaw) * distance)
|
||||
_camera.fov = float(view["fov"])
|
||||
_camera.look_at(target, Vector3.UP)
|
||||
|
||||
func _shoot() -> void:
|
||||
var variant: Dictionary = VARIANTS[_variant_i]
|
||||
var view: Dictionary = VIEWS[_view_i]
|
||||
var dir := ProjectSettings.globalize_path(out_dir)
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
var base := "%s__%s" % [variant["name"], view["name"]]
|
||||
var path := dir.path_join(base + ".png")
|
||||
var index := 2
|
||||
while FileAccess.file_exists(path):
|
||||
path = dir.path_join("%s-v%d.png" % [base, index])
|
||||
index += 1
|
||||
var img: Image = root.get_texture().get_image()
|
||||
var err := img.save_png(path)
|
||||
print("%s -> err=%d" % [base, err])
|
||||
Reference in new issue
Block a user