Files
AIGame/Tools/model_tools/bl_export_animation_lab.py

110 lines
5.6 KiB
Python

"""Export exact comparison mesh/bind/pose data for Pipeline-authored Unity assets.
Blender Z-up -> Unity Y-up is (x,z,y), so anatomical -Y faces Unity -Z.
All asset creation is subsequently performed inside Unity through Pipeline.
"""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from math import pi
from mathutils import Matrix
root=Path(sys.argv[sys.argv.index('--')+1])
folder=root/'Tools/output/campguide_studio/rig_mixamo41'
base=root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
out=folder/'unity_review'
out.mkdir(exist_ok=True)
C=Matrix(((1,0,0,0),(0,0,1,0),(0,1,0,0),(0,0,0,1)))
def trs(m):
p,q,s=m.decompose()
return list(p)+[q.x,q.y,q.z,q.w]+list(s)
result={'coordinateSystem':'Unity Y-up, -Z front; positions in metres','variants':[],'bones':[],'clips':[]}
names=None
for label,path in [('Mixamo',base/'CampGuideStudio.fbx'),('TokenRigRaw',folder/'tokenrig_mixamo41.blend'),('TokenRigLocal',folder/'tokenrig_mixamo41_local.blend')]:
bpy.ops.wm.read_factory_settings(use_empty=True)
if path.suffix=='.blend':bpy.ops.wm.open_mainfile(filepath=str(path))
else:bpy.ops.import_scene.fbx(filepath=str(path))
arm=next(o for o in bpy.data.objects if o.type=='ARMATURE')
mesh=next(o for o in bpy.data.objects if o.type=='MESH')
pts=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices])
normalize=Matrix.Rotation(pi/2,4,'X') if np.argmax(np.ptp(pts,axis=0))==1 else Matrix.Identity(4)
world={b.name:C@normalize@arm.matrix_world@b.matrix_local@C for b in arm.data.bones}
if names is None:
names=[b.name for b in arm.data.bones]
for b in arm.data.bones:
parent=b.parent.name if b.parent else None
local=world[parent].inverted()@world[b.name] if parent else world[b.name]
result['bones'].append({'name':b.name,'parent':names.index(parent) if parent else -1,
'rest':trs(local),'bindpose':[list(row) for row in world[b.name].inverted()]})
assert set(names)==set(arm.data.bones.keys())
weights=[]
for v in mesh.data.vertices:
w={mesh.vertex_groups[g.group].name:g.weight for g in v.groups if g.weight>1e-8}
total=sum(w.values())
weights.append([[names.index(n),value/total] for n,value in sorted(w.items(),key=lambda x:-x[1])])
transform=C@normalize@mesh.matrix_world
normal_transform=transform.to_3x3().inverted().transposed()
mesh.data.calc_loop_triangles()
uv_layer=mesh.data.uv_layers.active
vertices=[];normals=[];uv=[];influences=[];triangles=[];lookup={};first={}
for triangle in mesh.data.loop_triangles:
ids=[]
for loop_index in triangle.loops:
loop=mesh.data.loops[loop_index]
vi=loop.vertex_index
n=normal_transform@mesh.data.corner_normals[loop_index].vector
n.normalize()
tex=list(uv_layer.data[loop_index].uv) if uv_layer else [0,0]
key=(vi,*[round(x,6) for x in tex],*[round(x,6) for x in n])
if key not in lookup:
index=len(vertices);lookup[key]=index;first.setdefault(vi,index)
vertices.append(list(transform@mesh.data.vertices[vi].co));normals.append(list(n));uv.append(tex);influences.append(weights[vi])
ids.append(lookup[key])
triangles.extend([ids[0],ids[2],ids[1]])
# A reflection reverses the winding; the reversed triangle order above is
# checked visually in Unity and does not alter the comparison edge lengths.
raw=np.array([normalize@mesh.matrix_world@v.co for v in mesh.data.vertices])
height=float(np.ptp(raw[:,2]));z=(raw[:,2]-raw[:,2].min())/height
hand=(np.abs(raw[:,0])>height*.255)&(z>.55)&(z<.67)
neck=(z>.67)&(z<.78)&(np.abs(raw[:,0])<height*.12)
head=z>.78
edges=[]
for edge in mesh.data.edges:
a,b=edge.vertices
length=float(np.linalg.norm(raw[a]-raw[b]))
if length<=height*.002:continue
mask=1|(2 if hand[a] and hand[b] else 0)|(4 if neck[a] and neck[b] else 0)|(8 if head[a] and head[b] else 0)
edges.append([first[a],first[b],length,mask])
result['variants'].append({'name':label,'vertices':vertices,'normals':normals,'uv':uv,'triangles':triangles,
'weights':influences,'auditEdges':edges,'originalVertices':len(mesh.data.vertices),'trianglesCount':len(triangles)//3})
print('MESH',label,len(vertices),len(triangles)//3)
for label in ['Walking','Breathing Idle','Happy Idle']:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(base/('CampGuideStudio@'+label+'.fbx')))
arm=next(o for o in bpy.data.objects if o.type=='ARMATURE')
first,last=map(int,arm.animation_data.action.frame_range)
samples=[];previous={}
for frame in range(first,last+1):
bpy.context.scene.frame_set(frame);bpy.context.view_layer.update()
world={n:C@arm.matrix_world@arm.pose.bones[n].matrix@C for n in names}
sample=[]
for i,n in enumerate(names):
parent=result['bones'][i]['parent']
local=world[names[parent]].inverted()@world[n] if parent>=0 else world[n]
values=trs(local)
if n in previous and sum(a*b for a,b in zip(previous[n],values[3:7]))<0:
values[3:7]=[-x for x in values[3:7]]
previous[n]=values[3:7]
sample.append(values)
samples.append(sample)
result['clips'].append({'name':label.replace(' ','_'),'fps':30,'samples':samples})
print('CLIP',label,len(samples))
(out/'review_data.json').write_text(json.dumps(result,separators=(',',':')),encoding='utf-8')
print('EXPORTED',out/'review_data.json')