The shared clip library ships exactly one `Dance_Loop`, and five copies of one
clip is not five dances. What the runtime does have is a procedural pose layer
over a real skeleton with spring-driven hair and cloth, which is enough — if
the motion is constructed the way an animator would construct it rather than
the way a programmer reaches for first.
Wiring sine waves to bones is that first reach, and everyone can tell. A raw
sine moves fastest through the middle and slowest at the ends by the same
amount on every channel, all in phase, forever. It floats. It has no weight, no
accent, and no sense that one part of the body is driving and the rest is
following. Four principles fix it, and all four are cheap:
OVERLAP the body is a chain. Hips lead, spine follows a beat later,
head last. One subtraction — `beat - lag * i` — and the spring
solver then carries it out through the hair and skirt for free,
because the dance layer runs before it.
ACCENT a dance HITS poses. `shape` bends the wave so it hangs at the
extremes and snaps between them, which is what a key-and-
breakdown pass produces by hand.
WEIGHT the HIPS translate, not just rotate. A body that never leaves
its own axis reads as a puppet on a stick.
CONTRAST Robot deliberately breaks all of the above — zero lag,
quantised motion — and reads as mechanical precisely because
the other four do not.
Spin spots its head: it holds a heading against the turn and whips round to
catch up, which is what a real dancer does to keep from getting dizzy and the
most recognisable thing about a turn.
The dial is a radial menu because every option is then the SAME DISTANCE from
where the pointer starts — the choice is a direction, and a direction becomes
muscle memory in a way "the fourth row down" does not. Selection is by ANGLE
alone, so a flick and a careful nudge do the same thing. HOLD to open, release
to commit; a tap too short to have aimed replays the last emote, which is what
the button did before, so the old habit still works. Pressing while already
dancing just stops — having to aim at something in order to STOP would be the
most annoying possible way to build this.
debug/dance_check.gd asserts the overlap, and getting it to measure that took
four wrong measurements, each of which is now a comment where it was made:
- correlating the hips' TRANSLATION against the head's position relative to
them compared two different quantities at different periods; it ranked the
Robot, whose lag is zero by construction, as the most overlapped routine.
- a signed scalar `angle * sign of the axis's largest component` is
DISCONTINUOUS — as a rocking bone passes back through rest the axis flips —
so smooth Two-Step measured a full-range jump per frame, which is exactly
what quantised motion looks like.
- a bone's GLOBAL rotation carries every ancestor's, so the head correlates
with the hips at lag zero however delayed the head itself is.
- and the hips and head are driven by different channels anyway.
Measuring two links of the SAME chain, as local rotation vectors, agrees with
the authored lag: Spin measures 9 frames against 8.4 authored, Two-Step 7
against 6.6, Robot 0. The Robot is checked on the property it actually has —
its jump per frame is 0.41 of its range against 0.03-0.06 for the others.
RigRoles is pulled out of ShooterPoseModifier so the dance layer resolves bones
the same way rather than carrying a second copy. Two copies is how a rig ends up
animating correctly under one modifier and not the other.
spawn smoke 0 failures, 11/11 movement, 21/21 weapon-hold pairs, contrast 108/108.
Co-Authored-By: Claude Opus 5 <[email protected]>
240 lines
11 KiB
GDScript
240 lines
11 KiB
GDScript
extends SkeletonModifier3D
|
|
class_name DanceModifier
|
|
|
|
## Drives a dance routine onto the skeleton, over whatever clip is playing.
|
|
##
|
|
## Runs AFTER the shooter pose layer and blends over it, so starting a dance
|
|
## takes the arms off the weapon smoothly rather than cutting, and stopping one
|
|
## hands them back the same way. The spring solver runs after both, so hair and
|
|
## cloth follow the dance without anything being asked to make that happen.
|
|
##
|
|
## The routine data — and the reasoning behind why the motion is built the way it
|
|
## is — lives in characters/dance_routines.gd. This is the machine that plays it.
|
|
|
|
## The routine to play. See DanceRoutines.ROUTINES.
|
|
var routine: Dictionary = {}
|
|
## 0..1. Eased by the owner, so a dance fades in and out rather than snapping.
|
|
var weight: float = 0.0
|
|
## `<model>.rig.json` roles, for bone resolution. See RigRoles.
|
|
var roles: Dictionary = {}
|
|
|
|
var _idx: Dictionary = {}
|
|
var _resolved := false
|
|
var _t: float = 0.0
|
|
|
|
## Per-link overlap, in seconds, from the hips outward. Index 0 is the hips.
|
|
const CHAIN := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
|
|
|
|
|
|
func _process_modification() -> void:
|
|
var skel := get_skeleton()
|
|
if skel == null or weight <= 0.001 or routine.is_empty():
|
|
return
|
|
if not _resolved:
|
|
_idx = RigRoles.resolve(skel, roles)
|
|
_resolved = true
|
|
|
|
var delta := get_physics_process_delta_time() if Engine.is_in_physics_frame() \
|
|
else get_process_delta_time()
|
|
_t += delta
|
|
|
|
var bpm: float = float(routine.get("bpm", 100.0))
|
|
var beat := _t * bpm / 60.0
|
|
var lag: float = float(routine.get("lag", 0.05)) * bpm / 60.0
|
|
var w := weight
|
|
|
|
# ── Hips: the driver ────────────────────────────────────────────────────
|
|
#
|
|
# Translated, not just rotated. A body that never leaves its own axis reads
|
|
# as a puppet on a stick; moving the hips is what makes the legs look like
|
|
# they are carrying someone, and every other channel below is a reaction to
|
|
# this one.
|
|
var swing := _wave(beat, 2.0, 0.0)
|
|
var bob := _wave(beat, float(routine.get("hip_bob_beats", 1.0)), 0.25)
|
|
var hips: int = _idx.get("DEF-hips", -1)
|
|
if hips >= 0:
|
|
# Character-right is -X in skeleton space, up is +Y.
|
|
var offset := Vector3(-swing * float(routine.get("hip_swing", 0.0)),
|
|
bob * float(routine.get("hip_bob", 0.0)), 0.0) * w
|
|
_offset_bone(skel, hips, offset)
|
|
_add_space(skel, hips,
|
|
Quaternion(Vector3(0, 0, 1), swing * float(routine.get("hip_roll", 0.0)) * w)
|
|
* Quaternion(Vector3(0, 1, 0), swing * float(routine.get("hip_yaw", 0.0)) * w)
|
|
* Quaternion(Vector3(1, 0, 0), bob * float(routine.get("hip_pitch", 0.0)) * w))
|
|
|
|
# ── Spine: the same motion, later ───────────────────────────────────────
|
|
#
|
|
# Each link reads the wave at `beat - lag * i`, which is the whole of
|
|
# overlapping action. It is one subtraction and it is the difference between
|
|
# a rig oscillating and a person moving.
|
|
var counter: float = float(routine.get("spine_counter", 0.0))
|
|
var links := CHAIN.slice(1)
|
|
var n := maxf(links.size(), 1)
|
|
for i in links.size():
|
|
var b: int = _idx.get(links[i], -1)
|
|
if b < 0:
|
|
continue
|
|
var at := beat - lag * float(i + 1)
|
|
var s := _wave(at, 2.0, 0.0)
|
|
var v := _wave(at, float(routine.get("hip_bob_beats", 1.0)), 0.25)
|
|
var q := Quaternion(Vector3(0, 0, 1),
|
|
s * float(routine.get("spine_roll", 0.0)) * w / n) \
|
|
* Quaternion(Vector3(0, 1, 0),
|
|
(s * float(routine.get("spine_yaw", 0.0))
|
|
- s * float(routine.get("hip_yaw", 0.0)) * counter) * w / n) \
|
|
* Quaternion(Vector3(1, 0, 0),
|
|
v * float(routine.get("spine_pitch", 0.0)) * w / n)
|
|
_add_space(skel, b, q)
|
|
|
|
_head(skel, beat, lag, w)
|
|
_arms(skel, beat, lag, w)
|
|
_legs(skel, beat, w)
|
|
|
|
|
|
## Head and neck: the last link in the chain, and the one carrying the spot.
|
|
func _head(skel: Skeleton3D, beat: float, lag: float, w: float) -> void:
|
|
var at := beat - lag * float(CHAIN.size())
|
|
var s := _wave(at, 2.0, 0.0)
|
|
var v := _wave(at, float(routine.get("hip_bob_beats", 1.0)), 0.25)
|
|
|
|
var yaw: float = s * float(routine.get("head_yaw", 0.0))
|
|
|
|
# SPOTTING. A dancer turning keeps their head pointed at one place for as
|
|
# long as they can, then whips it round to catch up. It is what stops them
|
|
# getting dizzy, and it is the single most recognisable thing about a turn —
|
|
# a head that simply rotates with the shoulders reads as a mannequin on a
|
|
# turntable.
|
|
#
|
|
# So this is not a wave. The head COUNTERS the body's yaw exactly while the
|
|
# hold lasts, then releases over a short window and lets the neck catch up.
|
|
var spot: float = float(routine.get("spot", 0.0))
|
|
if spot > 0.001:
|
|
var body_yaw: float = s * float(routine.get("hip_yaw", 0.0)) \
|
|
+ s * float(routine.get("spine_yaw", 0.0))
|
|
# Where in the two-beat turn we are, 0..1.
|
|
var u := fposmod(at / 2.0, 1.0)
|
|
# Hold for the first 70%, then whip round over the next 20%, then arrive.
|
|
var hold := 1.0 - smoothstep(0.70, 0.90, u)
|
|
yaw -= body_yaw * spot * hold
|
|
|
|
var q := Quaternion(Vector3(0, 1, 0), yaw * w) \
|
|
* Quaternion(Vector3(0, 0, 1), s * float(routine.get("head_roll", 0.0)) * w) \
|
|
* Quaternion(Vector3(1, 0, 0),
|
|
(v * float(routine.get("head_bob", 0.0))
|
|
+ v * float(routine.get("head_pitch", 0.0))) * w)
|
|
# Split, so the whole column leans rather than the skull hinging off a rigid
|
|
# neck.
|
|
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(q, 0.4))
|
|
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(q, 0.6))
|
|
|
|
|
|
## Arms: out from the body, swinging in opposition, elbows folding on the beat.
|
|
##
|
|
## The two arms are half a cycle apart, which is what opposition is. Both read
|
|
## the wave later than the spine did, so the hands are the last thing to arrive —
|
|
## the end of the chain, where overlap is most visible.
|
|
func _arms(skel: Skeleton3D, beat: float, lag: float, w: float) -> void:
|
|
var at := beat - lag * float(CHAIN.size() + 1)
|
|
var beats: float = float(routine.get("arm_beats", 2.0))
|
|
var out: float = float(routine.get("arm_out", 0.0))
|
|
var swing: float = float(routine.get("arm_swing", 0.0))
|
|
var elbow: float = float(routine.get("elbow", 0.0))
|
|
|
|
for sign_i in 2:
|
|
var right := sign_i == 0
|
|
var side := 1.0 if right else -1.0
|
|
var s := _wave(at + (0.0 if right else beats * 0.5), beats, 0.0)
|
|
var ua: int = _idx.get("DEF-upper_arm." + ("R" if right else "L"), -1)
|
|
var fa: int = _idx.get("DEF-forearm." + ("R" if right else "L"), -1)
|
|
if ua >= 0:
|
|
# Character-right is -X, so a positive Z rotation lifts the LEFT arm
|
|
# and drops the right — hence the side flip. `out` is a static lift
|
|
# that the swing then rides on top of, which is what stops the arms
|
|
# from passing through the body at the bottom of the stroke.
|
|
_add_space(skel, ua,
|
|
Quaternion(Vector3(0, 0, 1), -side * (out + s * 0.35 * swing) * w)
|
|
* Quaternion(Vector3(1, 0, 0), s * swing * w))
|
|
if fa >= 0:
|
|
# The elbow only ever folds, never hyperextends: a signed wave here
|
|
# bends the forearm backwards through the joint on half of every
|
|
# cycle, which is the most obvious possible tell.
|
|
var fold := (0.5 + 0.5 * s) * elbow
|
|
_add_space(skel, fa, Quaternion(Vector3(1, 0, 0), fold * w))
|
|
|
|
|
|
## Legs: knees absorbing the bob, out of phase with each other so the weight
|
|
## visibly transfers from one to the other.
|
|
func _legs(skel: Skeleton3D, beat: float, w: float) -> void:
|
|
var knee: float = float(routine.get("knee", 0.0))
|
|
if knee <= 0.001:
|
|
return
|
|
var beats: float = float(routine.get("hip_bob_beats", 1.0))
|
|
for sign_i in 2:
|
|
var right := sign_i == 0
|
|
var s := _wave(beat + (0.0 if right else beats * 0.5), beats, 0.25)
|
|
var thigh: int = _idx.get("DEF-thigh." + ("R" if right else "L"), -1)
|
|
var shin: int = _idx.get("DEF-shin." + ("R" if right else "L"), -1)
|
|
var bend := (0.5 + 0.5 * s) * knee
|
|
# Thigh forward and shin back by twice as much, so the foot stays roughly
|
|
# under the hip instead of the whole leg swinging out in front.
|
|
_add_space(skel, thigh, Quaternion(Vector3(1, 0, 0), -bend * 0.5 * w))
|
|
_add_space(skel, shin, Quaternion(Vector3(1, 0, 0), bend * w))
|
|
|
|
|
|
# ── The wave ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
## One channel's value at `beat`, in -1..1.
|
|
##
|
|
## `shape` bends a sine so it HANGS at the extremes and SNAPS between them, which
|
|
## is what an animator's key-and-breakdown pass produces and what a raw sine
|
|
## cannot. At shape 1 this is exactly `sin`; above it the curve gets punchier
|
|
## while staying continuous and staying in -1..1, so no amount of shaping can
|
|
## make a channel overshoot its authored amplitude.
|
|
##
|
|
## `steps` quantises the result instead, for the robot — the one routine whose
|
|
## whole point is that it does NOT move like the others.
|
|
func _wave(beat: float, beats: float, phase: float) -> float:
|
|
if beats <= 0.001:
|
|
return 0.0
|
|
var u := beat / beats + phase
|
|
var steps: int = int(routine.get("steps", 0))
|
|
if steps > 0:
|
|
# Hold a value for a whole step, then jump. Rounded rather than floored
|
|
# so the extremes are actually reached — a floor never returns +1.
|
|
return sin(TAU * (round(u * float(steps)) / float(steps)))
|
|
var s := sin(TAU * u)
|
|
var shape: float = float(routine.get("shape", 1.0))
|
|
if is_equal_approx(shape, 1.0):
|
|
return s
|
|
return signf(s) * pow(absf(s), 1.0 / shape)
|
|
|
|
|
|
# ── Bone plumbing ────────────────────────────────────────────────────────────
|
|
|
|
|
|
## Move a bone by an offset expressed in SKELETON space.
|
|
##
|
|
## A bone's pose position is in its PARENT's space, so the offset has to be
|
|
## rotated out of skeleton space by the parent's rest basis first. Skipping that
|
|
## sends the hips sideways in whatever direction the rig happens to have called
|
|
## "x", which differs per character.
|
|
func _offset_bone(skel: Skeleton3D, idx: int, offset: Vector3) -> void:
|
|
if idx < 0 or offset == Vector3.ZERO:
|
|
return
|
|
var parent := skel.get_bone_parent(idx)
|
|
var local := offset
|
|
if parent >= 0:
|
|
local = skel.get_bone_global_rest(parent).basis.inverse() * offset
|
|
skel.set_bone_pose_position(idx, skel.get_bone_rest(idx).origin + local)
|
|
|
|
|
|
## Compose a skeleton-space rotation onto a bone's animated local pose. Same
|
|
## contract as ShooterPoseModifier._add_space.
|
|
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
|
|
if idx < 0:
|
|
return
|
|
var b := skel.get_bone_global_rest(idx).basis.get_rotation_quaternion()
|
|
var local := b.inverse() * q_space * b
|
|
skel.set_bone_pose_rotation(idx, skel.get_bone_pose_rotation(idx) * local)
|