Feat/visual audio overhaul #21

Merged
Dotts merged 31 commits from feat/visual-audio-overhaul into main 2026-07-21 14:09:18 -07:00
13 changed files with 229 additions and 0 deletions
Showing only changes of commit d0c746d084 - Show all commits
+7
View File
@@ -6,6 +6,13 @@
"description": "Hatsune Miku \u2014 Virtual Idol",
"model": "res://assets/characters/skins/miku.glb",
"unlocked": true
},
{
"id": "taila",
"name": "Taila",
"description": "Original anime character by Partaevil (CC-BY)",
"model": "res://assets/characters/skins/taila.glb",
"unlocked": true
}
]
}
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Taila | Original work",
"uid": "8ae231b61fc34827be30e2a1edc5b811",
"author": "Partaevil",
"author_url": "https://sketchfab.com/Partaevil",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/taila-original-work-8ae231b61fc34827be30e2a1edc5b811"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

+1
View File
@@ -0,0 +1 @@
uid://dc7dqp671fvya
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Strip an existing rig from a character GLB so tools/autorig.py re-rigs it on
the animation-library skeleton.
Some downloaded characters ship pre-rigged (VRoid J_Bip_*, Mixamo, custom
names). tools/merge_animations.py retargets by bone NAME from the library
armature, so a foreign skeleton silently produces flat 2-key clips — a
rest-pose statue (see docs/ASSET_SOURCES.md). The fix is to discard the
foreign rig entirely and let autorig fit the library's DEF-* skeleton.
The mesh keeps its REST pose: armature modifiers are removed (not applied),
so whatever pose the file rests in (usually T/A-pose) is what autorig sees.
Usage:
blender --background --python tools/strip_rig.py -- <input.glb> <output.glb>
"""
import bpy
import sys
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) < 2:
print("Usage: blender --background --python tools/strip_rig.py -- <in.glb> <out.glb>")
sys.exit(1)
INPUT, OUTPUT = argv[0], argv[1]
bpy.ops.wm.read_factory_settings(use_empty=True)
if INPUT.lower().endswith((".glb", ".gltf")):
bpy.ops.import_scene.gltf(filepath=INPUT)
elif INPUT.lower().endswith(".fbx"):
bpy.ops.import_scene.fbx(filepath=INPUT)
else:
print(f"ERROR: unsupported input {INPUT}")
sys.exit(1)
all_meshes = [o for o in bpy.data.objects if o.type == "MESH"]
if not all_meshes:
print("ERROR: no meshes in input")
sys.exit(1)
def is_skinned(obj) -> bool:
if any(m.type == "ARMATURE" for m in obj.modifiers):
return True
return obj.parent is not None and obj.parent.type == "ARMATURE"
# The CHARACTER is whatever was skinned to the rig. Anything else is scene
# dressing (bases, dioramas, floating props) that must not become part of
# the player model.
meshes = [o for o in all_meshes if is_skinned(o)]
if not meshes:
print("WARNING: no skinned meshes found — keeping all meshes")
meshes = all_meshes
else:
for obj in [o for o in all_meshes if o not in meshes]:
print(f"Dropping unskinned prop mesh: {obj.name}")
bpy.data.objects.remove(obj, do_unlink=True)
# Drop armature modifiers and parenting, keep the rest-pose mesh data.
for obj in meshes:
for mod in [m for m in obj.modifiers if m.type == "ARMATURE"]:
obj.modifiers.remove(mod)
if obj.parent and obj.parent.type == "ARMATURE":
world = obj.matrix_world.copy()
obj.parent = None
obj.matrix_world = world
# Old vertex groups reference the dead rig; autorig makes fresh ones.
obj.vertex_groups.clear()
for obj in [o for o in bpy.data.objects if o.type == "ARMATURE"]:
bpy.data.objects.remove(obj, do_unlink=True)
# Join into one mesh so autorig binds everything (hair/clothes included).
bpy.ops.object.select_all(action="DESELECT")
for obj in meshes:
obj.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
if len(meshes) > 1:
bpy.ops.object.join()
# Recentre: feet on the ground at the world origin (imports sometimes place
# the character far off-origin inside a larger scene).
joined = bpy.context.view_layer.objects.active
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
xs = [v.co.x for v in joined.data.vertices]
ys = [v.co.y for v in joined.data.vertices]
zs = [v.co.z for v in joined.data.vertices]
cx = (min(xs) + max(xs)) / 2.0
cy = (min(ys) + max(ys)) / 2.0
floor = min(zs) # Blender is Z-up; glTF exporter converts to Y-up
for v in joined.data.vertices:
v.co.x -= cx
v.co.y -= cy
v.co.z -= floor
# Normalize materials: some anime models ship UNLIT (black base color, albedo
# in the emissive texture). Our pipeline shades with the toon shader off the
# ALBEDO, so rewire each material's first image texture into Principled Base
# Color and drop the emission trick — else the character renders pitch black.
for mat in joined.data.materials:
if not mat or not mat.use_nodes:
continue
nt = mat.node_tree
tex_node = next((n for n in nt.nodes if n.type == "TEX_IMAGE" and n.image), None)
principled = next((n for n in nt.nodes if n.type == "BSDF_PRINCIPLED"), None)
if principled is None:
principled = nt.nodes.new("ShaderNodeBsdfPrincipled")
out = next((n for n in nt.nodes if n.type == "OUTPUT_MATERIAL"), None)
if out is None:
out = nt.nodes.new("ShaderNodeOutputMaterial")
for l in list(out.inputs["Surface"].links):
nt.links.remove(l)
nt.links.new(principled.outputs["BSDF"], out.inputs["Surface"])
if tex_node:
base = principled.inputs["Base Color"]
for l in list(base.links):
nt.links.remove(l)
nt.links.new(tex_node.outputs["Color"], base)
print(f"Material '{mat.name}': routed texture '{tex_node.image.name}' to base color")
# Kill the emission trick so the toon shader owns the look.
if "Emission Strength" in principled.inputs:
principled.inputs["Emission Strength"].default_value = 0.0
bpy.ops.export_scene.gltf(filepath=OUTPUT, export_format="GLB", export_skins=False,
export_animations=False, export_apply=True)
print(f"Done. Stripped rig -> {OUTPUT}")
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
Convert KHR_materials_unlit "emissive albedo" materials in a GLB to plain PBR.
Anime-style models often ship unlit: black baseColorFactor with the real
albedo in emissiveTexture. Blender's importer turns those into textureless
EMISSION node trees (the image is dropped), so everything downstream renders
pitch black. Rewriting the material JSON up front — baseColorTexture :=
emissiveTexture, white base factor, unlit/emissive stripped — gives every
tool in the pipeline a normal textured PBR model.
Usage: python tools/unlit_to_pbr.py <in.glb> <out.glb>
"""
import json
import struct
import sys
def main() -> None:
if len(sys.argv) < 3:
print("Usage: python tools/unlit_to_pbr.py <in.glb> <out.glb>")
sys.exit(1)
src, dst = sys.argv[1], sys.argv[2]
with open(src, "rb") as f:
data = f.read()
magic, version, _length = struct.unpack_from("<III", data, 0)
if magic != 0x46546C67:
print("ERROR: not a GLB file")
sys.exit(1)
offset = 12
json_chunk = None
other_chunks = []
while offset < len(data):
clen, ctype = struct.unpack_from("<II", data, offset)
chunk = data[offset + 8:offset + 8 + clen]
if ctype == 0x4E4F534A: # 'JSON'
json_chunk = chunk
else:
other_chunks.append((ctype, chunk))
offset += 8 + clen
doc = json.loads(json_chunk)
fixed = 0
for mat in doc.get("materials", []):
emis_tex = mat.get("emissiveTexture")
if emis_tex is None:
continue
pbr = mat.setdefault("pbrMetallicRoughness", {})
if "baseColorTexture" not in pbr:
pbr["baseColorTexture"] = emis_tex
pbr["baseColorFactor"] = [1.0, 1.0, 1.0, 1.0]
pbr.setdefault("metallicFactor", 0.0)
pbr["roughnessFactor"] = 1.0
mat.pop("emissiveTexture", None)
mat.pop("emissiveFactor", None)
exts = mat.get("extensions", {})
exts.pop("KHR_materials_unlit", None)
if not exts:
mat.pop("extensions", None)
fixed += 1
used = doc.get("extensionsUsed", [])
if "KHR_materials_unlit" in used:
used.remove("KHR_materials_unlit")
if not used:
doc.pop("extensionsUsed", None)
payload = json.dumps(doc, separators=(",", ":")).encode("utf-8")
payload += b" " * (-len(payload) % 4)
out = bytearray()
out += struct.pack("<II", len(payload), 0x4E4F534A) + payload
for ctype, chunk in other_chunks:
chunk = chunk + b"\x00" * (-len(chunk) % 4)
out += struct.pack("<II", len(chunk), ctype) + chunk
header = struct.pack("<III", 0x46546C67, version, 12 + len(out))
with open(dst, "wb") as f:
f.write(header + out)
print(f"Rewrote {fixed} unlit material(s) -> {dst}")
if __name__ == "__main__":
main()