big
This commit is contained in:
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 28–35% 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
@@ -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
@@ -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, "
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user