Avatar: tune Roblox proportions + walk cycle from real rendered screenshots
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:
2026-09-06 14:06:46 +00:00
parent 1b1260f371
commit 3fb45f0ef2
10 changed files with 197 additions and 41 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -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")
+6
View File
@@ -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")
+5 -4
View File
@@ -46,10 +46,10 @@ subdivisions_height = 8
[sub_resource type="Environment" id="WorldEnvironment_env"] [sub_resource type="Environment" id="WorldEnvironment_env"]
background_mode = 1 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_source = 2
ambient_light_color = Color(0.55, 0.6, 0.68, 1) ambient_light_color = Color(0.7, 0.75, 0.82, 1)
ambient_light_energy = 0.5 ambient_light_energy = 0.8
[node name="TestLevel" type="Node3D"] [node name="TestLevel" type="Node3D"]
@@ -110,7 +110,8 @@ shape = SubResource("SphereShape3D_prop5")
mesh = SubResource("SphereMesh_prop5") mesh = SubResource("SphereMesh_prop5")
[node name="Sun" type="DirectionalLight3D" parent="."] [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 shadow_enabled = true
[node name="WorldEnvironment" type="WorldEnvironment" parent="."] [node name="WorldEnvironment" type="WorldEnvironment" parent="."]
+6 -4
View File
@@ -10,8 +10,9 @@ extends Node
## avatar's Skeleton3D. Bones are addressed by canonical name, so this works ## avatar's Skeleton3D. Bones are addressed by canonical name, so this works
## for the local player and for snapshot-driven remote players alike. ## for the local player and for snapshot-driven remote players alike.
const STRIDE := 0.7 ## peak hip/shoulder swing, radians const STRIDE := 0.42 ## peak hip swing, radians (~24 deg)
const KNEE := 1.0 ## peak knee bend, radians 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 CYCLE := 1.9 ## phase (radians) advanced per metre travelled
const BLEND := 12.0 ## ease rate toward the target pose 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 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 s := sin(_phase)
var swing := s * STRIDE * _amount var swing := s * STRIDE * _amount
var arm := s * ARM_SWING * _amount
_pitch("LeftUpperLeg", swing) _pitch("LeftUpperLeg", swing)
_pitch("RightUpperLeg", -swing) _pitch("RightUpperLeg", -swing)
_pitch("LeftUpperArm", -swing) _pitch("LeftUpperArm", -arm)
_pitch("RightUpperArm", swing) _pitch("RightUpperArm", arm)
# Knee folds only while that leg is swinging back (negative pitch phase). # Knee folds only while that leg is swinging back (negative pitch phase).
_pitch("LeftLowerLeg", maxf(0.0, -s) * KNEE * _amount) _pitch("LeftLowerLeg", maxf(0.0, -s) * KNEE * _amount)
_pitch("RightLowerLeg", maxf(0.0, s) * KNEE * _amount) _pitch("RightLowerLeg", maxf(0.0, s) * KNEE * _amount)
+69
View File
@@ -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)
+33 -33
View File
@@ -19,29 +19,29 @@ const OUT_DIR := "res://assets/avatars/"
# name -> [parent_name, global_rest_position] (A-pose, arms down, Y-up, metres) # name -> [parent_name, global_rest_position] (A-pose, arms down, Y-up, metres)
const BONES := { const BONES := {
"Root": ["", Vector3(0.00, 0.00, 0.00)], "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)], "Spine": ["Hips", Vector3(0.00, 1.00, 0.00)],
"Chest": ["Spine", Vector3(0.00, 1.22, 0.00)], "Chest": ["Spine", Vector3(0.00, 1.30, 0.00)],
"Neck": ["Chest", Vector3(0.00, 1.36, 0.00)], "Neck": ["Chest", Vector3(0.00, 1.50, 0.00)],
"Head": ["Neck", Vector3(0.00, 1.44, 0.00)], "Head": ["Neck", Vector3(0.00, 1.58, 0.00)],
"HeadTop": ["Head", Vector3(0.00, 1.92, 0.00)], "HeadTop": ["Head", Vector3(0.00, 2.00, 0.00)],
"Jaw": ["Head", Vector3(0.00, 1.54, 0.14)], "Jaw": ["Head", Vector3(0.00, 1.64, 0.16)],
"LeftEye": ["Head", Vector3(0.12, 1.66, 0.28)], "LeftEye": ["Head", Vector3(0.12, 1.76, 0.27)],
"RightEye": ["Head", Vector3(-0.12, 1.66, 0.28)], "RightEye": ["Head", Vector3(-0.12, 1.76, 0.27)],
"LeftShoulder": ["Chest", Vector3(0.22, 1.34, 0.00)], "LeftShoulder": ["Chest", Vector3(0.42, 1.50, 0.00)],
"LeftUpperArm": ["LeftShoulder", Vector3(0.45, 1.30, 0.00)], "LeftUpperArm": ["LeftShoulder", Vector3(0.60, 1.44, 0.00)],
"LeftLowerArm": ["LeftUpperArm", Vector3(0.45, 1.02, 0.00)], "LeftLowerArm": ["LeftUpperArm", Vector3(0.60, 1.14, 0.00)],
"LeftHand": ["LeftLowerArm", Vector3(0.45, 0.74, 0.00)], "LeftHand": ["LeftLowerArm", Vector3(0.60, 0.82, 0.00)],
"RightShoulder": ["Chest", Vector3(-0.22, 1.34, 0.00)], "RightShoulder": ["Chest", Vector3(-0.42, 1.50, 0.00)],
"RightUpperArm": ["RightShoulder", Vector3(-0.45, 1.30, 0.00)], "RightUpperArm": ["RightShoulder", Vector3(-0.60, 1.44, 0.00)],
"RightLowerArm": ["RightUpperArm", Vector3(-0.45, 1.02, 0.00)], "RightLowerArm": ["RightUpperArm", Vector3(-0.60, 1.14, 0.00)],
"RightHand": ["RightLowerArm", Vector3(-0.45, 0.74, 0.00)], "RightHand": ["RightLowerArm", Vector3(-0.60, 0.82, 0.00)],
"LeftUpperLeg": ["Hips", Vector3(0.17, 0.78, 0.00)], "LeftUpperLeg": ["Hips", Vector3(0.21, 0.74, 0.00)],
"LeftLowerLeg": ["LeftUpperLeg", Vector3(0.17, 0.40, 0.00)], "LeftLowerLeg": ["LeftUpperLeg", Vector3(0.21, 0.38, 0.00)],
"LeftFoot": ["LeftLowerLeg", Vector3(0.17, 0.05, 0.05)], "LeftFoot": ["LeftLowerLeg", Vector3(0.21, 0.05, 0.06)],
"RightUpperLeg": ["Hips", Vector3(-0.17, 0.78, 0.00)], "RightUpperLeg": ["Hips", Vector3(-0.21, 0.74, 0.00)],
"RightLowerLeg": ["RightUpperLeg", Vector3(-0.17, 0.40, 0.00)], "RightLowerLeg": ["RightUpperLeg", Vector3(-0.21, 0.38, 0.00)],
"RightFoot": ["RightLowerLeg", Vector3(-0.17, 0.05, 0.05)], "RightFoot": ["RightLowerLeg", Vector3(-0.21, 0.05, 0.06)],
} }
# Order matters: parents before children. # 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 # weighted to its bone -- so the rig still drives it, but it reads as a clean
# solid block (no banding, no capsule taper). # solid block (no banding, no capsule taper).
const PARTS := [ const PARTS := [
["Head", Vector3(0.00, 1.62, 0.00), Vector3(0.60, 0.56, 0.56), C_SKIN], ["Head", Vector3(0.00, 1.74, 0.00), Vector3(0.58, 0.56, 0.56), C_SKIN],
["Spine", Vector3(0.00, 1.06, 0.00), Vector3(0.74, 0.64, 0.38), C_SHIRT], ["Spine", Vector3(0.00, 1.14, 0.00), Vector3(0.90, 0.80, 0.48), C_SHIRT],
["LeftUpperArm", Vector3(0.45, 1.04, 0.00), Vector3(0.27, 0.66, 0.31), C_SKIN], ["LeftUpperArm", Vector3(0.58, 1.14, 0.00), Vector3(0.34, 0.78, 0.40), C_SKIN],
["RightUpperArm", Vector3(-0.45, 1.04, 0.00), Vector3(0.27, 0.66, 0.31), C_SKIN], ["RightUpperArm", Vector3(-0.58, 1.14, 0.00), Vector3(0.34, 0.78, 0.40), C_SKIN],
["LeftUpperLeg", Vector3(0.17, 0.39, 0.00), Vector3(0.31, 0.80, 0.33), C_PANTS], ["LeftUpperLeg", Vector3(0.21, 0.38, 0.00), Vector3(0.40, 0.78, 0.40), C_PANTS],
["RightUpperLeg", Vector3(-0.17, 0.39, 0.00), Vector3(0.31, 0.80, 0.33), 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 # 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.13, 1.78, 0.28), Vector3(0.09, 0.12, 0.02), C_FACE],
["Head", Vector3(-0.12, 1.66, 0.285), Vector3(0.07, 0.10, 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.52, 0.285), Vector3(0.26, 0.05, 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] # 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)], "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)], "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)], "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_belt_01": ["belt", Vector3(0, 0.74, 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_necklace_01": ["necklace", Vector3(0, 1.34, 0.10), Color(0.85, 0.75, 0.20)],
} }
var _fail := 0 var _fail := 0
+72
View File
@@ -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()