Avatar: tune Roblox proportions + walk cycle from real rendered screenshots
checkpoint / checkpoint (push) Successful in 1m47s
checkpoint / checkpoint (push) Successful in 1m47s
Now that cloudbison can render (Radeon GPU via Xvfb, or llvmpipe software), added scripts/tools/screenshot_rig.gd + scenes/dev/ so a scene can be booted, run a few frames, and its rendered frame saved to PNG -- caught three things the headless smoke never could: - limbs floated detached from the torso: widened the torso and overlapped the arm/leg boxes into it, and pulled limb depth in behind the torso front so they stop z-fighting. - proportions were scrawny: reworked to Roblox R6 ratios (2-stud torso, 2-stud arms/legs, 1-stud head) with the head clearly pegged on top. - the walk swing was a 40-deg lunge: STRIDE 0.7->0.42 rad, separate 0.32 arm swing, KNEE 1.0->0.5. Reads as a walk now, not a crouch. test_level lighting was near-black on the shaded side (ambient was disabled) -- colour ambient at 0.8 + a stronger key light, so the avatar reads in-world. avatar_preview.tscn (AGP_PREVIEW_WALK=1) renders the assembled avatar walking in place for quick visual checks. Verified: avatar smoke 14/14 source + exported pack; fresh-clone boot PASS; eyeballed in rendered screenshots (game scene + walk sequence). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://c0avpre7dev0rig"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/tools/avatar_preview.gd" id="1"]
|
||||
|
||||
[node name="AvatarPreview" type="Node3D"]
|
||||
script = ExtResource("1")
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://bqs8xk2rig0dev"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/tools/screenshot_rig.gd" id="1"]
|
||||
|
||||
[node name="ScreenshotRig" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -46,10 +46,10 @@ subdivisions_height = 8
|
||||
|
||||
[sub_resource type="Environment" id="WorldEnvironment_env"]
|
||||
background_mode = 1
|
||||
background_color = Color(0.4, 0.6, 0.8, 1)
|
||||
background_color = Color(0.45, 0.62, 0.82, 1)
|
||||
ambient_light_source = 2
|
||||
ambient_light_color = Color(0.55, 0.6, 0.68, 1)
|
||||
ambient_light_energy = 0.5
|
||||
ambient_light_color = Color(0.7, 0.75, 0.82, 1)
|
||||
ambient_light_energy = 0.8
|
||||
|
||||
[node name="TestLevel" type="Node3D"]
|
||||
|
||||
@@ -110,7 +110,8 @@ shape = SubResource("SphereShape3D_prop5")
|
||||
mesh = SubResource("SphereMesh_prop5")
|
||||
|
||||
[node name="Sun" type="DirectionalLight3D" parent="."]
|
||||
rotation = Vector3(-0.785398, 0, 0)
|
||||
rotation = Vector3(-0.95, -0.5, 0)
|
||||
light_energy = 1.3
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||
|
||||
@@ -10,8 +10,9 @@ extends Node
|
||||
## avatar's Skeleton3D. Bones are addressed by canonical name, so this works
|
||||
## for the local player and for snapshot-driven remote players alike.
|
||||
|
||||
const STRIDE := 0.7 ## peak hip/shoulder swing, radians
|
||||
const KNEE := 1.0 ## peak knee bend, radians
|
||||
const STRIDE := 0.42 ## peak hip swing, radians (~24 deg)
|
||||
const ARM_SWING := 0.32 ## peak shoulder swing, radians
|
||||
const KNEE := 0.5 ## peak knee bend, radians
|
||||
const CYCLE := 1.9 ## phase (radians) advanced per metre travelled
|
||||
const BLEND := 12.0 ## ease rate toward the target pose
|
||||
const MOVE_EPS := 0.35 ## m/s below which we treat the avatar as idle
|
||||
@@ -52,11 +53,12 @@ func _process(delta: float) -> void:
|
||||
|
||||
var s := sin(_phase)
|
||||
var swing := s * STRIDE * _amount
|
||||
var arm := s * ARM_SWING * _amount
|
||||
|
||||
_pitch("LeftUpperLeg", swing)
|
||||
_pitch("RightUpperLeg", -swing)
|
||||
_pitch("LeftUpperArm", -swing)
|
||||
_pitch("RightUpperArm", swing)
|
||||
_pitch("LeftUpperArm", -arm)
|
||||
_pitch("RightUpperArm", arm)
|
||||
# Knee folds only while that leg is swinging back (negative pitch phase).
|
||||
_pitch("LeftLowerLeg", maxf(0.0, -s) * KNEE * _amount)
|
||||
_pitch("RightLowerLeg", maxf(0.0, s) * KNEE * _amount)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
extends Node3D
|
||||
## Dev-only. Builds the current avatar via AvatarSystem, stands it on a small
|
||||
## stage with even lighting, and (optionally) drives the procedural walk cycle
|
||||
## so screenshots can verify both the silhouette and that the limbs move.
|
||||
##
|
||||
## Env:
|
||||
## AGP_PREVIEW_WALK "1" -> feed the limb animator a walking speed
|
||||
## AGP_PREVIEW_SPIN degrees/sec turntable (default 0)
|
||||
## Used together with screenshot_rig.gd (AGP_SHOT_SCENE=res://scenes/dev/avatar_preview.tscn,
|
||||
## AGP_SHOT_SEQ="6:10" to catch the cycle at several phases).
|
||||
|
||||
const LimbAnimator := preload("res://scripts/avatar/limb_animator.gd")
|
||||
|
||||
var _avatar: Node3D = null
|
||||
var _anim: Node = null
|
||||
var _spin_deg := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_spin_deg = float(OS.get_environment("AGP_PREVIEW_SPIN"))
|
||||
|
||||
var env := WorldEnvironment.new()
|
||||
var e := Environment.new()
|
||||
e.background_mode = Environment.BG_COLOR
|
||||
e.background_color = Color(0.16, 0.18, 0.22)
|
||||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
e.ambient_light_color = Color(0.6, 0.64, 0.7)
|
||||
e.ambient_light_energy = 0.6
|
||||
env.environment = e
|
||||
add_child(env)
|
||||
|
||||
var key := DirectionalLight3D.new()
|
||||
key.rotation = Vector3(-0.9, -0.6, 0.0)
|
||||
key.light_energy = 1.1
|
||||
add_child(key)
|
||||
|
||||
var ground := MeshInstance3D.new()
|
||||
var pm := PlaneMesh.new()
|
||||
pm.size = Vector2(6, 6)
|
||||
ground.mesh = pm
|
||||
var gmat := StandardMaterial3D.new()
|
||||
gmat.albedo_color = Color(0.3, 0.32, 0.36)
|
||||
ground.material_override = gmat
|
||||
add_child(ground)
|
||||
|
||||
var cam := Camera3D.new()
|
||||
cam.position = Vector3(2.1, 1.15, 2.6)
|
||||
cam.look_at_from_position(cam.position, Vector3(0.0, 1.0, 0.0), Vector3.UP)
|
||||
cam.current = true
|
||||
add_child(cam)
|
||||
|
||||
var avatars := get_node_or_null("/root/AvatarSystem")
|
||||
if avatars != null and avatars.has_method("build_current_avatar"):
|
||||
_avatar = avatars.build_current_avatar()
|
||||
if _avatar != null:
|
||||
add_child(_avatar)
|
||||
var skel := _avatar.find_child("Skeleton3D", true, false)
|
||||
if skel is Skeleton3D and OS.get_environment("AGP_PREVIEW_WALK") == "1":
|
||||
_anim = LimbAnimator.new()
|
||||
add_child(_anim)
|
||||
_anim.call("setup", skel)
|
||||
_anim.set("speed", 4.2)
|
||||
else:
|
||||
push_warning("avatar_preview: AvatarSystem.build_current_avatar() returned null")
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _avatar != null and _spin_deg != 0.0:
|
||||
_avatar.rotate_y(deg_to_rad(_spin_deg) * delta)
|
||||
@@ -19,29 +19,29 @@ const OUT_DIR := "res://assets/avatars/"
|
||||
# name -> [parent_name, global_rest_position] (A-pose, arms down, Y-up, metres)
|
||||
const BONES := {
|
||||
"Root": ["", Vector3(0.00, 0.00, 0.00)],
|
||||
"Hips": ["Root", Vector3(0.00, 0.80, 0.00)],
|
||||
"Hips": ["Root", Vector3(0.00, 0.76, 0.00)],
|
||||
"Spine": ["Hips", Vector3(0.00, 1.00, 0.00)],
|
||||
"Chest": ["Spine", Vector3(0.00, 1.22, 0.00)],
|
||||
"Neck": ["Chest", Vector3(0.00, 1.36, 0.00)],
|
||||
"Head": ["Neck", Vector3(0.00, 1.44, 0.00)],
|
||||
"HeadTop": ["Head", Vector3(0.00, 1.92, 0.00)],
|
||||
"Jaw": ["Head", Vector3(0.00, 1.54, 0.14)],
|
||||
"LeftEye": ["Head", Vector3(0.12, 1.66, 0.28)],
|
||||
"RightEye": ["Head", Vector3(-0.12, 1.66, 0.28)],
|
||||
"LeftShoulder": ["Chest", Vector3(0.22, 1.34, 0.00)],
|
||||
"LeftUpperArm": ["LeftShoulder", Vector3(0.45, 1.30, 0.00)],
|
||||
"LeftLowerArm": ["LeftUpperArm", Vector3(0.45, 1.02, 0.00)],
|
||||
"LeftHand": ["LeftLowerArm", Vector3(0.45, 0.74, 0.00)],
|
||||
"RightShoulder": ["Chest", Vector3(-0.22, 1.34, 0.00)],
|
||||
"RightUpperArm": ["RightShoulder", Vector3(-0.45, 1.30, 0.00)],
|
||||
"RightLowerArm": ["RightUpperArm", Vector3(-0.45, 1.02, 0.00)],
|
||||
"RightHand": ["RightLowerArm", Vector3(-0.45, 0.74, 0.00)],
|
||||
"LeftUpperLeg": ["Hips", Vector3(0.17, 0.78, 0.00)],
|
||||
"LeftLowerLeg": ["LeftUpperLeg", Vector3(0.17, 0.40, 0.00)],
|
||||
"LeftFoot": ["LeftLowerLeg", Vector3(0.17, 0.05, 0.05)],
|
||||
"RightUpperLeg": ["Hips", Vector3(-0.17, 0.78, 0.00)],
|
||||
"RightLowerLeg": ["RightUpperLeg", Vector3(-0.17, 0.40, 0.00)],
|
||||
"RightFoot": ["RightLowerLeg", Vector3(-0.17, 0.05, 0.05)],
|
||||
"Chest": ["Spine", Vector3(0.00, 1.30, 0.00)],
|
||||
"Neck": ["Chest", Vector3(0.00, 1.50, 0.00)],
|
||||
"Head": ["Neck", Vector3(0.00, 1.58, 0.00)],
|
||||
"HeadTop": ["Head", Vector3(0.00, 2.00, 0.00)],
|
||||
"Jaw": ["Head", Vector3(0.00, 1.64, 0.16)],
|
||||
"LeftEye": ["Head", Vector3(0.12, 1.76, 0.27)],
|
||||
"RightEye": ["Head", Vector3(-0.12, 1.76, 0.27)],
|
||||
"LeftShoulder": ["Chest", Vector3(0.42, 1.50, 0.00)],
|
||||
"LeftUpperArm": ["LeftShoulder", Vector3(0.60, 1.44, 0.00)],
|
||||
"LeftLowerArm": ["LeftUpperArm", Vector3(0.60, 1.14, 0.00)],
|
||||
"LeftHand": ["LeftLowerArm", Vector3(0.60, 0.82, 0.00)],
|
||||
"RightShoulder": ["Chest", Vector3(-0.42, 1.50, 0.00)],
|
||||
"RightUpperArm": ["RightShoulder", Vector3(-0.60, 1.44, 0.00)],
|
||||
"RightLowerArm": ["RightUpperArm", Vector3(-0.60, 1.14, 0.00)],
|
||||
"RightHand": ["RightLowerArm", Vector3(-0.60, 0.82, 0.00)],
|
||||
"LeftUpperLeg": ["Hips", Vector3(0.21, 0.74, 0.00)],
|
||||
"LeftLowerLeg": ["LeftUpperLeg", Vector3(0.21, 0.38, 0.00)],
|
||||
"LeftFoot": ["LeftLowerLeg", Vector3(0.21, 0.05, 0.06)],
|
||||
"RightUpperLeg": ["Hips", Vector3(-0.21, 0.74, 0.00)],
|
||||
"RightLowerLeg": ["RightUpperLeg", Vector3(-0.21, 0.38, 0.00)],
|
||||
"RightFoot": ["RightLowerLeg", Vector3(-0.21, 0.05, 0.06)],
|
||||
}
|
||||
|
||||
# Order matters: parents before children.
|
||||
@@ -64,16 +64,16 @@ const C_FACE := Color(0.05, 0.05, 0.06) # near-black (eyes + smile)
|
||||
# weighted to its bone -- so the rig still drives it, but it reads as a clean
|
||||
# solid block (no banding, no capsule taper).
|
||||
const PARTS := [
|
||||
["Head", Vector3(0.00, 1.62, 0.00), Vector3(0.60, 0.56, 0.56), C_SKIN],
|
||||
["Spine", Vector3(0.00, 1.06, 0.00), Vector3(0.74, 0.64, 0.38), C_SHIRT],
|
||||
["LeftUpperArm", Vector3(0.45, 1.04, 0.00), Vector3(0.27, 0.66, 0.31), C_SKIN],
|
||||
["RightUpperArm", Vector3(-0.45, 1.04, 0.00), Vector3(0.27, 0.66, 0.31), C_SKIN],
|
||||
["LeftUpperLeg", Vector3(0.17, 0.39, 0.00), Vector3(0.31, 0.80, 0.33), C_PANTS],
|
||||
["RightUpperLeg", Vector3(-0.17, 0.39, 0.00), Vector3(0.31, 0.80, 0.33), C_PANTS],
|
||||
["Head", Vector3(0.00, 1.74, 0.00), Vector3(0.58, 0.56, 0.56), C_SKIN],
|
||||
["Spine", Vector3(0.00, 1.14, 0.00), Vector3(0.90, 0.80, 0.48), C_SHIRT],
|
||||
["LeftUpperArm", Vector3(0.58, 1.14, 0.00), Vector3(0.34, 0.78, 0.40), C_SKIN],
|
||||
["RightUpperArm", Vector3(-0.58, 1.14, 0.00), Vector3(0.34, 0.78, 0.40), C_SKIN],
|
||||
["LeftUpperLeg", Vector3(0.21, 0.38, 0.00), Vector3(0.40, 0.78, 0.40), C_PANTS],
|
||||
["RightUpperLeg", Vector3(-0.21, 0.38, 0.00), Vector3(0.40, 0.78, 0.40), C_PANTS],
|
||||
# painted-on face -- thin plates on the front of the head (+Z), rigged to Head
|
||||
["Head", Vector3(0.12, 1.66, 0.285), Vector3(0.07, 0.10, 0.02), C_FACE],
|
||||
["Head", Vector3(-0.12, 1.66, 0.285), Vector3(0.07, 0.10, 0.02), C_FACE],
|
||||
["Head", Vector3(0.00, 1.52, 0.285), Vector3(0.26, 0.05, 0.02), C_FACE],
|
||||
["Head", Vector3(0.13, 1.78, 0.28), Vector3(0.09, 0.12, 0.02), C_FACE],
|
||||
["Head", Vector3(-0.13, 1.78, 0.28), Vector3(0.09, 0.12, 0.02), C_FACE],
|
||||
["Head", Vector3(0.00, 1.64, 0.28), Vector3(0.32, 0.05, 0.02), C_FACE],
|
||||
]
|
||||
|
||||
# Attachments: id -> [Mesh, local_offset_from_mount_bone, colour]
|
||||
@@ -90,8 +90,8 @@ const ATTACHMENTS := {
|
||||
"right_hand_glove_01": ["blob", Vector3(0, 0.0, 0.0), Color(0.15, 0.15, 0.18)],
|
||||
"left_foot_boot_01": ["boot", Vector3(0, -0.02, 0.05), Color(0.12, 0.10, 0.09)],
|
||||
"right_foot_boot_01": ["boot", Vector3(0, -0.02, 0.05), Color(0.12, 0.10, 0.09)],
|
||||
"accessory_belt_01": ["belt", Vector3(0, 0.80, 0.0), Color(0.20, 0.18, 0.12)],
|
||||
"accessory_necklace_01": ["necklace", Vector3(0, 1.30, 0.06), Color(0.85, 0.75, 0.20)],
|
||||
"accessory_belt_01": ["belt", Vector3(0, 0.74, 0.0), Color(0.20, 0.18, 0.12)],
|
||||
"accessory_necklace_01": ["necklace", Vector3(0, 1.34, 0.10), Color(0.85, 0.75, 0.20)],
|
||||
}
|
||||
|
||||
var _fail := 0
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
extends Node
|
||||
## Dev-only screenshot harness. Boots a target scene inside the real (GPU)
|
||||
## renderer, lets it run a few frames, then reads the rendered frame back off
|
||||
## the viewport and writes it to disk. Nothing in the shipped game touches this.
|
||||
##
|
||||
## Driven entirely by environment variables so it needs no CLI parsing:
|
||||
## AGP_SHOT_SCENE res:// path of the scene to capture (default: game.tscn)
|
||||
## AGP_SHOT_OUT output PNG path (absolute) (default: /tmp/agp_shot.png)
|
||||
## AGP_SHOT_FRAMES frames to settle before the first shot (default: 45)
|
||||
## AGP_SHOT_SEQ "count:gap" -> take <count> shots <gap> frames apart,
|
||||
## suffixing the filename _01.._NN (default: single shot)
|
||||
##
|
||||
## Launch (hardware Vulkan on cloudbison's Radeon, via Xvfb):
|
||||
## Xvfb :99 -screen 0 1280x720x24 &
|
||||
## sg render -c "sg video -c 'DISPLAY=:99 AGP_SHOT_SCENE=res://scenes/game.tscn \
|
||||
## AGP_SHOT_OUT=/abs/shot.png ~/godot/godot --path . \
|
||||
## --rendering-driver vulkan --gpu-index 0 --resolution 1280x720 \
|
||||
## res://scenes/dev/screenshot_rig.tscn'"
|
||||
|
||||
func _ready() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var target := OS.get_environment("AGP_SHOT_SCENE")
|
||||
if target.is_empty():
|
||||
target = "res://scenes/game.tscn"
|
||||
var out := OS.get_environment("AGP_SHOT_OUT")
|
||||
if out.is_empty():
|
||||
out = "/tmp/agp_shot.png"
|
||||
var settle := int(OS.get_environment("AGP_SHOT_FRAMES"))
|
||||
if settle <= 0:
|
||||
settle = 45
|
||||
|
||||
var seq_count := 1
|
||||
var seq_gap := 0
|
||||
var seq_env := OS.get_environment("AGP_SHOT_SEQ")
|
||||
if seq_env.contains(":"):
|
||||
var parts := seq_env.split(":")
|
||||
seq_count = maxi(1, int(parts[0]))
|
||||
seq_gap = maxi(1, int(parts[1]))
|
||||
|
||||
var packed: PackedScene = load(target)
|
||||
if packed == null:
|
||||
push_error("screenshot_rig: cannot load %s" % target)
|
||||
get_tree().quit(1)
|
||||
return
|
||||
var inst: Node = packed.instantiate()
|
||||
get_tree().root.add_child(inst)
|
||||
get_tree().current_scene = inst
|
||||
|
||||
for i in settle:
|
||||
await get_tree().process_frame
|
||||
|
||||
for shot in seq_count:
|
||||
# get_image() on the viewport texture is a GPU->CPU readback of the
|
||||
# frame that was just rendered -- no window presentation needed, which
|
||||
# is why this works over Xvfb (no DRI3).
|
||||
var img: Image = get_viewport().get_texture().get_image()
|
||||
var path := out
|
||||
if seq_count > 1:
|
||||
path = "%s_%02d.png" % [out.get_basename(), shot + 1]
|
||||
var err := img.save_png(path)
|
||||
if err != OK:
|
||||
push_error("screenshot_rig: save_png(%s) failed: %d" % [path, err])
|
||||
else:
|
||||
print("SHOT %s (%dx%d)" % [path, img.get_width(), img.get_height()])
|
||||
if shot < seq_count - 1:
|
||||
for j in seq_gap:
|
||||
await get_tree().process_frame
|
||||
|
||||
get_tree().quit()
|
||||
Reference in New Issue
Block a user