Feat/visual audio overhaul #21
@@ -6,6 +6,13 @@
|
|||||||
"description": "Hatsune Miku \u2014 Virtual Idol",
|
"description": "Hatsune Miku \u2014 Virtual Idol",
|
||||||
"model": "res://assets/characters/skins/miku.glb",
|
"model": "res://assets/characters/skins/miku.glb",
|
||||||
"unlocked": true
|
"unlocked": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "taila",
|
||||||
|
"name": "Taila",
|
||||||
|
"description": "Original anime character by Partaevil (CC-BY)",
|
||||||
|
"model": "res://assets/characters/skins/taila.glb",
|
||||||
|
"unlocked": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 384 KiB |
@@ -0,0 +1 @@
|
|||||||
|
uid://dc7dqp671fvya
|
||||||
@@ -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()
|
||||||