From ac691dee90fea17e3973ddd1456d1ecae652aea6 Mon Sep 17 00:00:00 2001 From: Jeremy Kashkett Date: Thu, 16 Jul 2026 13:48:10 -0700 Subject: [PATCH] =?UTF-8?q?feat(tools):=20pingpong=5Fbake=20=E2=80=94=20pa?= =?UTF-8?q?lindrome=20loops;=207=20*=5Fpp=20GLBs=20(all=20SMOOTH)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward+reversed baked as one clip: end==start by construction, drift self-cancels, full choreography kept; motion reverses at the turn point. Co-Authored-By: Claude Fable 5 --- exchange/pingpong-glb/aloha1_pp.glb | 3 + exchange/pingpong-glb/alohaOG_pp.glb | 3 + exchange/pingpong-glb/dance1_pp.glb | 3 + exchange/pingpong-glb/firedance1_pp.glb | 3 + exchange/pingpong-glb/hakaOG_pp.glb | 3 + exchange/pingpong-glb/hakadance1_pp.glb | 3 + exchange/pingpong-glb/hakadance2_pp.glb | 3 + tools/pingpong_bake.py | 94 +++++++++++++++++++++++++ 8 files changed, 115 insertions(+) create mode 100644 exchange/pingpong-glb/aloha1_pp.glb create mode 100644 exchange/pingpong-glb/alohaOG_pp.glb create mode 100644 exchange/pingpong-glb/dance1_pp.glb create mode 100644 exchange/pingpong-glb/firedance1_pp.glb create mode 100644 exchange/pingpong-glb/hakaOG_pp.glb create mode 100644 exchange/pingpong-glb/hakadance1_pp.glb create mode 100644 exchange/pingpong-glb/hakadance2_pp.glb create mode 100644 tools/pingpong_bake.py diff --git a/exchange/pingpong-glb/aloha1_pp.glb b/exchange/pingpong-glb/aloha1_pp.glb new file mode 100644 index 0000000..0c87762 --- /dev/null +++ b/exchange/pingpong-glb/aloha1_pp.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:16572122cfc1ade5f4f94108001f0e1b518bb82d2cc0cc000262ffa1025fabe8 +size 3816524 diff --git a/exchange/pingpong-glb/alohaOG_pp.glb b/exchange/pingpong-glb/alohaOG_pp.glb new file mode 100644 index 0000000..6418110 --- /dev/null +++ b/exchange/pingpong-glb/alohaOG_pp.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e8e08f1a7b16ef33721a994b0690d3d99bb0e45aea089068f785924742999fb +size 3816524 diff --git a/exchange/pingpong-glb/dance1_pp.glb b/exchange/pingpong-glb/dance1_pp.glb new file mode 100644 index 0000000..6dc4824 --- /dev/null +++ b/exchange/pingpong-glb/dance1_pp.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:137e45aacf167c02904ed41277b5d22047eedc673ec0c6a068bf8747d8a6bb1e +size 3816524 diff --git a/exchange/pingpong-glb/firedance1_pp.glb b/exchange/pingpong-glb/firedance1_pp.glb new file mode 100644 index 0000000..75a06e6 --- /dev/null +++ b/exchange/pingpong-glb/firedance1_pp.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:836fb72df87a87010dd5ee922ed4aa425270a9b1be89f5eb8de60a93f6727527 +size 3816528 diff --git a/exchange/pingpong-glb/hakaOG_pp.glb b/exchange/pingpong-glb/hakaOG_pp.glb new file mode 100644 index 0000000..db0fbff --- /dev/null +++ b/exchange/pingpong-glb/hakaOG_pp.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f00a5c2099b834ac45593d76cb193269ea2adbec22b8870327ca702f87b71f64 +size 3816524 diff --git a/exchange/pingpong-glb/hakadance1_pp.glb b/exchange/pingpong-glb/hakadance1_pp.glb new file mode 100644 index 0000000..c812b1b --- /dev/null +++ b/exchange/pingpong-glb/hakadance1_pp.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:733fadf410591c25982306df9a37cf6fddacc940b75035f4cd2019be1e007c32 +size 3816528 diff --git a/exchange/pingpong-glb/hakadance2_pp.glb b/exchange/pingpong-glb/hakadance2_pp.glb new file mode 100644 index 0000000..0fb6267 --- /dev/null +++ b/exchange/pingpong-glb/hakadance2_pp.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdc7dfbaf9b5486091dac9ffdc981a137d7d3f40451fe46b6a939d9a1e87595c +size 3816528 diff --git a/tools/pingpong_bake.py b/tools/pingpong_bake.py new file mode 100644 index 0000000..e6b2421 --- /dev/null +++ b/tools/pingpong_bake.py @@ -0,0 +1,94 @@ +"""pingpong_bake.py — bake a clip into a palindrome (forward + reversed) loop. + +Plays the clip forward, then backward, as ONE animation: frames [0..N] followed by +[N-1..1] mirrored (total 2N). The final frame equals frame 0 exactly, so Godot's +LoopMode.Linear wraps with zero pose gap and zero root drift — the character returns +to its start point by construction. The trade-off: motion direction reverses at the +turn point and at the wrap (inherent to the tactic). + +Usage: + blender --background --python tools/pingpong_bake.py -- \ + --src --out --name +""" +import bpy, sys, argparse + +def parse(): + argv = sys.argv[sys.argv.index("--")+1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--src", required=True) + p.add_argument("--out", required=True) + p.add_argument("--name", required=True, help="clip name baked into the output") + return p.parse_args(argv) + +def action_fcurves(a): + """All fcurves across Blender 5 slotted-action layers/strips (legacy fallback).""" + if hasattr(a, "fcurves") and getattr(a, "fcurves", None) is not None: + return list(a.fcurves) + fcs = [] + for layer in getattr(a, "layers", []): + for strip in layer.strips: + for bag in strip.channelbags: + fcs.extend(bag.fcurves) + return fcs + +def main(): + a = parse() + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=a.src) + arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE') + act = arm.animation_data.action + if getattr(act, "slots", None) and arm.animation_data.action_slot is None: + arm.animation_data.action_slot = act.slots[0] + f0, f1 = int(act.frame_range[0]), int(act.frame_range[1]) + n = f1 - f0 + print(f"[pingpong] src clip '{act.name}' frames {f0}..{f1} -> palindrome 0..{2*n}") + + # extend every fcurve in place: append mirrored keys [N-1..0] after frame N + for fc in action_fcurves(act): + vals = [fc.evaluate(f0 + k) for k in range(n + 1)] + base = len(fc.keyframe_points) + fc.keyframe_points.add(n) # frames N+1 .. 2N + # rewrite ALL points to a clean dense 0..2N ramp (handles f0 offset too) + total = 2 * n + need = total + 1 + have = base + n + if have < need: + fc.keyframe_points.add(need - have) + elif have > need: + for _ in range(have - need): + fc.keyframe_points.remove(fc.keyframe_points[-1], fast=True) + for k in range(need): + v = vals[k] if k <= n else vals[2 * n - k] + kp = fc.keyframe_points[k] + kp.co = (k, v) + kp.interpolation = 'LINEAR' + fc.update() + + act.name = a.name + + # drop NLA tracks that came in with the import — only our palindrome track may export + for t in list(arm.animation_data.nla_tracks): + arm.animation_data.nla_tracks.remove(t) + + # single NLA track named = clip name (same convention as cc_retarget.py) + track = arm.animation_data.nla_tracks.new() + track.name = a.name + strip = track.strips.new(a.name, 0, act) + strip.name = a.name + arm.animation_data.action = None + + for o in [o for o in bpy.data.objects if o.type == 'MESH']: + bpy.data.objects.remove(o, do_unlink=True) + + bpy.context.scene.frame_start = 0 + bpy.context.scene.frame_end = 2 * n + bpy.ops.export_scene.gltf( + filepath=a.out, + export_animation_mode="NLA_TRACKS", + export_force_sampling=True, + export_optimize_animation_size=False, + ) + print(f"[pingpong] EXPORTED {a.out} clip '{a.name}' ({2*n+1} frames)") + +if __name__ == "__main__": + main()