From 3fb45f0ef25565fc7eb13e99e23b0ef91c9275f4 Mon Sep 17 00:00:00 2001 From: daddy Date: Sun, 6 Sep 2026 14:06:46 +0000 Subject: [PATCH] Avatar: tune Roblox proportions + walk cycle from real rendered screenshots 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 Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ --- assets/avatars/accessory_belt_01.glb | Bin 129372 -> 129376 bytes assets/avatars/accessory_necklace_01.glb | Bin 129392 -> 129388 bytes assets/avatars/base_human_male_01.glb | Bin 24908 -> 24864 bytes scenes/dev/avatar_preview.tscn | 6 ++ scenes/dev/screenshot_rig.tscn | 6 ++ scenes/worlds/test_level.tscn | 9 +-- scripts/avatar/limb_animator.gd | 10 ++-- scripts/tools/avatar_preview.gd | 69 ++++++++++++++++++++++ scripts/tools/gen_placeholder_avatars.gd | 66 ++++++++++----------- scripts/tools/screenshot_rig.gd | 72 +++++++++++++++++++++++ 10 files changed, 197 insertions(+), 41 deletions(-) create mode 100644 scenes/dev/avatar_preview.tscn create mode 100644 scenes/dev/screenshot_rig.tscn create mode 100644 scripts/tools/avatar_preview.gd create mode 100644 scripts/tools/screenshot_rig.gd diff --git a/assets/avatars/accessory_belt_01.glb b/assets/avatars/accessory_belt_01.glb index 9d254b05ef82848fba41897a4fe8d14ba0cd5724..fb5c9f4901de71369f24b60b7d9b88c1d2c0cdaa 100644 GIT binary patch delta 58 zcmccfiT%MRcHZ=y5H}_U28M*6j0_?hdGE3bn41`Yfu*UjnYoFv(d3_O+RO?H3e76) L+f~>ZEj|GN5NZ%t delta 53 zcmaFxiT%zecHZ=y5H}_U28NiQj0^%BdGE6ETNr?Wp`oRbfu)h<#s%^K|6HP{&~ GJ^=v7h!48} diff --git a/assets/avatars/accessory_necklace_01.glb b/assets/avatars/accessory_necklace_01.glb index cd460135bd2800f3a40aa956bef531666be091cb..400a3284c272369f617fa626d910625d87debd95 100644 GIT binary patch delta 73 zcmezHiT%wdcHZ=y5H}_U28NuUj0_4JdGE3*8=Dw_fU&W$xrLd5p^kx`Ay~k`z|h3f Xz|hdl&}cFryLz()`*sU%P<7Gt%_fM6Q}ijeO_o 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 literal 24908 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 diff --git a/scenes/dev/avatar_preview.tscn b/scenes/dev/avatar_preview.tscn new file mode 100644 index 0000000..8664331 --- /dev/null +++ b/scenes/dev/avatar_preview.tscn @@ -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") diff --git a/scenes/dev/screenshot_rig.tscn b/scenes/dev/screenshot_rig.tscn new file mode 100644 index 0000000..7ea02ea --- /dev/null +++ b/scenes/dev/screenshot_rig.tscn @@ -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") diff --git a/scenes/worlds/test_level.tscn b/scenes/worlds/test_level.tscn index 64f2eac..89f33a7 100644 --- a/scenes/worlds/test_level.tscn +++ b/scenes/worlds/test_level.tscn @@ -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="."] diff --git a/scripts/avatar/limb_animator.gd b/scripts/avatar/limb_animator.gd index 568acd0..d58aa24 100644 --- a/scripts/avatar/limb_animator.gd +++ b/scripts/avatar/limb_animator.gd @@ -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) diff --git a/scripts/tools/avatar_preview.gd b/scripts/tools/avatar_preview.gd new file mode 100644 index 0000000..bec5a06 --- /dev/null +++ b/scripts/tools/avatar_preview.gd @@ -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) diff --git a/scripts/tools/gen_placeholder_avatars.gd b/scripts/tools/gen_placeholder_avatars.gd index 7089e2d..cf02fe6 100644 --- a/scripts/tools/gen_placeholder_avatars.gd +++ b/scripts/tools/gen_placeholder_avatars.gd @@ -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 diff --git a/scripts/tools/screenshot_rig.gd b/scripts/tools/screenshot_rig.gd new file mode 100644 index 0000000..329a43d --- /dev/null +++ b/scripts/tools/screenshot_rig.gd @@ -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 shots 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()