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:
co-authored by
Claude Fable 5
parent
6c227b5538
commit
d0c746d084
@@ -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}")
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user