feat: Taila — second anime character through a hardened import pipeline

New selectable skin: Taila (original anime character by Partaevil,
CC-BY, license kept alongside the model), cel-shaded with ink outlines,
running the full 18-clip animation set + the new rifle-hold pose layer.

Pipeline hardening learned the hard way (each of these produced a broken
character before the fix):
- tools/strip_rig.py: strips a foreign rig (VRoid/Mixamo names bake FLAT
  clips in the name-based retarget), keeps only meshes skinned to that rig
  (scene props were joining into the player model), recentres feet-on-origin,
  and reroutes image textures into Principled base color for re-export.
- tools/unlit_to_pbr.py: KHR_materials_unlit anime models carry their albedo
  in emissiveTexture over a black base — Blender 5.1 drops the texture on
  import, rendering the character pitch black. Rewrites the GLB JSON to
  standard textured PBR before Blender ever sees it.

Also dropped the Elle import attempt: her rest pose is seated (posed scene),
which the T-pose autorig cannot use.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-19 23:07:17 -04:00
co-authored by Claude Fable 5
parent 6c227b5538
commit d0c746d084
13 changed files with 229 additions and 0 deletions
+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}")