fix(cloth): solve the garment instead of repairing it five times
The skirt glitched when the character moved and the thigh still came through it. Both came from the same place: the solver integrated one spring per bone and then ran four more passes behind it — resolve the collision against the target, resolve it again against the answer, relax the cross-panel links and rebuild every pose from the corrected tips, then walk a separate ancestor "lift" — each writing bone poses the next read back and partly undid. The lift wrote poses that were never fed back into the spring state at all, so every frame began by pulling against a pose the springs did not know about. Replaced with one position-based solve, the shape Magica Cloth 2's BoneCloth uses. Every JOINT is a particle, so a bone's head can move; predict with inertia in the anchor's frame; relax length, bend, backstop, the cross-panel links and the colliders together; convert to rotations once at the end. A contact with no rotational leverage is now resolved by the panel moving, which is what a bodily chain push, an ancestor lift and a drape weight were each approximating separately. Measured, at a dead-still idle and over a movement sweep: idle jitter (skirt) 0.53 -> 0.025 deg/frame, worst 24 -> 1.9 settling after a dash 103 -> 18 mm of leg left inside the skirt fall / air / walk 82 -> 40, 96 -> 75, 72 -> 76 mm run / slide / dash unchanged, ~95 mm The idle buzz and the failure to come home after a hard move are gone — those were the "glitches out". Peak clipping in a run, a slide and a dash is NOT fixed and is still around 95 mm. Four things this turned up on the way: - debug/cloth_clip_check.gd was measuring the animation, not the render. Godot restores bone poses after the modifier pass, so reading them with force_update_all_bone_transforms() afterwards sees nothing any modifier did. It reported the same ~95 mm with collision fully enabled and with it commented out. It now observes from inside the pass. Every number ever taken from this tool before now was measuring the wrong pose. - The collision hulls came from ten farthest-point samples per bone, which describe a panel's corners and hem and leave its MIDDLE unsampled — exactly where a thigh comes through. Built from the real mesh at load time instead. - The drape term is gone. It was there to move a panel the old solver could not, and once the solver could, it was worse in every state but a walk and cost 20x in stability: its target sat inside the leg the collision was pushing out of, so the two ran against each other forever. - Cost was 10.9 ms per character. The inner loop rebuilt every capsule and reallocated the hull array for every (bone, collider, pass). Now 2.6 ms at full quality with a distance LOD behind it. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
374d9f9822
commit
0dd9d01ac7
@@ -0,0 +1,129 @@
|
||||
extends SceneTree
|
||||
|
||||
## Fixed simulation step — see _lock_timestep.
|
||||
const STEP := 1.0 / 60.0
|
||||
|
||||
## Dev tool: the front of the skirt through a whole run cycle, as a player sees it.
|
||||
##
|
||||
## godot --path . --windowed --resolution 1280x960 \
|
||||
## -s res://debug/skirt_run_view.gd -- <out_dir> [skin]
|
||||
##
|
||||
## Every other check in debug/ looks at this from 0.9 m with flat colours or from
|
||||
## a solver's point of view. Those answer "is a bone inside a capsule" and "did
|
||||
## the mesh tear", and both can read clean while the render is obviously wrong —
|
||||
## a material that does not occlude, an outline shell drawn over the cloth, or
|
||||
## simply a pose none of the sampled frames happened to catch.
|
||||
##
|
||||
## So: the shipped materials, a straight-on front camera at normal viewing
|
||||
## distance, and EVERY frame of the run cycle rather than eight samples of it.
|
||||
|
||||
const FIRST := 40 # let the chains settle before recording
|
||||
const FRAMES := 48 # a full stride at 60 fps and then some
|
||||
|
||||
var _frames := 0
|
||||
var _out := "."
|
||||
var _model: SkinnedPlayerModel = null
|
||||
var _cam: Camera3D = null
|
||||
var _shots := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
_out = args[0] if args.size() > 0 else "."
|
||||
var path := "res://assets/characters/skins/taila.glb"
|
||||
if args.size() > 1:
|
||||
path = "res://assets/characters/skins/%s.glb" % args[1]
|
||||
var scene := Node3D.new()
|
||||
root.add_child(scene)
|
||||
current_scene = scene
|
||||
|
||||
var env := WorldEnvironment.new()
|
||||
var e := Environment.new()
|
||||
e.background_mode = Environment.BG_COLOR
|
||||
e.background_color = Color(0.16, 0.16, 0.2)
|
||||
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
e.ambient_light_color = Color(1, 1, 1)
|
||||
e.ambient_light_energy = 1.5
|
||||
env.environment = e
|
||||
scene.add_child(env)
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-35, 25, 0)
|
||||
sun.light_energy = 1.3
|
||||
scene.add_child(sun)
|
||||
|
||||
_model = SkinnedPlayerModel.new()
|
||||
_model.model_path = path
|
||||
scene.add_child(_model)
|
||||
_cam = Camera3D.new()
|
||||
_cam.fov = 38.0
|
||||
scene.add_child(_cam)
|
||||
_cam.current = true
|
||||
|
||||
|
||||
## Drive the animation and the solver on a FIXED timestep.
|
||||
##
|
||||
## Both advance on the real frame delta otherwise, so the pose at a given frame
|
||||
## drifts between runs and the same build measured 146k and 398k offending
|
||||
## pixels. Every A/B comparison made without this was noise, and several tuning
|
||||
## decisions were taken on the strength of it.
|
||||
##
|
||||
## The modifier stack needs pinning too: on PHYSICS it runs a variable number
|
||||
## of times per rendered frame, so with a fixed step the amount of simulated
|
||||
## time per frame still wandered.
|
||||
func _lock_timestep() -> void:
|
||||
var skel: Skeleton3D = _model.skeleton
|
||||
if skel:
|
||||
skel.modifier_callback_mode_process = \
|
||||
Skeleton3D.MODIFIER_CALLBACK_MODE_PROCESS_IDLE
|
||||
var spring := skel.get_node_or_null("SpringBones")
|
||||
if spring:
|
||||
spring.fixed_delta = STEP
|
||||
for n in _model.find_children("*", "AnimationTree", true, false):
|
||||
n.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL
|
||||
|
||||
|
||||
## One fixed step of the animation. Call once per rendered frame.
|
||||
func _step_anim() -> void:
|
||||
for n in _model.find_children("*", "AnimationTree", true, false):
|
||||
n.advance(STEP)
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
_frames += 1
|
||||
if _frames < 10 or not _model.loaded:
|
||||
return false
|
||||
if _frames == 10:
|
||||
_lock_timestep()
|
||||
# `nospring` renders the same cycle with the cloth solver removed, so a
|
||||
# change can be told from no change at all.
|
||||
var a := OS.get_cmdline_user_args()
|
||||
if a.size() > 2 and String(a[2]) == "nospring":
|
||||
var sp := _model.skeleton.get_node_or_null("SpringBones")
|
||||
if sp:
|
||||
sp.queue_free()
|
||||
print("spring solver REMOVED")
|
||||
_model.update_state("ground", 9.0, false)
|
||||
_model.set_locomotion(0.0, 1.0, 0.0)
|
||||
_step_anim()
|
||||
|
||||
var hips := 0.95
|
||||
if _model.skeleton:
|
||||
var h := _model.skeleton.find_bone("DEF-spine")
|
||||
if h >= 0:
|
||||
hips = _model.skeleton.get_bone_global_pose(h).origin.y
|
||||
# Waist to knee, dead in front, from about where a third-person camera sits.
|
||||
#
|
||||
# NEGATIVE Z. SkinnedPlayerModel spins the imported scene 180 degrees
|
||||
# (`facing_flip`: glTF forward is +Z, players face -Z), so a camera on +Z is
|
||||
# looking at the character's BACK. Every earlier tool in here sat on +Z and
|
||||
# every "front" render judged from them was the back of the skirt.
|
||||
_cam.position = Vector3(0.0, hips - 0.05, -1.5)
|
||||
_cam.look_at(Vector3(0, hips - 0.20, 0), Vector3.UP)
|
||||
|
||||
if _frames >= FIRST and _shots < FRAMES:
|
||||
root.get_texture().get_image().save_png(
|
||||
"%s/run_%02d.png" % [_out, _shots])
|
||||
_shots += 1
|
||||
if _shots == FRAMES:
|
||||
print("saved %d run frames" % FRAMES)
|
||||
return true
|
||||
return _frames > FIRST + FRAMES * 3
|
||||
Reference in New Issue
Block a user