This commit is contained in:
Nicholas Butzke
2026-08-02 02:20:02 -04:00
parent 61669627db
commit 922983429e
226 changed files with 34032 additions and 18521 deletions
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env python
"""Release gate for runtime audio assets.
Checks format, headroom, clipping, DC offset, loop continuity and event-family
coverage. It intentionally uses only the Python standard library plus
ffprobe, matching the rest of the repository's tooling.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import shutil
import statistics
import struct
import subprocess
import sys
import tempfile
import wave
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOUNDS = ROOT / "assets" / "sounds"
FFPROBE = shutil.which("ffprobe")
FFMPEG = shutil.which("ffmpeg")
FAMILIES = {
"weapon_ak47": 3,
"weapon_m4": 3,
"weapon_mp7": 3,
"weapon_dmr": 2,
"weapon_awp": 2,
"weapon_shotgun": 2,
"weapon_nailgun": 2,
"weapon_plasma": 2,
"weapon_rocket": 1,
"weapon_swarm": 2,
"weapon_mortar": 1,
"weapon_knife": 3,
"weapon_reload": 1,
"explosion_deep": 5,
"impact_bullet": 3,
"footstep_concrete": 4,
"footstep_metal": 4,
"footstep_wood": 4,
"footstep_glass": 4,
"ui_hover_premium": 2,
"ui_click_premium": 2,
"ui_confirm_premium": 1,
"ui_kill_premium": 1,
"ui_error_premium": 1,
"ui_equip_premium": 1,
"movement_grapple_launch": 1,
"movement_grapple_latch": 1,
}
LOOPS = {
"movement_wind.wav": 2,
"movement_slide_concrete.wav": 2,
"movement_slide_metal.wav": 2,
"movement_slide_wood.wav": 2,
"movement_slide_glass.wav": 2,
"movement_wallrun.wav": 2,
"movement_grapple_reel.wav": 2,
"projectile_rocket_loop.wav": 1,
"projectile_swarm_loop.wav": 1,
}
ONESHOTS = (
"movement_dash_00.wav",
"movement_jump_00.wav",
"movement_double_jump_00.wav",
"movement_vault_00.wav",
"movement_land_00.wav",
"movement_grapple_launch_00.wav",
"movement_grapple_latch_00.wav",
)
def db(value: float) -> float:
return 20.0 * math.log10(max(value, 1.0e-12))
def wav_metrics(path: Path) -> dict[str, float | int]:
with wave.open(str(path), "rb") as source:
channels = source.getnchannels()
rate = source.getframerate()
width = source.getsampwidth()
count = source.getnframes()
if width != 2:
raise ValueError(f"{path.name}: expected 16-bit PCM, got {width * 8}-bit")
raw = source.readframes(count)
samples = struct.unpack("<" + "h" * (len(raw) // 2), raw)
mono: list[float] = []
for frame in range(count):
start = frame * channels
mono.append(sum(samples[start:start + channels]) / (32768.0 * channels))
peak = max((abs(v) for v in mono), default=0.0)
mean = sum(mono) / max(1, count)
rms = math.sqrt(sum(v * v for v in mono) / max(1, count))
diffs = [mono[i] - mono[i - 1] for i in range(1, count)]
diff_rms = math.sqrt(sum(v * v for v in diffs) / max(1, len(diffs)))
sorted_diff = sorted(abs(v) for v in diffs)
typical_diff = sorted_diff[len(sorted_diff) // 2] if sorted_diff else 0.0
seam = abs(mono[-1] - mono[0]) if mono else 0.0
clipped = sum(1 for value in samples if abs(value) >= 32767)
return {
"channels": channels,
"rate": rate,
"duration": count / rate,
"peak_db": db(peak),
"rms_db": db(rms),
"crest_db": db(peak / max(rms, 1.0e-12)),
"dc": abs(mean),
"clip_samples": clipped,
"roughness": diff_rms / max(rms, 1.0e-12),
"seam": seam,
"seam_ratio": seam / max(typical_diff, 1.0e-6),
}
def inspect_music(path: Path) -> dict:
if not FFPROBE:
raise RuntimeError("ffprobe is required to inspect Ogg music")
result = subprocess.run([
FFPROBE, "-v", "error", "-show_entries",
"format=duration:stream=sample_rate,channels", "-of", "json", str(path),
], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def spectral_metrics(path: Path) -> dict[str, float]:
"""Active-frame spectral distribution from FFmpeg's spectrum analyzer."""
if not FFMPEG:
raise RuntimeError("ffmpeg is required for spectral audio gates")
result = subprocess.run([
FFMPEG, "-hide_banner", "-nostats", "-v", "error", "-i", str(path),
"-af", "aspectralstats=measure=centroid+flatness,ametadata=print:file=-",
"-f", "null", os.devnull,
], check=True, capture_output=True, text=True)
centroids: list[float] = []
flatness: list[float] = []
for line in (result.stdout + result.stderr).splitlines():
if "centroid=" in line:
value = float(line.rsplit("=", 1)[1])
if value > 20.0:
centroids.append(value)
elif "flatness=" in line:
value = float(line.rsplit("=", 1)[1])
if value > 0.0:
flatness.append(value)
if not centroids or not flatness:
raise RuntimeError(f"{path.name}: no active spectral frames")
centroids.sort()
flatness.sort()
def percentile(values: list[float], amount: float) -> float:
index = min(len(values) - 1, round((len(values) - 1) * amount))
return values[index]
return {
"centroid": statistics.median(centroids),
"centroid_p90": percentile(centroids, 0.90),
"flatness": statistics.median(flatness),
"flatness_p90": percentile(flatness, 0.90),
}
def music_metrics(path: Path) -> dict[str, float | int]:
if not FFMPEG:
raise RuntimeError("ffmpeg is required to inspect the music loop seam")
with tempfile.TemporaryDirectory(prefix="papaya-music-audit-") as folder:
decoded = Path(folder) / "music.wav"
subprocess.run([
FFMPEG, "-hide_banner", "-loglevel", "error", "-y",
"-i", str(path), "-ar", "48000", "-ac", "2",
"-c:a", "pcm_s16le", str(decoded),
], check=True)
return wav_metrics(decoded)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
failures: list[str] = []
checked: set[Path] = set()
metrics_by_name: dict[str, dict[str, float | int]] = {}
for family, minimum in FAMILIES.items():
matches = sorted(SOUNDS.glob(f"{family}_[0-9][0-9].wav"))
if len(matches) < minimum:
failures.append(
f"{family}: needs {minimum} variations, found {len(matches)}")
checked.update(matches)
for filename, channels in LOOPS.items():
path = SOUNDS / filename
if not path.exists():
failures.append(f"{filename}: missing loop")
continue
checked.add(path)
metrics = wav_metrics(path)
if metrics["channels"] != channels:
failures.append(
f"{filename}: expected {channels} channels, got {metrics['channels']}")
if metrics["duration"] < 3.5:
failures.append(f"{filename}: loop shorter than 3.5 seconds")
if filename == "movement_wind.wav" and metrics["duration"] < 40.0:
failures.append(
f"{filename}: speed-wind cycle is still perceptibly short "
f"({metrics['duration']:.1f}s)")
if metrics["seam_ratio"] > 6.0:
failures.append(
f"{filename}: audible loop seam ratio {metrics['seam_ratio']:.2f}")
for filename in ONESHOTS:
path = SOUNDS / filename
if not path.exists():
failures.append(f"{filename}: missing movement one-shot")
else:
checked.add(path)
for path in sorted(checked):
try:
metrics = wav_metrics(path)
except (wave.Error, ValueError) as exc:
failures.append(str(exc))
continue
metrics_by_name[path.name] = metrics
if metrics["rate"] != 48_000:
failures.append(f"{path.name}: expected 48 kHz, got {metrics['rate']}")
if metrics["peak_db"] > -1.0:
failures.append(f"{path.name}: unsafe peak {metrics['peak_db']:.2f} dBFS")
if metrics["clip_samples"]:
failures.append(
f"{path.name}: {metrics['clip_samples']} clipped PCM samples")
if metrics["dc"] > 0.012:
failures.append(f"{path.name}: DC offset {metrics['dc']:.4f}")
# High first-difference energy catches the old raw/screaming noise beds.
if path.name in LOOPS and metrics["roughness"] > 1.45:
failures.append(
f"{path.name}: harsh loop roughness {metrics['roughness']:.2f}")
if args.verbose:
print(
f"{path.name:34} peak {metrics['peak_db']:6.1f} "
f"rms {metrics['rms_db']:6.1f} crest {metrics['crest_db']:5.1f} "
f"rough {metrics['roughness']:4.2f} seam {metrics['seam_ratio']:4.2f}")
# Constant friction/motor loops must remain materially structured. Wind is
# exempt from flatness because genuine turbulent air is broadband by nature.
for filename in LOOPS:
if filename == "movement_wind.wav":
continue
try:
spectrum = spectral_metrics(SOUNDS / filename)
if spectrum["flatness"] > 0.48:
failures.append(
f"{filename}: noise-like spectral flatness "
f"{spectrum['flatness']:.2f}")
if spectrum["centroid_p90"] > 3_200.0:
failures.append(
f"{filename}: grating upper-spectrum centroid "
f"{spectrum['centroid_p90']:.0f} Hz")
if args.verbose:
print(
f"{filename:34} centroid {spectrum['centroid']:7.0f} Hz "
f"p90 {spectrum['centroid_p90']:7.0f} Hz "
f"flatness {spectrum['flatness']:.3f}/"
f"{spectrum['flatness_p90']:.3f}")
except (RuntimeError, ValueError, subprocess.CalledProcessError) as exc:
failures.append(str(exc))
# The criticized high/childish families stay physically low, while the
# explosion family must sit clearly above firearms before bus processing.
low_tone_files = [
*sorted(SOUNDS.glob("ui_*_premium_[0-9][0-9].wav")),
SOUNDS / "movement_dash_00.wav",
SOUNDS / "movement_jump_00.wav",
SOUNDS / "movement_double_jump_00.wav",
SOUNDS / "movement_grapple_launch_00.wav",
SOUNDS / "movement_grapple_latch_00.wav",
]
for path in low_tone_files:
try:
spectrum = spectral_metrics(path)
if spectrum["centroid"] > 4_200.0:
failures.append(
f"{path.name}: high-pitched centroid "
f"{spectrum['centroid']:.0f} Hz")
if spectrum["centroid_p90"] > 2_800.0:
failures.append(
f"{path.name}: harsh transient centroid "
f"{spectrum['centroid_p90']:.0f} Hz")
roughness = float(metrics_by_name[path.name]["roughness"])
if roughness > 0.18:
failures.append(
f"{path.name}: grating transient roughness {roughness:.2f}")
if args.verbose:
print(
f"{path.name:34} tone {spectrum['centroid']:7.0f} Hz "
f"p90 {spectrum['centroid_p90']:7.0f} Hz")
except (RuntimeError, ValueError, subprocess.CalledProcessError) as exc:
failures.append(str(exc))
explosion_rms = [
float(values["rms_db"]) for name, values in metrics_by_name.items()
if name.startswith("explosion_deep_")
]
firearm_rms = [
float(values["rms_db"]) for name, values in metrics_by_name.items()
if name.startswith("weapon_") and "reload" not in name
]
if explosion_rms and firearm_rms:
if statistics.median(explosion_rms) < statistics.median(firearm_rms) + 4.0:
failures.append(
"explosions lack the required 4 dB median body advantage")
if max(explosion_rms) < -15.0:
failures.append("explosion family is still too quiet")
music = SOUNDS / "music_menu_premium.ogg"
if not music.exists():
failures.append("music_menu_premium.ogg: missing")
else:
try:
info = inspect_music(music)
stream = info["streams"][0]
duration = float(info["format"]["duration"])
if int(stream["sample_rate"]) != 48_000 or int(stream["channels"]) != 2:
failures.append("music_menu_premium.ogg: expected 48 kHz stereo")
if duration < 25.0:
failures.append(
f"music_menu_premium.ogg: too short ({duration:.1f}s)")
metrics = music_metrics(music)
if metrics["seam_ratio"] > 6.0:
failures.append(
"music_menu_premium.ogg: audible loop seam "
f"ratio {metrics['seam_ratio']:.2f}")
if metrics["clip_samples"] or metrics["peak_db"] > -1.0:
failures.append("music_menu_premium.ogg: unsafe decoded peak")
except (RuntimeError, KeyError, ValueError, subprocess.CalledProcessError) as exc:
failures.append(f"music_menu_premium.ogg: {exc}")
print(
f"AUDIO_AUDIT files={len(checked) + int(music.exists())} "
f"families={len(FAMILIES)} loops={len(LOOPS)} failures={len(failures)}")
for failure in failures:
print("FAIL:", failure)
if failures:
return 1
print("AUDIO_AUDIT PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Measure authored ground speed from root-motion animation clips.
Usage:
blender --background --python tools/audit_animation_speeds.py -- \
<library.glb> <clip> [<clip> ...]
The reported metres/second is the ground-plane displacement of the resolved
hips bone over the clip's own duration. Use the root-motion edition of an
animation library; an in-place edition correctly reports approximately zero.
"""
import os
import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rig_map import RigRoles
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) < 2:
print(__doc__)
sys.exit(1)
LIBRARY = argv[0]
CLIPS = argv[1:]
def assign_action(armature, action):
if not armature.animation_data:
armature.animation_data_create()
armature.animation_data.action = action
try:
if len(action.slots):
armature.animation_data.action_slot = action.slots[0]
except (AttributeError, TypeError):
pass
def hips_world_position(armature, hips_name, frame):
bpy.context.scene.frame_set(int(round(frame)))
depsgraph = bpy.context.evaluated_depsgraph_get()
evaluated = armature.evaluated_get(depsgraph)
return (
evaluated.matrix_world @ evaluated.pose.bones[hips_name].matrix
).translation
def main():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
bpy.ops.import_scene.gltf(filepath=LIBRARY)
armatures = [obj for obj in bpy.data.objects if obj.type == "ARMATURE"]
if len(armatures) != 1:
print(f"ERROR: expected one armature, found {len(armatures)}")
sys.exit(1)
armature = armatures[0]
roles = RigRoles(armature)
if not roles.hips:
print("ERROR: could not resolve hips bone")
sys.exit(1)
fps = (
bpy.context.scene.render.fps
/ bpy.context.scene.render.fps_base
)
if CLIPS == ["--list"]:
for action in sorted(bpy.data.actions, key=lambda item: item.name):
print(action.name)
return
failed = False
print(f"AUTHORED_SPEED_AUDIT fps={fps:.3f} hips={roles.hips}")
for clip in CLIPS:
action = bpy.data.actions.get(clip)
if action is None:
print(f"ERROR: missing clip {clip}")
failed = True
continue
assign_action(armature, action)
first, last = (float(value) for value in action.frame_range)
start = hips_world_position(armature, roles.hips, first)
end = hips_world_position(armature, roles.hips, last)
displacement = end - start
displacement.z = 0.0
duration = (last - first) / fps
speed = displacement.length / duration if duration > 1e-6 else 0.0
print(
f"AUTHORED_SPEED {clip} speed={speed:.5f}m/s "
f"distance={displacement.length:.5f}m "
f"duration={duration:.5f}s "
f"delta=({displacement.x:.5f},{displacement.y:.5f})"
)
sys.exit(1 if failed else 0)
main()
+655
View File
@@ -0,0 +1,655 @@
#!/usr/bin/env python
"""Build Papaya Shooter's mastered audio set.
The checked-in source recordings remain untouched. This script creates the
runtime masters with deterministic filtering/layering so their peaks, tone and
loop seams cannot drift between hand edits.
Requirements:
Python 3.10+
ffmpeg on PATH
"""
from __future__ import annotations
import math
import random
import shutil
import struct
import subprocess
import tempfile
import urllib.request
import wave
import hashlib
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOUNDS = ROOT / "assets" / "sounds"
RATE = 48_000
FFMPEG = shutil.which("ffmpeg")
FFPROBE = shutil.which("ffprobe")
MENU_SOURCE = "https://opengameart.org/sites/default/files/pynchon.mp3"
MENU_SOURCE_SHA256 = "295bc30016587d909a8b1d494cc7fb13550f5c74807221e9eb0eb9f2769d4e3b"
SOURCE_FILES = {
# Field-recorded wind, already authored as a loop. CC-BY 3.0:
# Jonathan Shaw (InspectorJ), loop edit by AntumDeluge.
"wind": (
"https://opengameart.org/sites/default/files/wind-01_0.flac",
"4027477c493e26db8e2ea53793a30e376c4665de67c0333d7480777cd85e1639",
"wind-01.flac",
),
# All sources below are CC0. See assets/sounds/SOURCES.md.
"generator": (
"https://opengameart.org/sites/default/files/generator_loop.wav",
"64b174cf8ef71e7f71aa03cf29864ab489f7be1f624456cab916043da38911bd",
"generator_loop.wav",
),
"stomp": (
"https://opengameart.org/sites/default/files/stomp.flac",
"c9114c31ab07a371db12bfb22190d3a8e08424813319b67b6040df1f23fa9bba",
"stomp.flac",
),
"scrapes": (
"https://opengameart.org/sites/default/files/scrapes.zip",
"9598070ddd77ed99e922df1d4472a249624adcd945794d1b6eba88ac77f6a014",
"scrapes.zip",
),
"lightclunk1": (
"https://opengameart.org/sites/default/files/lightclunk1.wav",
"d5a2aa8764c02924f6ed6fbaad0c650ab2d57ec57a626cee9e6645f941dbc8fa",
"lightclunk1.wav",
),
"lightclunk2": (
"https://opengameart.org/sites/default/files/lightclunk2.wav",
"987be837a5941ded9a81572c319b9bf8f57d9f89741a5781fc7104adc87d60f5",
"lightclunk2.wav",
),
"mechanical2": (
"https://opengameart.org/sites/default/files/mechanical2.wav",
"27b4b6c3517c90d635295fca48fccd747950f869d680c871662f26038c3665ef",
"mechanical2.wav",
),
"mechanical_explosion": (
"https://opengameart.org/sites/default/files/mechanical_explosion.wav",
"13e9f713bd0d5921b223b01d29d68bb7625a1a1fbf1894af35ebfd5ef72c8632",
"mechanical_explosion.wav",
),
"air_whoosh": (
"https://opengameart.org/sites/default/files/whoosh2_0.wav",
"d3fbff5bb78b289af16b2dfa29083f07ef83b0cbaff4514c48dfda16d082b468",
"whoosh2.wav",
),
"rocket_engine": (
"https://opengameart.org/sites/default/files/rocket_engine.001.wav",
"dc82ef86d6af2278d6592d20372737c32620f1c1957290c79f6643af5381f277",
"rocket_engine.wav",
),
}
def run(args: list[str]) -> None:
subprocess.run(args, cwd=ROOT, check=True)
def ffmpeg(*args: str) -> None:
if not FFMPEG:
raise SystemExit("ffmpeg is required to build audio assets")
run([FFMPEG, "-hide_banner", "-loglevel", "error", "-y", *args])
def media_duration(path: Path) -> float:
if not FFPROBE:
raise SystemExit("ffprobe is required to build audio assets")
result = subprocess.run([
FFPROBE, "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", str(path),
], check=True, capture_output=True, text=True)
return float(result.stdout.strip())
def write_wav(path: Path, channels: list[list[float]]) -> None:
count = min(map(len, channels))
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as out:
out.setnchannels(len(channels))
out.setsampwidth(2)
out.setframerate(RATE)
frames = bytearray()
for i in range(count):
for channel in channels:
value = max(-0.999, min(0.999, channel[i]))
frames.extend(struct.pack("<h", round(value * 32767.0)))
out.writeframes(frames)
def normalize(values: list[float], peak: float) -> list[float]:
current = max((abs(v) for v in values), default=1.0)
gain = peak / max(current, 1.0e-9)
return [v * gain for v in values]
def download_sources(temp: Path) -> dict[str, Path]:
"""Fetch pinned, licensed recordings and fail if any upstream file drifts."""
result: dict[str, Path] = {}
for key, (url, expected_sha, filename) in SOURCE_FILES.items():
path = temp / filename
urllib.request.urlretrieve(url, path)
digest = hashlib.sha256(path.read_bytes()).hexdigest()
if digest != expected_sha:
raise RuntimeError(
f"{key} source checksum changed ({digest}); verify {url}")
result[key] = path
scrape_dir = temp / "scrapes"
with zipfile.ZipFile(result["scrapes"]) as archive:
archive.extractall(scrape_dir)
for index in range(1, 9):
result[f"scrape{index}"] = scrape_dir / f"scrape-{index}.wav"
return result
def make_loop(source: Path, output: Path, fade: float = 0.18) -> None:
"""Rotate a processed recording through a tail/head crossfade."""
duration = media_duration(source)
if duration < 3.5 + fade:
raise RuntimeError(f"{source.name}: source is too short to loop")
graph = (
f"[0:a]atrim=start={fade}:end={duration - fade},"
"asetpts=PTS-STARTPTS[body];"
f"[0:a]atrim=start={duration - fade}:end={duration},"
"asetpts=PTS-STARTPTS[tail];"
f"[0:a]atrim=start=0:end={fade},asetpts=PTS-STARTPTS[head];"
f"[tail][head]acrossfade=d={fade}:c1=tri:c2=tri[seam];"
"[body][seam]concat=n=2:v=0:a=1[out]"
)
ffmpeg("-i", str(source), "-filter_complex", graph, "-map", "[out]",
"-ar", str(RATE), "-c:a", "pcm_s16le", str(output))
def make_recording_sequence(
inputs: list[Path], output: Path, filters: str,
channels: int = 2) -> None:
"""Concatenate physical gestures, process them, then close the loop seam."""
base = output.with_name(f"{output.stem}_base.wav")
args: list[str] = []
labels: list[str] = []
graph_parts: list[str] = []
for index, source in enumerate(inputs):
args.extend(["-i", str(source)])
label = f"s{index}"
labels.append(f"[{label}]")
graph_parts.append(
f"[{index}:a]aresample={RATE},"
"aformat=sample_fmts=fltp:channel_layouts=mono,"
f"afade=t=in:st=0:d=0.015,afade=t=out:st=0.95:d=0.12[{label}]")
graph_parts.append(
"".join(labels) + f"concat=n={len(inputs)}:v=0:a=1,"
f"{filters}[processed]")
ffmpeg(*args, "-filter_complex", ";".join(graph_parts), "-map", "[processed]",
"-ar", str(RATE), "-ac", str(channels), "-c:a", "pcm_s16le",
str(base))
make_loop(base, output)
def make_source_loop(
source: Path, output: Path, filters: str, seconds: float,
channels: int) -> None:
base = source.parent / f"{output.stem}_base.wav"
ffmpeg("-i", str(source), "-af", f"{filters},atrim=duration={seconds}",
"-ar", str(RATE), "-ac", str(channels), "-c:a", "pcm_s16le",
str(base))
make_loop(base, output, 0.30)
def build_movement_loops(temp: Path, sources: dict[str, Path]) -> None:
# Use almost the complete source-authored wind loop. The previous 8-second
# edit exposed its cadence during sustained movement; forty-eight seconds
# makes repetition disappear in normal play. A narrower stereo image and
# less low ambience make it read as speed over the body, not weather around
# the whole map.
make_source_loop(
sources["wind"], SOUNDS / "movement_wind.wav",
"atrim=start=0,"
"highpass=f=150,lowpass=f=5200:p=2,"
"equalizer=f=420:t=q:w=0.8:g=2.0,"
"equalizer=f=2600:t=q:w=1.0:g=-4.5,"
"stereotools=mlev=0.82:slev=0.40,"
"acompressor=threshold=-25dB:ratio=1.8:attack=28:release=220:makeup=1dB,"
"alimiter=limit=0.20:attack=7:release=140:level=0",
48.8, 2)
scrape_sets = {
"movement_slide_concrete.wav": (
[sources["scrape2"], sources["scrape6"], sources["scrape4"]],
"highpass=f=55,lowpass=f=2200:p=2,"
"equalizer=f=420:t=q:w=0.9:g=1,"
"equalizer=f=1650:t=q:w=1.0:g=-3,"
"acompressor=threshold=-28dB:ratio=2.0:attack=18:release=150:makeup=0dB,"
"alimiter=limit=0.22:attack=7:release=100:level=0",
),
"movement_slide_metal.wav": (
[sources["scrape3"], sources["scrape8"], sources["scrape1"]],
"highpass=f=75,lowpass=f=2400:p=2,"
"equalizer=f=680:t=q:w=1.1:g=1.5,"
"equalizer=f=1750:t=q:w=1.1:g=-4,"
"acompressor=threshold=-28dB:ratio=2.0:attack=20:release=150:makeup=0dB,"
"alimiter=limit=0.21:attack=7:release=105:level=0",
),
"movement_slide_wood.wav": (
[sources["scrape5"], sources["scrape2"], sources["scrape7"]],
"highpass=f=45,lowpass=f=1800:p=2,"
"equalizer=f=180:t=q:w=0.9:g=2,"
"equalizer=f=1350:t=q:w=1.0:g=-2.5,"
"acompressor=threshold=-29dB:ratio=2.0:attack=20:release=165:makeup=0dB,"
"alimiter=limit=0.22:attack=7:release=110:level=0",
),
"movement_slide_glass.wav": (
[sources["scrape1"], sources["scrape4"], sources["scrape8"]],
"highpass=f=95,lowpass=f=2300:p=2,"
"equalizer=f=1000:t=q:w=1.0:g=1,"
"equalizer=f=1850:t=q:w=1.1:g=-4,"
"acompressor=threshold=-29dB:ratio=2.0:attack=20:release=145:makeup=0dB,"
"alimiter=limit=0.19:attack=7:release=100:level=0",
),
"movement_wallrun.wav": (
[sources["scrape7"], sources["scrape5"], sources["scrape2"]],
"highpass=f=55,lowpass=f=2000:p=2,"
"equalizer=f=240:t=q:w=0.9:g=1.5,"
"equalizer=f=1500:t=q:w=1.0:g=-3,"
"acompressor=threshold=-29dB:ratio=2.0:attack=20:release=160:makeup=0dB,"
"alimiter=limit=0.19:attack=7:release=110:level=0",
),
}
for filename, (recordings, filters) in scrape_sets.items():
make_recording_sequence(recordings, temp / filename, filters)
shutil.copyfile(temp / filename, SOUNDS / filename)
# Cable reel: discrete ratchet/clutch recordings alternating with short
# friction pulls. A continuous boiler/generator bed read as another jet,
# which is semantically wrong even after aggressive filtering.
make_recording_sequence(
[sources["mechanical2"], sources["scrape3"],
sources["lightclunk2"], sources["scrape8"]],
SOUNDS / "movement_grapple_reel.wav",
"highpass=f=80,lowpass=f=2200:p=2,"
"equalizer=f=260:t=q:w=0.8:g=1.5,"
"equalizer=f=1700:t=q:w=1.1:g=-3,"
"volume=6dB,"
"acompressor=threshold=-28dB:ratio=1.8:attack=18:release=135:makeup=0dB,"
"alimiter=limit=0.18:attack=7:release=105:level=0")
make_source_loop(
sources["generator"], SOUNDS / "projectile_rocket_loop.wav",
"atrim=start=7,highpass=f=38,lowpass=f=1700:p=2,"
"equalizer=f=95:t=q:w=0.8:g=3,"
"acompressor=threshold=-26dB:ratio=2.0:attack=20:release=160:makeup=0dB,"
"alimiter=limit=0.25:attack=7:release=110:level=0",
5.0, 1)
make_source_loop(
sources["generator"], SOUNDS / "projectile_swarm_loop.wav",
"atrim=start=12,highpass=f=75,lowpass=f=2300:p=2,"
"equalizer=f=380:t=q:w=0.9:g=1.5,"
"acompressor=threshold=-27dB:ratio=2.0:attack=18:release=145:makeup=0dB,"
"alimiter=limit=0.20:attack=7:release=100:level=0",
5.0, 1)
def synth_foot_layer(kind: str, variant: int, path: Path) -> None:
rng = random.Random(3000 + variant * 101 + sum(map(ord, kind)))
duration = 0.24
count = round(duration * RATE)
values: list[float] = []
low_state = 0.0
configs = {
"concrete": (92.0, 0.065, 0.10, 0.14),
"metal": (128.0, 0.050, 0.07, 0.11),
"wood": (78.0, 0.085, 0.045, 0.18),
"glass": (112.0, 0.055, 0.055, 0.11),
}
body_hz, body_decay, grit, body_gain = configs[kind]
body_hz *= 1.0 + (variant - 1.5) * 0.018
for i in range(count):
t = i / RATE
noise = rng.uniform(-1.0, 1.0)
low_state += 0.075 * (noise - low_state)
heel = math.exp(-t / body_decay)
toe_t = max(0.0, t - (0.052 + variant * 0.003))
toe = math.exp(-toe_t / 0.030) if t > 0.050 else 0.0
body = math.sin(math.tau * body_hz * t) * heel * body_gain
texture = low_state * (heel * 0.7 + toe * 0.3) * grit
ring = 0.0
if kind == "metal":
ring = (math.sin(math.tau * 510.0 * t)
+ 0.55 * math.sin(math.tau * 735.0 * t)) \
* math.exp(-t / 0.055) * 0.022
elif kind == "wood":
ring = math.sin(math.tau * 142.0 * t) \
* math.exp(-t / 0.070) * 0.035
elif kind == "glass":
ring = (math.sin(math.tau * 740.0 * t)
+ 0.35 * math.sin(math.tau * 1120.0 * t)) \
* math.exp(-t / 0.038) * 0.012
values.append(body + texture + ring)
write_wav(path, [normalize(values, 0.28)])
def process_footsteps(temp: Path) -> None:
sources = sorted(SOUNDS.glob("footstep*.wav"))
# Do not recursively remaster outputs from a previous run.
sources = [p for p in sources if not any(
token in p.stem for token in ("concrete", "metal", "wood", "glass"))]
if len(sources) < 4:
raise SystemExit("Expected at least four source footstep recordings")
filters = {
"concrete": "highpass=f=55,lowpass=f=6800,equalizer=f=180:t=q:w=0.8:g=2",
"metal": "highpass=f=65,lowpass=f=8200,equalizer=f=2400:t=q:w=1.2:g=-1",
"wood": "highpass=f=45,lowpass=f=4700,equalizer=f=150:t=q:w=0.8:g=3",
"glass": "highpass=f=65,lowpass=f=7600,equalizer=f=3200:t=q:w=1.0:g=-2",
}
for kind, source_filter in filters.items():
for variant in range(4):
layer = temp / f"{kind}_{variant}.wav"
synth_foot_layer(kind, variant, layer)
out = SOUNDS / f"footstep_{kind}_{variant:02d}.wav"
graph = (
f"[0:a]{source_filter},volume=0.72[src];"
"[1:a]volume=0.55[layer];"
"[src][layer]amix=inputs=2:duration=longest:normalize=0,"
"afade=t=out:st=0.19:d=0.05,"
"alimiter=limit=0.50:attack=2:release=35:level=0[out]"
)
ffmpeg("-i", str(sources[variant % len(sources)]), "-i", str(layer),
"-filter_complex", graph, "-map", "[out]",
"-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le", str(out))
def process_layered(source: Path, output: Path, profile: str) -> None:
profiles = {
"auto": (12_500, 250, 0.30, "36|72", "0.14|0.07", 0.68),
"heavy": (11_500, 220, 0.40, "58|116|210", "0.18|0.10|0.055", 0.70),
"shotgun": (10_500, 190, 0.48, "65|135|245", "0.20|0.11|0.06", 0.68),
"energy": (10_000, 310, 0.24, "45|95", "0.12|0.06", 0.62),
"launcher": (9_000, 175, 0.50, "70|145|260", "0.19|0.10|0.05", 0.68),
"melee": (9_500, 350, 0.12, "30|65", "0.08|0.04", 0.58),
}
lowpass, body_lp, body_gain, delays, decays, limit = profiles[profile]
graph = (
"[0:a]aformat=sample_fmts=fltp:channel_layouts=mono,asplit=3[d][b][t];"
f"[d]highpass=f=38,lowpass=f={lowpass},"
"equalizer=f=3600:t=q:w=1.1:g=-2.2,volume=0.74[dry];"
f"[b]highpass=f=32,lowpass=f={body_lp},volume={body_gain}[body];"
f"[t]aecho=0.8:0.55:{delays}:{decays},"
f"lowpass=f={min(6200, lowpass)},volume=0.24[tail];"
"[dry][body][tail]amix=inputs=3:duration=longest:normalize=0,"
f"alimiter=limit={limit}:attack=3:release=80:level=0[out]"
)
ffmpeg("-i", str(source), "-filter_complex", graph, "-map", "[out]",
"-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le", str(output))
def process_weapons() -> None:
groups = {
"weapon_ak47": ("ak47_fire", "auto"),
"weapon_m4": ("m4_fire", "auto"),
"weapon_mp7": ("mp7_fire", "auto"),
"weapon_dmr": ("dmr_fire", "heavy"),
"weapon_awp": ("awp_fire", "heavy"),
"weapon_shotgun": ("shotgun_fire", "shotgun"),
"weapon_nailgun": ("nailgun_fire", "energy"),
"weapon_plasma": ("plasma_fire", "energy"),
"weapon_rocket": ("rocket_fire", "launcher"),
"weapon_swarm": ("swarm_fire", "launcher"),
"weapon_mortar": ("mortar_fire", "launcher"),
"weapon_knife": ("knife_swing", "melee"),
}
for output_id, (source_id, profile) in groups.items():
sources = sorted(SOUNDS.glob(f"{source_id}*.wav"))
if not sources:
raise SystemExit(f"No source files found for {source_id}")
for variant, source in enumerate(sources[:4]):
process_layered(
source, SOUNDS / f"{output_id}_{variant:02d}.wav", profile)
reload_source = SOUNDS / "shotgun_reload.wav"
reload_output = SOUNDS / "weapon_reload_00.wav"
# This master did not change in the industrial redesign. Avoid rewriting a
# stream an open Godot editor may currently have mapped into a live weapon.
if not reload_output.exists():
ffmpeg("-i", str(reload_source),
"-af", "highpass=f=55,lowpass=f=9000,"
"equalizer=f=3900:t=q:w=1.1:g=-3,"
"acompressor=threshold=-16dB:ratio=2.2:attack=800:release=100:"
"makeup=1.5dB,"
"alimiter=limit=0.56:attack=2:release=60:level=0",
"-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le",
str(reload_output))
def process_explosions(sources: dict[str, Path]) -> None:
candidates = sorted(SOUNDS.glob("explosion*.wav"))
original_explosions = [p for p in candidates if "deep" not in p.stem]
for variant, source in enumerate(original_explosions[:5]):
graph = (
"[0:a]aformat=sample_fmts=fltp:channel_layouts=mono,asplit=3[d][b][t];"
"[d]highpass=f=26,lowpass=f=6800,"
"equalizer=f=3400:t=q:w=1:g=-4.5,volume=0.92[dry];"
"[b]highpass=f=24,lowpass=f=185,volume=0.90[body];"
"[t]aecho=0.8:0.55:95|205|390:0.24|0.13|0.07,"
"lowpass=f=4200,volume=0.36[tail];"
"[1:a]aformat=sample_fmts=fltp:channel_layouts=mono,"
"highpass=f=30,lowpass=f=3200:p=2,"
"equalizer=f=1900:t=q:w=1.0:g=-4,volume=0.26[metal];"
"[dry][body][tail][metal]amix=inputs=4:duration=longest:normalize=0,"
"acompressor=threshold=-14dB:ratio=2.0:attack=6:release=260:makeup=3dB,"
"alimiter=limit=0.86:attack=4:release=190:level=0[out]"
)
ffmpeg("-i", str(source), "-i", str(sources["mechanical_explosion"]),
"-filter_complex", graph, "-map", "[out]",
"-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le",
str(SOUNDS / f"explosion_deep_{variant:02d}.wav"))
def make_layered_oneshot(
output: Path,
layers: list[tuple[Path, float, int, float, float, bool]],
final_filters: str) -> None:
"""Build one tactile cue from recorded gestures, never broadband synthesis.
layer tuple: (source, volume, delay_ms, pitch_ratio, trim_seconds, reverse)
"""
args: list[str] = []
graph: list[str] = []
labels: list[str] = []
for index, (source, volume, delay_ms, pitch, trim, reverse) in enumerate(layers):
args.extend(["-i", str(source)])
chain = (
f"[{index}:a]aresample={RATE},"
"aformat=sample_fmts=fltp:channel_layouts=mono,"
f"atrim=start=0:end={trim},asetpts=PTS-STARTPTS")
if reverse:
chain += ",areverse"
if abs(pitch - 1.0) > 0.001:
chain += f",asetrate={RATE * pitch:.2f},aresample={RATE}"
effective_duration = trim / pitch
fade_out = min(0.10, effective_duration * 0.28)
fade_start = max(0.01, effective_duration - fade_out)
chain += (
f",volume={volume},"
"afade=t=in:st=0:d=0.008,"
f"afade=t=out:st={fade_start:.3f}:d={fade_out:.3f},"
f"adelay={delay_ms}")
label = f"l{index}"
graph.append(f"{chain}[{label}]")
labels.append(f"[{label}]")
graph.append(
"".join(labels)
+ f"amix=inputs={len(layers)}:duration=longest:normalize=0,"
+ final_filters + "[out]")
ffmpeg(*args, "-filter_complex", ";".join(graph), "-map", "[out]",
"-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le", str(output))
def process_movement_oneshots(sources: dict[str, Path]) -> None:
# Ordinary movement stays physical. Only the impossible dash/double jump
# receives a restrained thrust layer: the audio version of the 90/10 rule.
events = {
"movement_dash": [
(sources["air_whoosh"], 0.56, 0, 0.96, 0.48, False),
(sources["rocket_engine"], 0.54, 5, 0.90, 0.44, False),
(sources["lightclunk2"], 0.10, 28, 0.74, 0.20, False),
],
"movement_jump": [
# Two short shoe contacts: heel/body, then toe release. No whoosh,
# reversed scrape, engine, mechanism or other thrust-like source.
(SOUNDS / "footstep_concrete_00.wav", 0.72, 0, 1.14, 0.19, False),
(SOUNDS / "footstep_concrete_02.wav", 0.32, 18, 0.98, 0.15, False),
],
"movement_double_jump": [
(sources["air_whoosh"], 0.52, 0, 0.94, 0.46, False),
(sources["rocket_engine"], 0.58, 4, 0.88, 0.46, False),
(sources["lightclunk1"], 0.09, 24, 0.72, 0.20, False),
],
"movement_vault": [
(sources["scrape5"], 0.22, 0, 0.84, 0.48, False),
(sources["air_whoosh"], 0.34, 0, 0.92, 0.38, False),
(sources["lightclunk1"], 0.12, 70, 0.76, 0.22, False),
],
"movement_land": [
(sources["stomp"], 0.74, 0, 0.84, 0.50, False),
(sources["lightclunk1"], 0.15, 18, 0.72, 0.24, False),
],
"movement_grapple_launch": [
# Spring release, cable friction and trigger clutch.
(sources["mechanical2"], 0.32, 0, 0.88, 0.28, False),
(sources["scrape5"], 0.16, 12, 0.86, 0.25, False),
(sources["lightclunk1"], 0.28, 0, 0.90, 0.18, False),
],
"movement_grapple_latch": [
# Hook impact followed by the pawl closing on the line.
(sources["lightclunk2"], 0.56, 0, 0.92, 0.24, False),
(sources["stomp"], 0.18, 0, 1.08, 0.18, False),
(sources["mechanical2"], 0.24, 34, 1.04, 0.25, False),
],
}
for output_id, layers in events.items():
is_jet = output_id in {"movement_dash", "movement_double_jump"}
is_grapple = output_id.startswith("movement_grapple_")
upper_cut = 2100 if is_grapple else (2200 if is_jet else 2800)
presence_cut = -6.5 if is_grapple else (-5.5 if is_jet else -3.5)
make_layered_oneshot(
SOUNDS / f"{output_id}_00.wav", layers,
f"highpass=f=42,lowpass=f={upper_cut}:p=2,"
"equalizer=f=170:t=q:w=0.9:g=2,"
f"equalizer=f=1500:t=q:w=1.1:g={presence_cut},"
"acompressor=threshold=-20dB:ratio=1.7:attack=14:release=125:makeup=0dB,"
"loudnorm=I=-20:TP=-6:LRA=5,"
"alimiter=limit=0.48:attack=6:release=100:level=0")
impacts = sorted(SOUNDS.glob("bullet_impact*.wav"))
for variant, source in enumerate(impacts[:4]):
ffmpeg("-i", str(source),
"-af", "highpass=f=70,lowpass=f=9500,"
"equalizer=f=4100:t=q:w=1.2:g=-3,"
"aecho=0.8:0.5:24|52:0.10|0.04,"
"alimiter=limit=0.42:attack=2:release=45:level=0",
"-ar", str(RATE), "-ac", "1", "-c:a", "pcm_s16le",
str(SOUNDS / f"impact_bullet_{variant:02d}.wav"))
def envelope(t: float, attack: float, release: float, duration: float) -> float:
return min(1.0, t / max(attack, 1.0e-6)) \
* min(1.0, (duration - t) / max(release, 1.0e-6))
def build_ui(sources: dict[str, Path]) -> None:
# Interface feedback is a family of relay/lever gestures. Pitch stays below
# the old arcade chirp range, and semantic weight comes from mechanism size.
cues = {
"ui_hover_premium_00.wav": [
(sources["lightclunk2"], 0.34, 0, 0.68, 0.18, False),
],
"ui_hover_premium_01.wav": [
(sources["lightclunk1"], 0.30, 0, 0.66, 0.18, False),
],
"ui_click_premium_00.wav": [
(sources["lightclunk1"], 0.52, 0, 0.72, 0.26, False),
],
"ui_click_premium_01.wav": [
(sources["lightclunk2"], 0.54, 0, 0.70, 0.24, False),
],
"ui_confirm_premium_00.wav": [
(sources["lightclunk2"], 0.34, 0, 0.68, 0.20, False),
(sources["lightclunk1"], 0.46, 46, 0.70, 0.26, False),
],
"ui_kill_premium_00.wav": [
(sources["stomp"], 0.44, 0, 0.78, 0.42, False),
(sources["lightclunk2"], 0.38, 62, 0.72, 0.24, False),
],
"ui_error_premium_00.wav": [
(sources["mechanical2"], 0.28, 0, 0.66, 0.34, False),
(sources["lightclunk1"], 0.38, 42, 0.62, 0.24, False),
],
"ui_equip_premium_00.wav": [
(sources["mechanical2"], 0.38, 0, 0.68, 0.42, False),
(sources["lightclunk2"], 0.30, 58, 0.66, 0.22, False),
],
}
for filename, layers in cues.items():
make_layered_oneshot(
SOUNDS / filename, layers,
"highpass=f=60,lowpass=f=2400:p=2,"
"equalizer=f=210:t=q:w=0.9:g=1.5,"
"equalizer=f=1500:t=q:w=1.0:g=-3,"
"equalizer=f=2200:t=q:w=1.0:g=-4,"
"acompressor=threshold=-22dB:ratio=1.6:attack=12:release=95:makeup=0dB,"
"loudnorm=I=-22:TP=-8:LRA=4,"
"alimiter=limit=0.40:attack=5:release=85:level=0")
def build_menu_music(temp: Path) -> None:
source = temp / "pynchon.mp3"
urllib.request.urlretrieve(MENU_SOURCE, source)
digest = hashlib.sha256(source.read_bytes()).hexdigest()
if digest != MENU_SOURCE_SHA256:
raise RuntimeError(
"PYNCHON source checksum changed; verify the CC0 download before rebuilding")
mastered = temp / "pynchon_mastered.wav"
ffmpeg("-i", str(source),
"-af", "highpass=f=28,lowpass=f=15500,"
"loudnorm=I=-18:TP=-2:LRA=5",
"-ar", str(RATE), "-ac", "2", "-c:a", "pcm_s16le", str(mastered))
duration = media_duration(mastered)
fade = 0.25
# MP3 encoder delay leaves even a source-authored loop with a boundary
# click. Rotate by the crossfade length: output starts at +fade, and its
# final crossfade ends at that same musical point.
graph = (
f"[0:a]atrim=start={fade}:end={duration - fade},asetpts=PTS-STARTPTS[body];"
f"[0:a]atrim=start={duration - fade}:end={duration},asetpts=PTS-STARTPTS[tail];"
f"[0:a]atrim=start=0:end={fade},asetpts=PTS-STARTPTS[head];"
f"[tail][head]acrossfade=d={fade}:c1=tri:c2=tri[seam];"
"[body][seam]concat=n=2:v=0:a=1[out]"
)
ffmpeg("-i", str(mastered), "-filter_complex", graph, "-map", "[out]",
"-ar", str(RATE), "-ac", "2", "-c:a", "libvorbis", "-q:a", "5",
str(SOUNDS / "music_menu_premium.ogg"))
def main() -> None:
SOUNDS.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="papaya-audio-") as raw_temp:
temp = Path(raw_temp)
sources = download_sources(temp)
build_movement_loops(temp, sources)
process_footsteps(temp)
process_weapons()
process_explosions(sources)
process_movement_oneshots(sources)
build_ui(sources)
build_menu_music(temp)
print("Built premium audio assets in", SOUNDS)
if __name__ == "__main__":
main()
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Build the compact CMU backward-locomotion animation library.
Usage:
blender --background --python tools/build_cmu_locomotion_library.py -- \
<76_11.bvh> <output.glb>
Subject 76 trial 11 is CMU's optical capture described as "quick large steps
backwards." The script finds a naturally matching gait-cycle boundary, keeps
the captured bone motion intact, removes gameplay-owned horizontal root travel,
and exports only that cycle.
"""
import os
import sys
import bpy
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) != 2:
print(__doc__)
sys.exit(1)
SOURCE, OUTPUT = argv
SEARCH_START = range(170, 281)
SEARCH_END = range(300, 411)
MIN_CYCLE_FRAMES = 95
MAX_CYCLE_FRAMES = 175
SIGNATURE_BONES = (
"Hips", "LowerBack", "Spine", "Spine1", "Neck", "Head",
"LeftArm", "LeftForeArm", "RightArm", "RightForeArm",
"LeftUpLeg", "LeftLeg", "LeftFoot",
"RightUpLeg", "RightLeg", "RightFoot",
)
def action_fcurves(action):
"""Blender 5.x channelbag f-curves for an imported BVH action."""
curves = []
for layer in action.layers:
for strip in layer.strips:
for slot in action.slots:
channelbag = strip.channelbag(slot)
if channelbag is not None:
curves.extend(channelbag.fcurves)
return curves
def pose_signature(armature, frame):
bpy.context.scene.frame_set(frame)
signature = []
for name in SIGNATURE_BONES:
bone = armature.pose.bones.get(name)
if bone is None:
continue
signature.append(bone.matrix.to_quaternion().normalized())
hips = armature.pose.bones["Hips"].matrix.translation
return signature, hips.copy()
def find_cycle(armature):
samples = {
frame: pose_signature(armature, frame)
for frame in range(min(SEARCH_START), max(SEARCH_END) + 1)
}
best = None
for start in SEARCH_START:
start_rotations, start_hips = samples[start]
for end in SEARCH_END:
duration = end - start
if duration < MIN_CYCLE_FRAMES or duration > MAX_CYCLE_FRAMES:
continue
end_rotations, end_hips = samples[end]
rotation_error = sum(
rotation.rotation_difference(other).angle ** 2
for rotation, other in zip(start_rotations, end_rotations)
) / max(len(start_rotations), 1)
# Match the captured vertical phase but intentionally ignore
# horizontal displacement: it proves the performer travelled.
vertical_error = (end_hips.z - start_hips.z) ** 2 * 0.02
score = rotation_error + vertical_error
if best is None or score < best[0]:
best = (score, start, end)
if best is None:
raise RuntimeError("No plausible gait cycle found in the capture")
return best
def crop_action(action, start, end):
shift = start - 1
curves = action_fcurves(action)
for curve in curves:
points = curve.keyframe_points
for index in range(len(points) - 1, -1, -1):
if points[index].co.x < start or points[index].co.x > end:
points.remove(points[index])
for point in points:
point.co.x -= shift
point.handle_left.x -= shift
point.handle_right.x -= shift
# The gameplay body owns horizontal travel. Preserve every captured
# rotation and the vertical compression/lift, but keep the cycle in place.
hips_path = 'pose.bones["Hips"].location'
for curve in curves:
if curve.data_path != hips_path or curve.array_index not in (0, 1):
continue
baseline = curve.evaluate(1.0)
for point in curve.keyframe_points:
point.co.y -= baseline
point.handle_left.y -= baseline
point.handle_right.y -= baseline
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.scene.render.fps = 120
bpy.context.scene.render.fps_base = 1.0
bpy.ops.import_anim.bvh(
filepath=SOURCE,
axis_forward="-Z",
axis_up="Y",
)
armature = next(
(obj for obj in bpy.data.objects if obj.type == "ARMATURE"), None
)
if armature is None or armature.animation_data is None:
raise RuntimeError(f"{SOURCE}: no animated armature")
action = armature.animation_data.action
if action is None:
raise RuntimeError(f"{SOURCE}: no action")
score, start, end = find_cycle(armature)
crop_action(action, start, end)
action.name = "RunBackward"
action.use_fake_user = True
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = end - start + 1
track = armature.animation_data.nla_tracks.new()
track.name = "RunBackward"
strip = track.strips.new("RunBackward", 0, action)
strip.name = "RunBackward"
track.mute = True
armature.animation_data.action = None
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=OUTPUT,
export_format="GLB",
export_yup=True,
export_skins=True,
export_animations=True,
export_animation_mode="NLA_TRACKS",
export_bake_animation=False,
export_optimize_animation_size=True,
export_optimize_animation_keep_anim_armature=False,
)
print(
f"CMU 76_11 frames {start}..{end} -> RunBackward "
f"({(end - start) / 120.0:.3f}s, phase score {score:.6f})"
)
print(f"Wrote {OUTPUT}")
main()
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""
Build the compact, animation-only wall-run library used by retarget.py.
The source clips are Uisco's authored UE4 Mannequin wall-run pair:
https://uisco.itch.io/wall-running-animations
Usage:
blender --background --python tools/build_wallrun_library.py -- \
<wall_run_left.FBX> <wall_run_right.FBX> <output.glb>
The two FBX files use the same UE4 mannequin skeleton. This script keeps one
copy of that skeleton, names the actions explicitly, and exports no mesh.
"""
import os
import sys
import bpy
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) != 3:
print(__doc__)
sys.exit(1)
LEFT, RIGHT, OUTPUT = argv
def import_clip(path, name):
before_objects = set(bpy.data.objects)
before_actions = set(bpy.data.actions)
bpy.ops.import_scene.fbx(filepath=path)
objects = [obj for obj in bpy.data.objects if obj not in before_objects]
arms = [obj for obj in objects if obj.type == "ARMATURE"]
actions = [action for action in bpy.data.actions if action not in before_actions]
if len(arms) != 1 or len(actions) != 1:
raise RuntimeError(
f"{path}: expected one armature/action, got "
f"{len(arms)} armatures and {len(actions)} actions"
)
actions[0].name = name
actions[0].use_fake_user = True
return arms[0], actions[0], objects
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
left_arm, left_action, left_objects = import_clip(LEFT, "WallRunLeft")
right_arm, right_action, right_objects = import_clip(RIGHT, "WallRunRight")
left_bones = [bone.name for bone in left_arm.data.bones]
right_bones = [bone.name for bone in right_arm.data.bones]
if left_bones != right_bones:
raise RuntimeError("left/right source skeletons do not match")
for obj in left_objects + right_objects:
if obj not in (left_arm, right_arm):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.objects.remove(right_arm, do_unlink=True)
if left_arm.animation_data is None:
left_arm.animation_data_create()
left_arm.animation_data.action = None
for track in list(left_arm.animation_data.nla_tracks):
left_arm.animation_data.nla_tracks.remove(track)
for action in (left_action, right_action):
track = left_arm.animation_data.nla_tracks.new()
track.name = action.name
strip = track.strips.new(action.name, 0, action)
strip.name = action.name
track.mute = True
for action in list(bpy.data.actions):
if action not in (left_action, right_action):
bpy.data.actions.remove(action)
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=OUTPUT,
export_format="GLB",
export_yup=True,
export_animations=True,
export_animation_mode="NLA_TRACKS",
export_skins=True,
export_bake_animation=False,
export_optimize_animation_size=True,
)
print(
f"Exported WallRunLeft/WallRunRight on "
f"{len(left_arm.data.bones)} bones to {OUTPUT}"
)
main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Create a compact animation-only GLB from selected actions in a source GLB.
Usage:
blender --background --python tools/extract_animation_subset.py -- \
<source.glb> <output.glb> <clip> [<clip> ...]
The source skeleton is retained, all meshes and unrelated actions are removed,
and each requested action is exported as its own NLA track. This keeps external
authored animation libraries small enough to ship as retargeting sources.
"""
import os
import sys
import bpy
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) < 3:
print(
"Usage: blender --background --python "
"tools/extract_animation_subset.py -- "
"<source.glb> <output.glb> <clip> [<clip> ...]"
)
sys.exit(1)
SOURCE = argv[0]
OUTPUT = argv[1]
REQUESTED = argv[2:]
def main():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
bpy.ops.import_scene.gltf(filepath=SOURCE)
armatures = [obj for obj in bpy.data.objects if obj.type == "ARMATURE"]
if len(armatures) != 1:
print(f"ERROR: expected one armature, found {len(armatures)}")
sys.exit(1)
armature = armatures[0]
missing = [name for name in REQUESTED if bpy.data.actions.get(name) is None]
if missing:
print(f"ERROR: requested actions not found: {missing}")
sys.exit(1)
for obj in list(bpy.data.objects):
if obj != armature:
bpy.data.objects.remove(obj, do_unlink=True)
if not armature.animation_data:
armature.animation_data_create()
for track in list(armature.animation_data.nla_tracks):
armature.animation_data.nla_tracks.remove(track)
armature.animation_data.action = None
keep = set()
for name in REQUESTED:
action = bpy.data.actions[name]
action.use_fake_user = True
track = armature.animation_data.nla_tracks.new()
track.name = name
strip = track.strips.new(name, 0, action)
strip.name = name
track.mute = True
keep.add(action)
for action in list(bpy.data.actions):
if action not in keep:
bpy.data.actions.remove(action)
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=OUTPUT,
export_format="GLB",
export_yup=True,
export_animations=True,
export_animation_mode="NLA_TRACKS",
export_skins=True,
export_bake_animation=True,
)
print(f"Exported {len(keep)} clips to {OUTPUT}")
main()
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
Retarget the authored left/right wall-run clips into an existing character GLB.
Unlike retarget.py, this is deliberately animation-only: it does not rebuild
the hierarchy, touch skin weights, subdivide cloth, scale meshes, or rewrite
the rig sidecar. It is safe to use on a character that has already passed the
full one-time character build.
Usage:
blender --background --python tools/inject_wallrun.py -- \
<character.glb> <_wallrun.glb> <output.glb>
"""
import math
import os
import sys
import bpy
from mathutils import Matrix, Quaternion, Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rig_map import RigRoles, build_map
from retarget_pose import authored_world_rotation, build_segment_pairs
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) != 3:
print(__doc__)
sys.exit(1)
CHARACTER, LIBRARY, OUTPUT = argv
UP = Vector((0.0, 0.0, 1.0))
CLIPS = ("WallRunLeft", "WallRunRight")
def find_armature(objects):
return next((obj for obj in objects if obj.type == "ARMATURE"), None)
def assign_action(obj, action):
if obj.animation_data is None:
obj.animation_data_create()
obj.animation_data.action = action
if action is None:
return
try:
if len(action.slots):
obj.animation_data.action_slot = action.slots[0]
except Exception:
pass
def world_rest(arm):
return {
bone.name: arm.matrix_world @ bone.matrix_local
for bone in arm.data.bones
}
def bone_order(arm):
out = []
def walk(bone):
out.append(bone.name)
for child in bone.children:
walk(child)
for bone in arm.data.bones:
if bone.parent is None:
walk(bone)
return out
def rig_forward(arm, roles):
acc = Vector((0.0, 0.0, 0.0))
for side in ("L", "R"):
for role in ("toe", "foot"):
name = roles.limb.get((role, side))
if not name:
continue
bone = arm.data.bones[name]
direction = (
(arm.matrix_world @ bone.tail_local)
- (arm.matrix_world @ bone.matrix_local.translation)
)
direction.z = 0.0
if direction.length > 1e-5:
acc += direction.normalized()
break
return acc.normalized() if acc.length > 1e-5 else None
def facing_correction(src_arm, src_roles, target_arm, target_roles):
source = rig_forward(src_arm, src_roles)
target = rig_forward(target_arm, target_roles)
if source is None or target is None:
return Quaternion()
angle = Vector((source.x, source.y)).angle_signed(
Vector((target.x, target.y)), 0.0
)
return Quaternion(UP, angle) if abs(angle) >= 1e-4 else Quaternion()
def solve_pose(arm, order, rest_world, desired_rot, hips, hips_head):
pose_world = {}
basis = {}
bones = arm.data.bones
for name in order:
bone = bones[name]
rest = rest_world[name]
if bone.parent is not None:
parent = bone.parent.name
matrix = pose_world[parent] @ rest_world[parent].inverted() @ rest
else:
matrix = rest
rotation = Quaternion()
if name in desired_rot:
rotation = matrix.to_quaternion().inverted() @ desired_rot[name]
location = Vector((0.0, 0.0, 0.0))
if name == hips and hips_head is not None:
location = matrix.inverted() @ hips_head
basis[name] = (location, rotation)
pose_world[name] = (
matrix
@ Matrix.Translation(location)
@ rotation.to_matrix().to_4x4()
)
return basis
def retarget_action(target_arm, target_roles, src_arm, src_roles, mapping,
src_action, clip_name, yaw, scale):
src_rest = world_rest(src_arm)
target_rest = world_rest(target_arm)
target_order = bone_order(target_arm)
src_rest_rot = {
name: matrix.to_quaternion() for name, matrix in src_rest.items()
}
target_rest_rot = {
name: matrix.to_quaternion() for name, matrix in target_rest.items()
}
yaw_inverse = yaw.inverted()
segment_pairs = build_segment_pairs(mapping, src_roles, target_roles)
src_hips = src_roles.hips
target_hips = target_roles.hips
src_hips_rest = src_rest[src_hips].translation.copy()
target_hips_rest = target_rest[target_hips].translation.copy()
assign_action(src_arm, src_action)
frame_start, frame_end = (
int(round(value)) for value in src_action.frame_range
)
baked = bpy.data.actions.new(clip_name)
assign_action(target_arm, baked)
for pose_bone in target_arm.pose.bones:
pose_bone.rotation_mode = "QUATERNION"
scene = bpy.context.scene
for frame in range(frame_start, frame_end + 1):
scene.frame_set(frame)
depsgraph = bpy.context.evaluated_depsgraph_get()
src_eval = src_arm.evaluated_get(depsgraph)
source_world = src_eval.matrix_world
desired = {}
for target_name, source_name in mapping.items():
if (
source_name not in src_eval.pose.bones
or target_name not in target_rest_rot
):
continue
desired[target_name] = authored_world_rotation(
target_name,
source_name,
src_eval,
source_world,
src_rest_rot,
target_rest,
target_rest_rot,
yaw,
yaw_inverse,
segment_pairs,
)
moved = (
source_world @ src_eval.pose.bones[src_hips].matrix
).translation
displacement = yaw @ ((moved - src_hips_rest) * scale)
# Gameplay owns horizontal travel. Preserve vertical compression and
# lift from the performance, but keep the cycle in-place.
displacement.x = 0.0
displacement.y = 0.0
hips_head = target_hips_rest + displacement
basis = solve_pose(
target_arm,
target_order,
target_rest,
desired,
target_hips,
hips_head,
)
for name in mapping:
if name not in basis:
continue
pose_bone = target_arm.pose.bones[name]
pose_bone.rotation_quaternion = basis[name][1]
pose_bone.keyframe_insert("rotation_quaternion", frame=frame)
pose_bone = target_arm.pose.bones[target_hips]
pose_bone.location = basis[target_hips][0]
pose_bone.keyframe_insert("location", frame=frame)
assign_action(target_arm, None)
return baked
def add_clip_track(arm, action, name):
action.name = name
action.use_fake_user = True
track = arm.animation_data.nla_tracks.new()
track.name = name
strip = track.strips.new(name, 0, action)
strip.name = name
track.mute = True
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=CHARACTER)
target_arm = find_armature(bpy.data.objects)
if target_arm is None:
raise RuntimeError(f"{CHARACTER}: no armature")
target_roles = RigRoles(target_arm)
missing = target_roles.missing_core()
if missing:
raise RuntimeError(f"{CHARACTER}: missing target roles {missing}")
# Remove the old relabelled sprint, plus stale versions from a prior run.
for track in list(target_arm.animation_data.nla_tracks):
if track.name in ("WallRun",) + CLIPS:
target_arm.animation_data.nla_tracks.remove(track)
for action in list(bpy.data.actions):
if action.name in ("WallRun",) + CLIPS:
bpy.data.actions.remove(action)
assign_action(target_arm, None)
before_objects = set(bpy.data.objects)
before_actions = set(bpy.data.actions)
bpy.ops.import_scene.gltf(filepath=LIBRARY)
new_objects = [obj for obj in bpy.data.objects if obj not in before_objects]
new_actions = [
action for action in bpy.data.actions if action not in before_actions
]
src_arm = find_armature(new_objects)
if src_arm is None:
raise RuntimeError(f"{LIBRARY}: no source armature")
src_roles = RigRoles(src_arm)
mapping = build_map(src_roles, target_roles, {})
if len(mapping) < 20:
raise RuntimeError(f"{LIBRARY}: only {len(mapping)} mapped bones")
yaw = facing_correction(src_arm, src_roles, target_arm, target_roles)
src_h = (
src_arm.matrix_world
@ src_arm.data.bones[src_roles.hips].matrix_local
).translation.z
target_h = (
target_arm.matrix_world
@ target_arm.data.bones[target_roles.hips].matrix_local
).translation.z
scale = target_h / src_h if src_h > 1e-5 else 1.0
by_name = {action.name.split(".")[0]: action for action in new_actions}
for clip in CLIPS:
source = by_name.get(clip)
if source is None:
raise RuntimeError(f"{LIBRARY}: missing {clip}")
baked = retarget_action(
target_arm,
target_roles,
src_arm,
src_roles,
mapping,
source,
clip,
yaw,
scale,
)
add_clip_track(target_arm, baked, clip)
for obj in new_objects:
bpy.data.objects.remove(obj, do_unlink=True)
for action in new_actions:
if action.users == 0:
bpy.data.actions.remove(action)
assign_action(target_arm, None)
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=OUTPUT,
export_format="GLB",
export_yup=True,
export_apply=False,
export_skins=True,
export_animations=True,
export_animation_mode="NLA_TRACKS",
export_bake_animation=False,
export_optimize_animation_size=True,
export_optimize_animation_keep_anim_armature=False,
)
print(
f"Injected {', '.join(CLIPS)} into {OUTPUT}; "
f"{len(bpy.data.actions)} total actions"
)
main()
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python
"""Tone statistics for captured frames.
"It looks flat" is not actionable. "42% of the frame is one shade and the
adjacent-pixel detail is 1.2/255" is — you can watch that number move when you
add a panel-line law, and you can tell the difference between a change that
helped and a change you merely believe helped.
Run after any capture tool that writes PNGs:
python tools/levels.py <dir-or-png>... [--inset 12] [--gate]
Columns
mean p1 p50 p99 luma percentiles, 0-255. p99 is the practical white point;
if it sits at 255 the frame is clipping, if it sits at 140
the frame never reaches white and reads washed out.
clip% pixels >= 250. Blown highlights. Our tonemap history: a
LINEAR tonemap clipped lit whites and the glow pass bloomed
the clip, so every facade glowed like porcelain. This is
the number that catches that happening again.
black% pixels <= 7. Crushed shadows, no detail recoverable.
spread p99 - p1. Total tonal range in use.
dead% share of the frame held by the single most common luma.
A featureless sky filling the top half shows up here.
detail mean |luma difference| between horizontally adjacent
pixels. THE flatness metric: untextured flat-shaded planes
score near zero, surface detail (seams, wear, grain) moves
it up. Cel art should be low but not dead.
sat / satp99 mean and 99th-percentile HSV saturation. Guards the other
direction: we shifted authored hues once by grading
saturation to 1.3 and ginger hair rendered fire-truck red.
The HUD is bright, saturated and pinned to the frame edges, so it drags every
statistic toward itself. --inset trims that percentage off each edge, which is
what you want whenever you are measuring the 3D render rather than the screen.
"""
import argparse
import os
import sys
try:
from PIL import Image, ImageChops
except ImportError:
sys.exit("levels.py needs Pillow: python -m pip install pillow")
# --gate thresholds. These are not taste — they are the failure modes this
# project has actually shipped and had to walk back, written down as numbers so
# the next one gets caught by a script instead of by the user's eye.
GATES = {
"clip": (2.0, "highlights blowing out — the glow pass will bloom the clip"),
"black": (35.0, "shadows crushed to pure black, no form left in them"),
# Cel art deliberately carries broad flat fills. These limits are tuned to
# the deterministic probe: the old clear-colour sky hit 2835% dead and
# ~0.34 detail, while the authored sky + surface laws stay under 40% dead
# and above 0.37 detail. Real city shots carry substantially more detail.
"dead": (40.0, "nearly half the frame is one flat shade"),
"detail": (0.37, "surface detail at the old greybox baseline"),
"satp99": (250.0, "saturation railed; authored hues are being shifted"),
}
def _percentile(hist, total, frac):
"""Value at `frac` of the population, from a 256-bin histogram."""
target = total * frac
run = 0
for value, count in enumerate(hist):
run += count
if run >= target:
return value
return 255
def measure(path, inset_pct=0):
img = Image.open(path).convert("RGB")
if inset_pct:
w, h = img.size
dx, dy = int(w * inset_pct / 100.0), int(h * inset_pct / 100.0)
img = img.crop((dx, dy, w - dx, h - dy))
luma = img.convert("L")
hist = luma.histogram()
total = sum(hist)
# Adjacent-pixel difference: the image against itself shifted one pixel
# left. Done with ImageChops so the per-pixel work stays in C — a Python
# loop over a 1280x720 frame is a second per metric per shot.
w, h = luma.size
shifted = ImageChops.difference(luma.crop((1, 0, w, h)), luma.crop((0, 0, w - 1, h)))
dhist = shifted.histogram()
dtotal = sum(dhist)
detail = sum(v * c for v, c in enumerate(dhist)) / max(dtotal, 1)
sat = img.convert("HSV").getchannel(1)
shist = sat.histogram()
stotal = sum(shist)
p1 = _percentile(hist, total, 0.01)
p99 = _percentile(hist, total, 0.99)
return {
"shot": os.path.basename(path),
"mean": sum(v * c for v, c in enumerate(hist)) / max(total, 1),
"p1": p1,
"p50": _percentile(hist, total, 0.50),
"p99": p99,
"clip": 100.0 * sum(hist[250:]) / max(total, 1),
"black": 100.0 * sum(hist[:8]) / max(total, 1),
"spread": p99 - p1,
"dead": 100.0 * max(hist) / max(total, 1),
"detail": detail,
"sat": sum(v * c for v, c in enumerate(shist)) / max(stotal, 1),
"satp99": _percentile(shist, stotal, 0.99),
}
HEADER = "{:<22} {:>6} {:>4} {:>4} {:>4} {:>6} {:>7} {:>7} {:>6} {:>7} {:>6} {:>7}"
ROW = "{shot:<22} {mean:>6.1f} {p1:>4.0f} {p50:>4.0f} {p99:>4.0f} {clip:>6.2f} {black:>7.2f} {spread:>7.0f} {dead:>6.2f} {detail:>7.2f} {sat:>6.1f} {satp99:>7.0f}"
COLS = ("shot", "mean", "p1", "p50", "p99", "clip%", "black%", "spread", "dead%", "detail", "sat", "satp99")
def collect(targets):
paths = []
for t in targets:
if os.path.isdir(t):
paths += [os.path.join(t, f) for f in sorted(os.listdir(t))
if f.lower().endswith(".png")]
else:
paths.append(t)
return paths
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("targets", nargs="+", help="PNG files or directories of them")
ap.add_argument("--inset", type=float, default=0,
help="trim this %% off each edge before measuring (HUD lives there)")
ap.add_argument("--gate", action="store_true",
help="exit non-zero and name the frames that trip a threshold")
args = ap.parse_args()
paths = collect(args.targets)
if not paths:
sys.exit("levels.py: no PNGs found in %s" % ", ".join(args.targets))
print(HEADER.format(*COLS))
rows = [measure(p, args.inset) for p in paths]
for r in rows:
print(ROW.format(**r))
if len(rows) > 1:
avg = {k: sum(r[k] for r in rows) / len(rows)
for k in rows[0] if k != "shot"}
avg["shot"] = "AVERAGE (%d)" % len(rows)
print("-" * 100)
print(ROW.format(**avg))
if not args.gate:
return 0
failures = []
for r in rows:
for key, (limit, why) in GATES.items():
# detail is a floor, everything else is a ceiling.
bad = r[key] < limit if key == "detail" else r[key] > limit
if bad:
failures.append("%s: %s=%.2f (%s %.2f) — %s"
% (r["shot"], key, r[key],
"below" if key == "detail" else "over", limit, why))
print()
if failures:
print("GATE FAILED")
for f in failures:
print(" " + f)
return 1
print("GATE PASSED — %d frames within thresholds" % len(rows))
return 0
if __name__ == "__main__":
sys.exit(main())
+394 -75
View File
@@ -8,11 +8,11 @@ animations use the game's canonical clip names (Idle, Walk, Run, Jump, ...).
Animation library — two supported layouts in <animations_dir>:
A. `_library.glb` — a single multi-clip library (e.g. Quaternius Universal
Animation Library, CC0, committed at assets/characters/animations/).
Clip names map through LIBRARY_CLIP_MAP below. The character must be
rigged on this library's own skeleton (tools/autorig.py does this
automatically when _library.glb is present).
A. `_library.glb` and optional `_library_v2.glb` — multi-clip libraries
(Quaternius Universal Animation Libraries 1 and 2, CC0, committed at
assets/characters/animations/). Clip names map through the tables below.
The two editions use the same rest skeleton and proportions but different
bone names; V2_BONE_MAP describes that exact rename.
B. Loose FBX/GLB files, ONE clip per file, all on the Mixamo skeleton
(manual Mixamo downloads). Filenames map to canonical clip names:
@@ -28,8 +28,16 @@ Locomotion clips are exported in place (hips horizontal motion stripped)
unless --keep-root-motion is given, since the gameplay code moves the body.
"""
import bpy
import math
import sys
import os
from mathutils import Matrix, Quaternion, Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from rig_map import RigRoles, build_map
from retarget_pose import (
authored_world_rotation, build_segment_pairs, heading_inverse
)
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
@@ -44,6 +52,7 @@ OUTPUT = argv[2]
STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
MIX = "mixamorig:"
UP = Vector((0.0, 0.0, 1.0))
# Quaternius Universal Animation Library clip -> game canonical clip name.
# Unmapped clips are skipped to keep character GLBs small.
@@ -57,16 +66,122 @@ LIBRARY_CLIP_MAP = {
"Jump_Land": "Land",
"Crouch_Idle_Loop": "CrouchIdle",
"Crouch_Fwd_Loop": "CrouchWalk",
"Roll": "Dash",
"Roll": "Roll",
"Death01": "Death",
"Hit_Chest": "Hit",
"Dance_Loop": "Dance",
# Movement-shooter extras:
"Swim_Fwd_Loop": "Grapple", # superman reach reads as a swing pose
"Pistol_Idle_Loop": "PistolIdle", # armed idle (weapon actually held up)
"Pistol_Shoot": "PistolShoot",
"Pistol_Reload": "PistolReload",
"Sword_Attack": "Throw", # overhead swing reads as a grenade throw
"Sword_Attack": "SwordAttack",
}
# Quaternius Universal Animation Library 2 Standard adds authored traversal
# clips that the first library does not contain. Keep entry/loop/exit clips
# distinct: runtime code can now play the motion the animator authored instead
# of manufacturing a pose by rotating a few bones.
LIBRARY_V2_CLIP_MAP = {
"Slide_Start": "SlideStart",
"Slide_Loop": "Slide",
"Slide_Exit": "SlideExit",
"NinjaJump_Start": "WallRunStart",
"NinjaJump_Land": "WallRunExit",
"Idle_Rail_Loop": "WallCling",
"ClimbUp_1m": "WallClimb",
"Shield_Dash": "Dash",
"Sword_Dash": "SwordDash",
"OverhandThrow": "Throw",
"Hit_Knockback": "Knockback",
"Chest_Open": "EmoteStretch",
"Idle_Rail_Call": "EmoteCall",
"Yes": "EmoteYes",
"Idle_No_Loop": "EmoteNo",
}
# Four authored combat-strafe cycles from the Godot TPS Demo. These are kept as
# a tiny animation-only GLB; see _directional_LICENSE.md. They give the runtime
# a real backward and lateral stride instead of yawing a forward run's hips.
DIRECTIONAL_CLIP_MAP = {
"strafe_front-cycle": "StrafeWalkForward",
"strafe_back-cycle": "StrafeWalkBackward",
}
MESH2MOTION_CLIP_MAP = {
"Strafe_left": [("StrafeWalkLeft", 0.0), ("RunLeft", 0.0)],
"Strafe_right": [("StrafeWalkRight", 0.0), ("RunRight", 0.0)],
"Run_Anime": [("RunForward", 0.0)],
# Authored held flight/reach with only subtle foot motion.
"Flying Forward Super": [("Grapple", 0.0)],
}
WALLRUN_CLIP_MAP = {
"WallRunLeft": "WallRunLeft",
"WallRunRight": "WallRunRight",
}
CMU_CLIP_MAP = {
"RunBackward": "RunBackward",
}
# UAL2 renamed the original Quaternius/Rigify bones to Unreal-style names, but
# their rest transforms are byte-for-byte equivalent (verified in Blender).
# The map is target/game bone -> UAL2 source bone.
V2_BONE_MAP = {
"root": "root",
"DEF-hips": "pelvis",
"DEF-spine.001": "spine_01",
"DEF-spine.002": "spine_02",
"DEF-spine.003": "spine_03",
"DEF-neck": "neck_01",
"DEF-head": "Head",
"DEF-shoulder.L": "clavicle_l",
"DEF-upper_arm.L": "upperarm_l",
"DEF-forearm.L": "lowerarm_l",
"DEF-hand.L": "hand_l",
"DEF-shoulder.R": "clavicle_r",
"DEF-upper_arm.R": "upperarm_r",
"DEF-forearm.R": "lowerarm_r",
"DEF-hand.R": "hand_r",
"DEF-thigh.L": "thigh_l",
"DEF-shin.L": "calf_l",
"DEF-foot.L": "foot_l",
"DEF-toe.L": "ball_l",
"DEF-thigh.R": "thigh_r",
"DEF-shin.R": "calf_r",
"DEF-foot.R": "foot_r",
"DEF-toe.R": "ball_r",
}
for _side, _suffix in (("L", "l"), ("R", "r")):
for _game_digit, _source_digit in (
("f_index", "index"),
("f_middle", "middle"),
("f_ring", "ring"),
("f_pinky", "pinky"),
("thumb", "thumb"),
):
for _joint in range(1, 4):
V2_BONE_MAP[f"DEF-{_game_digit}.{_joint:02d}.{_side}"] = (
f"{_source_digit}_{_joint:02d}_{_suffix}"
)
# The TPS robot's lower-body rig is humanoid and its leg rest axes are within a
# few degrees of the Quaternius rig. Only the authored lower-body motion is
# transferred; the project's weapon layer keeps ownership of the upper body.
DIRECTIONAL_BONE_MAP = {
"root": "root",
"DEF-hips": "hips",
"DEF-spine.001": "spine1",
"DEF-spine.002": "spine2",
"DEF-spine.003": "chest",
"DEF-thigh.L": "thigh.L",
"DEF-shin.L": "shin.L",
"DEF-foot.L": "foot.L",
"DEF-toe.L": "toe.L",
"DEF-thigh.R": "thigh.R",
"DEF-shin.R": "shin.R",
"DEF-foot.R": "foot.R",
"DEF-toe.R": "toe.R",
}
@@ -144,7 +259,7 @@ def normalize_action_paths(action):
def hips_height(arm):
for b in arm.data.bones:
if b.name.lower().endswith("hips"):
if b.name.lower().endswith("hips") or b.name.lower() == "pelvis":
return (arm.matrix_world @ b.head_local).z
return 1.0
@@ -185,14 +300,18 @@ def main():
print("ERROR: character has no armature — run tools/autorig.py first")
sys.exit(1)
# Normalize character bone names too.
for bone in target_arm.data.bones:
fixed = normalize_prefix(bone.name)
if fixed != bone.name:
bone.name = fixed
# Preserve the character's authored bone names. The rig sidecar is the
# runtime contract for IK, aim and cloth, so silently renaming Hips/Head
# here would invalidate otherwise-correct metadata on artist rigs. Role
# mapping makes a shared naming convention unnecessary.
target_hips = hips_height(target_arm)
print(f"Character hips height: {target_hips:.3f}")
target_roles = RigRoles(target_arm)
missing = target_roles.missing_core()
if missing:
print(f"ERROR: could not identify target rig roles: {missing}")
sys.exit(1)
if not target_arm.animation_data:
target_arm.animation_data_create()
@@ -201,11 +320,29 @@ def main():
for track in list(target_arm.animation_data.nla_tracks):
target_arm.animation_data.nla_tracks.remove(track)
target_arm.animation_data.action = None
for action in list(bpy.data.actions):
if action.users == 0:
bpy.data.actions.remove(action)
library_path = os.path.join(ANIM_DIR, "_library.glb")
if os.path.exists(library_path):
merged = merge_from_library(target_arm, target_hips, library_path)
else:
library_specs = (
("_library.glb", LIBRARY_CLIP_MAP),
("_library_v2.glb", LIBRARY_V2_CLIP_MAP),
("_directional.glb", DIRECTIONAL_CLIP_MAP),
("_mesh2motion.glb", MESH2MOTION_CLIP_MAP),
("_cmu_locomotion.glb", CMU_CLIP_MAP),
("_wallrun.glb", WALLRUN_CLIP_MAP),
)
merged = 0
found_library = False
for filename, clip_map in library_specs:
library_path = os.path.join(ANIM_DIR, filename)
if not os.path.exists(library_path):
continue
found_library = True
merged += merge_from_library(
target_arm, target_roles, library_path, clip_map
)
if not found_library:
merged = merge_from_files(target_arm, target_hips)
if merged == 0:
@@ -221,7 +358,12 @@ def main():
export_animations=True,
export_animation_mode="NLA_TRACKS",
export_skins=True,
export_bake_animation=True,
# Only mapped body bones are keyed. Baking here would add frozen
# rest-pose tracks to every hair/skirt bone and steal them from the
# runtime spring solver.
export_bake_animation=False,
export_optimize_animation_size=True,
export_optimize_animation_keep_anim_armature=False,
)
print("Done.")
@@ -249,53 +391,201 @@ def _assign_action(obj, action):
pass
def retarget_action(target_arm, src_arm, src_action, clip_name):
"""Retarget one source clip onto the character rig via world-space
constraint baking.
def _world_rest(arm):
return {
bone.name: arm.matrix_world @ bone.matrix_local
for bone in arm.data.bones
}
The character rig's arms were fitted to the mesh's A-pose (autorig.py), so
its rest differs from the library's T-pose. Copying each bone's WORLD
orientation (not its local channel) reproduces the library's actual motion
regardless of that rest difference, then baking captures it as a clean
action on the character rig.
def _bone_order(arm):
out = []
def walk(bone):
out.append(bone.name)
for child in bone.children:
walk(child)
for bone in arm.data.bones:
if bone.parent is None:
walk(bone)
return out
def _rig_forward(arm, roles):
acc = Vector((0.0, 0.0, 0.0))
for side in ("L", "R"):
for role in ("toe", "foot"):
name = roles.limb.get((role, side))
if not name:
continue
bone = arm.data.bones[name]
direction = (
(arm.matrix_world @ bone.tail_local)
- (arm.matrix_world @ bone.matrix_local.translation)
)
direction.z = 0.0
if direction.length > 1e-5:
acc += direction.normalized()
break
return acc.normalized() if acc.length > 1e-5 else None
def _facing_correction(src_arm, src_roles, target_arm, target_roles):
source = _rig_forward(src_arm, src_roles)
target = _rig_forward(target_arm, target_roles)
if source is None or target is None:
return Quaternion()
angle = Vector((source.x, source.y)).angle_signed(
Vector((target.x, target.y)), 0.0
)
return Quaternion(UP, angle) if abs(angle) >= 1e-4 else Quaternion()
def _solve_pose(arm, order, rest_world, desired_rot, hips, hips_head):
pose_world = {}
basis = {}
bones = arm.data.bones
for name in order:
bone = bones[name]
rest = rest_world[name]
if bone.parent is not None:
parent = bone.parent.name
matrix = (
pose_world[parent]
@ rest_world[parent].inverted()
@ rest
)
else:
matrix = rest
rotation = Quaternion()
if name in desired_rot:
rotation = matrix.to_quaternion().inverted() @ desired_rot[name]
location = Vector((0.0, 0.0, 0.0))
if name == hips and hips_head is not None:
location = matrix.inverted() @ hips_head
basis[name] = (location, rotation)
pose_world[name] = (
matrix
@ Matrix.Translation(location)
@ rotation.to_matrix().to_4x4()
)
return basis
def retarget_action(target_arm, target_roles, src_arm, src_roles, mapping,
src_action, clip_name, yaw, scale,
lower_body_yaw_degrees=0.0):
"""Bake an authored motion onto any recognized humanoid rig.
Major joint-to-joint directions come from the source performance while the
target keeps its own bone roll. This prevents a crossed/T-pose modelling
rest from surviving into every clip without twisting artist rigs.
"""
src_rest = _world_rest(src_arm)
target_rest = _world_rest(target_arm)
target_order = _bone_order(target_arm)
src_rest_rot = {
name: matrix.to_quaternion() for name, matrix in src_rest.items()
}
target_rest_rot = {
name: matrix.to_quaternion() for name, matrix in target_rest.items()
}
yaw_inverse = yaw.inverted()
segment_pairs = build_segment_pairs(mapping, src_roles, target_roles)
src_hips = src_roles.hips
target_hips = target_roles.hips
src_hips_rest = src_rest[src_hips].translation.copy()
target_hips_rest = target_rest[target_hips].translation.copy()
_assign_action(src_arm, src_action)
fr0 = int(src_action.frame_range[0])
fr1 = int(src_action.frame_range[1])
frame_start, frame_end = (
int(round(value)) for value in src_action.frame_range
)
source_heading = Quaternion()
if clip_name == "RunBackward":
bpy.context.scene.frame_set(frame_start)
source_eval = src_arm.evaluated_get(
bpy.context.evaluated_depsgraph_get()
)
source_heading = heading_inverse(
(
source_eval.matrix_world
@ source_eval.pose.bones[src_hips].matrix
).to_quaternion(),
src_rest_rot[src_hips],
UP,
)
baked = bpy.data.actions.new(clip_name)
_assign_action(target_arm, baked)
for pose_bone in target_arm.pose.bones:
pose_bone.rotation_mode = "QUATERNION"
for pb in target_arm.pose.bones:
if pb.name not in src_arm.pose.bones:
continue
cr = pb.constraints.new("COPY_ROTATION")
cr.target = src_arm
cr.subtarget = pb.name
cr.target_space = "WORLD"
cr.owner_space = "WORLD"
if pb.name.lower().endswith("hips"):
cl = pb.constraints.new("COPY_LOCATION")
cl.target = src_arm
cl.subtarget = pb.name
cl.target_space = "WORLD"
cl.owner_space = "WORLD"
scene = bpy.context.scene
for frame in range(frame_start, frame_end + 1):
scene.frame_set(frame)
depsgraph = bpy.context.evaluated_depsgraph_get()
src_eval = src_arm.evaluated_get(depsgraph)
source_world = src_eval.matrix_world
desired = {}
for target_name, source_name in mapping.items():
if (
source_name not in src_eval.pose.bones
or target_name not in target_rest_rot
):
continue
target_rotation = authored_world_rotation(
target_name,
source_name,
src_eval,
source_world,
src_rest_rot,
target_rest,
target_rest_rot,
yaw,
yaw_inverse,
segment_pairs,
source_heading,
)
desired[target_name] = target_rotation
bpy.ops.object.select_all(action="DESELECT")
target_arm.select_set(True)
bpy.context.view_layer.objects.active = target_arm
if target_arm.animation_data:
target_arm.animation_data.action = None
hips_head = None
if src_hips in src_eval.pose.bones:
moved = (
source_world @ src_eval.pose.bones[src_hips].matrix
).translation
displacement = yaw @ (source_heading @ (
(moved - src_hips_rest) * scale
))
if STRIP_ROOT_MOTION:
displacement.x = 0.0
displacement.y = 0.0
hips_head = target_hips_rest + displacement
bpy.ops.nla.bake(frame_start=fr0, frame_end=fr1, step=1,
only_selected=False, visual_keying=True,
clear_constraints=True, clear_parents=False,
use_current_action=True, bake_types={"POSE"})
basis = _solve_pose(
target_arm,
target_order,
target_rest,
desired,
target_hips,
hips_head,
)
for name in mapping:
if name not in basis:
continue
pose_bone = target_arm.pose.bones[name]
pose_bone.rotation_quaternion = basis[name][1]
pose_bone.keyframe_insert("rotation_quaternion", frame=frame)
if hips_head is not None:
pose_bone = target_arm.pose.bones[target_hips]
pose_bone.location = basis[target_hips][0]
pose_bone.keyframe_insert("location", frame=frame)
baked = target_arm.animation_data.action
if STRIP_ROOT_MOTION:
strip_hips_horizontal(baked)
_assign_action(target_arm, None)
return baked
def merge_from_library(target_arm, target_hips, library_path):
def merge_from_library(target_arm, target_roles, library_path, clip_map):
"""Retarget every mapped clip from a single multi-clip library GLB onto the
character rig (whose arm rest was fitted to the mesh)."""
print(f"Using multi-clip library: {library_path}")
@@ -308,31 +598,60 @@ def merge_from_library(target_arm, target_hips, library_path):
if not src_arm:
print("ERROR: no armature in animation library")
sys.exit(1)
# Match the library rig's height to the character so hips bob / foot planting
# translate correctly under the world-space location copy.
src_hips = hips_height(src_arm)
if src_hips > 1e-4:
s = target_hips / src_hips
src_arm.scale = (s, s, s)
bpy.ops.object.select_all(action="DESELECT")
src_arm.select_set(True)
bpy.context.view_layer.objects.active = src_arm
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
src_roles = RigRoles(src_arm)
missing = src_roles.missing_core()
if missing:
print(f"ERROR: could not identify source rig roles: {missing}")
sys.exit(1)
mapping = build_map(src_roles, target_roles, {})
if len(mapping) < 10:
print(f"ERROR: only {len(mapping)} role-mapped bones in {library_path}")
sys.exit(1)
yaw = _facing_correction(src_arm, src_roles, target_arm, target_roles)
src_height = (
src_arm.matrix_world
@ src_arm.data.bones[src_roles.hips].matrix_local
).translation.z
target_height = (
target_arm.matrix_world
@ target_arm.data.bones[target_roles.hips].matrix_local
).translation.z
scale = target_height / src_height if src_height > 1e-5 else 1.0
print(
f"Role-mapped {len(mapping)} target bones; "
f"hips scale {scale:.3f}"
)
merged = 0
keep = []
for action in new_actions:
base = action.name.split(".")[0] # tolerate .001 suffixes
clip_name = LIBRARY_CLIP_MAP.get(base)
if not clip_name:
mapped = clip_map.get(base)
if not mapped:
continue
print(f"--- {base} -> '{clip_name}' (retarget bake)")
baked = retarget_action(target_arm, src_arm, action, clip_name)
target_arm.animation_data.action = None
_add_clip_track(target_arm, baked, clip_name)
keep.append(baked)
merged += 1
specs = [(mapped, 0.0)] if isinstance(mapped, str) else mapped
for spec in specs:
clip_name, travel_yaw_degrees = spec[:2]
print(
f"--- {base} -> '{clip_name}' (retarget bake, "
f"lower-body heading {travel_yaw_degrees:+.0f} deg)"
)
baked = retarget_action(
target_arm,
target_roles,
src_arm,
src_roles,
mapping,
action,
clip_name,
yaw,
scale,
travel_yaw_degrees,
)
target_arm.animation_data.action = None
_add_clip_track(target_arm, baked, clip_name)
keep.append(baked)
merged += 1
for o in new_objects:
bpy.data.objects.remove(o, do_unlink=True)
+166 -43
View File
@@ -57,6 +57,9 @@ import gltf_fix
import rig_map
import surface_map
from rig_map import RigRoles, build_map, is_cosmetic, is_segment_of, tokens
from retarget_pose import (
authored_world_rotation, build_segment_pairs, heading_inverse
)
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
@@ -86,15 +89,64 @@ LIBRARY_CLIP_MAP = {
"Jump_Land": "Land",
"Crouch_Idle_Loop": "CrouchIdle",
"Crouch_Fwd_Loop": "CrouchWalk",
"Roll": "Dash",
"Roll": "Roll",
"Death01": "Death",
"Hit_Chest": "Hit",
"Dance_Loop": "Dance",
"Swim_Fwd_Loop": "Grapple",
"Pistol_Idle_Loop": "PistolIdle",
"Pistol_Shoot": "PistolShoot",
"Pistol_Reload": "PistolReload",
"Sword_Attack": "Throw",
"Sword_Attack": "SwordAttack",
}
LIBRARY_V2_CLIP_MAP = {
"Slide_Start": "SlideStart",
"Slide_Loop": "Slide",
"Slide_Exit": "SlideExit",
"NinjaJump_Start": "WallRunStart",
"NinjaJump_Land": "WallRunExit",
"Idle_Rail_Loop": "WallCling",
"ClimbUp_1m": "WallClimb",
"Shield_Dash": "Dash",
"Sword_Dash": "SwordDash",
"OverhandThrow": "Throw",
"Hit_Knockback": "Knockback",
"Chest_Open": "EmoteStretch",
"Idle_Rail_Call": "EmoteCall",
"Yes": "EmoteYes",
"Idle_No_Loop": "EmoteNo",
}
# The TPS demo set is a combat *walk*, not a run. It used to occupy the
# high-speed Run* slots, which made the runtime accelerate a roughly 1.8 m/s
# step cycle to six times normal playback at the game's 11 m/s ground speed.
# Keep the useful authored front/back steps, but label them honestly.
DIRECTIONAL_CLIP_MAP = {
"strafe_front-cycle": "StrafeWalkForward",
"strafe_back-cycle": "StrafeWalkBackward",
}
# Mesh2Motion's CC0 human add-on library supplies authored forward and lateral
# actions. Never rotate only the legs to manufacture another direction: the
# captured CMU clip below owns backward travel as one coherent full-body move.
MESH2MOTION_CLIP_MAP = {
"Strafe_left": [("StrafeWalkLeft", 0.0), ("RunLeft", 0.0)],
"Strafe_right": [("StrafeWalkRight", 0.0), ("RunRight", 0.0)],
"Run_Anime": [("RunForward", 0.0)],
# A subtle held superhero-flight motion: the leading hands stay extended
# while the feet move less than two degrees across the runtime audit.
"Flying Forward Super": [("Grapple", 0.0)],
}
# Uisco's authored UE4 Mannequin wall-run pair. These are real lateral
# wall-running performances, not a forward sprint relabelled as traversal.
WALLRUN_CLIP_MAP = {
"WallRunLeft": "WallRunLeft",
"WallRunRight": "WallRunRight",
}
CMU_CLIP_MAP = {
"RunBackward": "RunBackward",
}
UP = Vector((0.0, 0.0, 1.0))
@@ -604,13 +656,14 @@ def solve_pose(arm, order, rest_w, desired_rot, hips, hips_head):
def retarget_clip(src_arm, src_roles, tgt_arm, tgt_roles, mapping, action,
clip_name, yaw, scale):
clip_name, yaw, scale, lower_body_yaw_degrees=0.0):
src_rest = world_rest(src_arm)
tgt_rest = world_rest(tgt_arm)
order = bone_order(tgt_arm)
src_rest_rot = {n: m.to_quaternion() for n, m in src_rest.items()}
tgt_rest_rot = {n: m.to_quaternion() for n, m in tgt_rest.items()}
yaw_inv = yaw.inverted()
segment_pairs = build_segment_pairs(mapping, src_roles, tgt_roles)
src_hips = src_roles.hips
tgt_hips = tgt_roles.hips
@@ -619,6 +672,20 @@ def retarget_clip(src_arm, src_roles, tgt_arm, tgt_roles, mapping, action,
assign_action(src_arm, action)
f0, f1 = (int(round(v)) for v in action.frame_range)
source_heading = Quaternion()
if clip_name == "RunBackward":
bpy.context.scene.frame_set(f0)
source_eval = src_arm.evaluated_get(
bpy.context.evaluated_depsgraph_get()
)
source_heading = heading_inverse(
(
source_eval.matrix_world
@ source_eval.pose.bones[src_hips].matrix
).to_quaternion(),
src_rest_rot[src_hips],
UP,
)
baked = bpy.data.actions.new(clip_name)
assign_action(tgt_arm, baked)
@@ -636,14 +703,27 @@ def retarget_clip(src_arm, src_roles, tgt_arm, tgt_roles, mapping, action,
for tgt_name, src_name in mapping.items():
if src_name not in src_eval.pose.bones or tgt_name not in tgt_rest_rot:
continue
pose_rot = (smw @ src_eval.pose.bones[src_name].matrix).to_quaternion()
delta = pose_rot @ src_rest_rot[src_name].inverted()
desired[tgt_name] = (yaw @ delta @ yaw_inv) @ tgt_rest_rot[tgt_name]
target_rotation = authored_world_rotation(
tgt_name,
src_name,
src_eval,
smw,
src_rest_rot,
tgt_rest,
tgt_rest_rot,
yaw,
yaw_inv,
segment_pairs,
source_heading,
)
desired[tgt_name] = target_rotation
hips_head = None
if src_hips in src_eval.pose.bones:
moved = (smw @ src_eval.pose.bones[src_hips].matrix).translation
d = yaw @ ((moved - src_hips_rest) * scale)
d = yaw @ (source_heading @ (
(moved - src_hips_rest) * scale
))
if STRIP_ROOT_MOTION:
d.x = 0.0
d.y = 0.0 # gameplay code moves the body; keep the vertical bob
@@ -1162,31 +1242,7 @@ def main():
fix_unlit_materials(meshes)
roles = RigRoles(arm) # rest positions moved; re-read
library = os.path.join(ANIM_DIR, "_library.glb")
if not os.path.exists(library):
print(f"ERROR: animation library not found: {library}")
sys.exit(1)
before = set(bpy.data.objects)
before_actions = set(bpy.data.actions)
bpy.ops.import_scene.gltf(filepath=library)
new_objects = [o for o in bpy.data.objects if o not in before]
src_arm = next((o for o in new_objects if o.type == "ARMATURE"), None)
if not src_arm:
print("ERROR: no armature in the animation library")
sys.exit(1)
src_roles = RigRoles(src_arm)
mapping = build_map(src_roles, roles, OVERRIDES)
print("\nLibrary rig:\n" + src_roles.describe())
print("\nCharacter rig:\n" + roles.describe())
print(f"\nDriving {len(mapping)} of {len(arm.data.bones)} bones from the library; "
f"{len(arm.data.bones) - len(mapping)} left free for secondary motion.")
yaw = facing_correction(src_arm, src_roles, arm, roles)
src_h = (src_arm.matrix_world @ src_arm.data.bones[src_roles.hips].matrix_local).translation.z
tgt_h = (arm.matrix_world @ arm.data.bones[roles.hips].matrix_local).translation.z
scale = tgt_h / src_h if src_h > 1e-5 else 1.0
print(f"Hips height: library {src_h:.3f} m, character {tgt_h:.3f} m (scale {scale:.3f})")
if not arm.animation_data:
arm.animation_data_create()
@@ -1195,16 +1251,83 @@ def main():
merged = 0
kept_clips = set()
for action in [a for a in bpy.data.actions if a not in before_actions]:
clip = LIBRARY_CLIP_MAP.get(action.name.split(".")[0])
if not clip:
all_library_objects = []
primary_mapping = {}
library_specs = (
("_library.glb", LIBRARY_CLIP_MAP),
("_library_v2.glb", LIBRARY_V2_CLIP_MAP),
("_directional.glb", DIRECTIONAL_CLIP_MAP),
("_mesh2motion.glb", MESH2MOTION_CLIP_MAP),
("_cmu_locomotion.glb", CMU_CLIP_MAP),
("_wallrun.glb", WALLRUN_CLIP_MAP),
)
found_library = False
for filename, clip_map in library_specs:
library = os.path.join(ANIM_DIR, filename)
if not os.path.exists(library):
continue
print(f" {action.name} -> {clip}")
baked = retarget_clip(src_arm, src_roles, arm, roles, mapping, action,
clip, yaw, scale)
add_nla_clip(arm, baked, clip)
kept_clips.add(clip)
merged += 1
found_library = True
before = set(bpy.data.objects)
before_actions = set(bpy.data.actions)
bpy.ops.import_scene.gltf(filepath=library)
new_objects = [o for o in bpy.data.objects if o not in before]
all_library_objects.extend(new_objects)
src_arm = next((o for o in new_objects if o.type == "ARMATURE"), None)
if not src_arm:
print(f"ERROR: no armature in animation library {filename}")
sys.exit(1)
src_roles = RigRoles(src_arm)
mapping = build_map(src_roles, roles, OVERRIDES)
if not primary_mapping:
primary_mapping = mapping
print(f"\n{filename} rig:\n" + src_roles.describe())
print(f"Driving {len(mapping)} of {len(arm.data.bones)} bones; "
f"{len(arm.data.bones) - len(mapping)} left free.")
yaw = facing_correction(src_arm, src_roles, arm, roles)
src_h = (
src_arm.matrix_world
@ src_arm.data.bones[src_roles.hips].matrix_local
).translation.z
tgt_h = (
arm.matrix_world @ arm.data.bones[roles.hips].matrix_local
).translation.z
scale = tgt_h / src_h if src_h > 1e-5 else 1.0
print(
f"Hips height: library {src_h:.3f} m, "
f"character {tgt_h:.3f} m (scale {scale:.3f})"
)
for action in [a for a in bpy.data.actions if a not in before_actions]:
mapped = clip_map.get(action.name.split(".")[0])
if not mapped:
continue
specs = [(mapped, 0.0)] if isinstance(mapped, str) else mapped
for spec in specs:
clip, travel_yaw_degrees = spec[:2]
suffix = (
f" (lower-body heading {travel_yaw_degrees:+.0f} deg)"
if abs(travel_yaw_degrees) > 1e-4 else ""
)
print(f" {action.name} -> {clip}{suffix}")
baked = retarget_clip(
src_arm,
src_roles,
arm,
roles,
mapping,
action,
clip,
yaw,
scale,
travel_yaw_degrees,
)
add_nla_clip(arm, baked, clip)
kept_clips.add(clip)
merged += 1
if not found_library:
print(f"ERROR: no animation libraries found in {ANIM_DIR}")
sys.exit(1)
if merged == 0:
print("ERROR: no clips retargeted")
sys.exit(1)
@@ -1241,12 +1364,12 @@ def main():
bpy.data.actions.remove(a)
print(f"Kept {len(kept_clips)} clips; dropped everything else")
for o in new_objects:
for o in all_library_objects:
bpy.data.objects.remove(o, do_unlink=True)
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
sidecar = os.path.splitext(OUTPUT)[0] + ".rig.json"
info = describe_rig(arm, roles, mapping, meshes)
info = describe_rig(arm, roles, primary_mapping, meshes)
with open(sidecar, "w", encoding="utf-8") as f:
json.dump(info, f, indent=2)
print(f"Wrote {os.path.basename(sidecar)}: {len(info['chains'])} cloth chains, "
+125
View File
@@ -0,0 +1,125 @@
"""Silhouette-preserving helpers shared by the animation retargeters.
The source animation owns the direction of each major body segment. Copying
only a bone's rest-relative rotation preserves a target rig's modelling pose;
characters authored with crossed or rearward arms then keep those directions
in every idle, dance, and locomotion clip.
These helpers transfer animated joint-to-joint directions for the torso and
limbs while retaining the target bone's own roll. This is an offline bake of
the source performance, not a runtime procedural animation layer.
"""
from mathutils import Quaternion, Vector
def build_segment_pairs(mapping, source_roles, target_roles):
"""Return target bone -> (target child, source bone, source child)."""
chains = [
[target_roles.hips] + list(target_roles.spine),
]
for side in ("L", "R"):
chains.append([
target_roles.limb.get((role, side))
for role in ("shoulder", "upper_arm", "forearm", "hand")
])
chains.append([
target_roles.limb.get((role, side))
for role in ("thigh", "shin", "foot", "toe")
])
pairs = {}
for chain in chains:
chain = [name for name in chain if name]
for index, target_name in enumerate(chain[:-1]):
source_name = mapping.get(target_name)
if not source_name:
continue
# Chain resampling can map neighbouring target spine bones to the
# same source joint. Look ahead to the first genuinely new joint.
for target_child in chain[index + 1:]:
source_child = mapping.get(target_child)
if source_child and source_child != source_name:
pairs[target_name] = (
target_child, source_name, source_child
)
break
return pairs
def _twist_about(rotation, axis):
"""Extract the twist component of a world-space quaternion around axis."""
axis = axis.normalized()
vector = Vector((rotation.x, rotation.y, rotation.z))
projected = axis * vector.dot(axis)
twist = Quaternion((rotation.w, projected.x, projected.y, projected.z))
if twist.magnitude < 1e-8:
return Quaternion()
twist.normalize()
return twist
def heading_inverse(pose_rotation, rest_rotation, up):
"""World-up heading that removes a capture's initial stage orientation."""
return _twist_about(
pose_rotation @ rest_rotation.inverted(), up
).inverted()
def authored_world_rotation(
target_name,
source_name,
source_eval,
source_world,
source_rest_rot,
target_rest,
target_rest_rot,
yaw,
yaw_inverse,
segment_pairs,
source_heading=Quaternion(),
):
"""Retarget one bone, using authored segment direction where available."""
pose_rotation = source_heading @ (
source_world @ source_eval.pose.bones[source_name].matrix
).to_quaternion()
delta = pose_rotation @ source_rest_rot[source_name].inverted()
full_rotation = (
yaw @ delta @ yaw_inverse
) @ target_rest_rot[target_name]
pair = segment_pairs.get(target_name)
if pair is None:
return full_rotation
target_child, pair_source, source_child = pair
if (
pair_source not in source_eval.pose.bones
or source_child not in source_eval.pose.bones
or target_child not in target_rest
):
return full_rotation
source_head = (
source_world @ source_eval.pose.bones[pair_source].matrix
).translation
source_child_head = (
source_world @ source_eval.pose.bones[source_child].matrix
).translation
desired_direction = yaw @ (
source_heading @ (source_child_head - source_head)
)
target_direction = (
target_rest[target_child].translation
- target_rest[target_name].translation
)
if desired_direction.length < 1e-7 or target_direction.length < 1e-7:
return full_rotation
desired_direction.normalize()
target_direction.normalize()
swing = target_direction.rotation_difference(desired_direction)
swung_rotation = swing @ target_rest_rot[target_name]
residual = full_rotation @ swung_rotation.inverted()
return (
_twist_about(residual, desired_direction) @ swung_rotation
).normalized()
+34 -3
View File
@@ -253,7 +253,20 @@ check(len(orphans) <= 1, "every chain is attached to the body",
# ----------------------------------------------------------------------- clips
actions = {a.name: a for a in bpy.data.actions}
print(f"\n {len(actions)} clips: {', '.join(sorted(actions))}\n")
check(len(actions) >= 10, "the canonical clip set shipped", f"{len(actions)} clips")
required_clips = {
"Idle", "Walk", "Run", "Sprint", "Jump", "Fall", "Land",
"CrouchIdle", "CrouchWalk", "Dash", "Slide",
"RunForward", "RunBackward", "RunLeft", "RunRight",
"StrafeWalkForward", "StrafeWalkBackward",
"StrafeWalkLeft", "StrafeWalkRight",
"WallRunLeft", "WallRunRight", "Grapple",
}
missing_clips = sorted(required_clips - actions.keys())
check(not missing_clips, "the canonical clip set shipped",
f"missing: {', '.join(missing_clips)}" if missing_clips
else f"{len(actions)} clips")
check("WallRun" not in actions, "the placeholder run-cycle wall run was removed",
"legacy WallRun is still present" if "WallRun" in actions else "")
def curves(action):
@@ -305,7 +318,8 @@ else:
# are upper-body clips with genuinely static legs, so demanding leg motion from
# every clip fails on a correct build.
LOCOMOTION = {"Idle", "Walk", "Run", "Sprint", "Jump", "Fall", "Land",
"CrouchIdle", "CrouchWalk", "Dash"}
"CrouchIdle", "CrouchWalk", "Dash",
"WallRunLeft", "WallRunRight"}
# The thighs and shins by ROLE, so this works on any rig's spelling.
leg_bones = {b for s in ROLE_LEGS.values() for b in s}
@@ -314,10 +328,13 @@ if not leg_bones:
if any(h in b.name.lower() for h in ("thigh", "shin"))}
frozen = []
held_rest = []
legless = []
keyed_cloth = set()
held_poses = {"Grapple"}
for name, action in sorted(actions.items()):
moved = defaultdict(float)
authored_offset = 0.0
for fc in curves(action):
b = bone_of(fc.data_path)
if not b or len(fc.keyframe_points) < 2:
@@ -326,7 +343,19 @@ for name, action in sorted(actions.items()):
keyed_cloth.add(b)
vals = [kp.co.y for kp in fc.keyframe_points]
moved[b] = max(moved[b], max(vals) - min(vals))
if max(moved.values(), default=0.0) < 0.005:
# A held pose is intentionally almost constant. Quaternion XYZ and
# translation channels are zero in the rest pose, so a meaningful
# absolute value proves the clip contains an authored silhouette.
if (fc.data_path.endswith("rotation_quaternion")
and fc.array_index in (1, 2, 3)) \
or fc.data_path.endswith("location"):
authored_offset = max(
authored_offset, max((abs(value) for value in vals), default=0.0)
)
if name in held_poses:
if authored_offset < 0.03:
held_rest.append(f"{name}({authored_offset:.4f})")
elif max(moved.values(), default=0.0) < 0.005:
frozen.append(name)
legs = max((v for b, v in moved.items() if b in leg_bones), default=0.0)
if name in LOCOMOTION and legs < 0.01:
@@ -334,6 +363,8 @@ for name, action in sorted(actions.items()):
check(not frozen, "no clip retargeted to a frozen rest pose",
f"frozen: {', '.join(frozen)}" if frozen else "")
check(not held_rest, "held clips contain a non-rest authored pose",
f"rest-like: {', '.join(held_rest)}" if held_rest else "")
check(not legless, "locomotion clips animate the legs",
f"static legs: {', '.join(legless)}" if legless else "")
check(not keyed_cloth, "cloth bones carry no animation keys",