Etapa 1 inteira (logica com 100% de cobertura, cena 3D, toque, sons, bancada de desempenho, e2e) e a base da etapa 2 (mapa novo no Tiled, texturas, imagens dos itens, sons conferidos, golpe recortado e medido). Pagina /jogo fora do menu e do sitemap ate a medicao no celular.
334 lines
15 KiB
Python
334 lines
15 KiB
Python
"""Prova do MPFB2 (MakeHuman para Blender) por linha de comando: homem adulto magro, vestido, com esqueleto "mixamo".
|
|
|
|
Mede triângulos e ossos, passa os 6 clipes do zumbi atual (3d/fontes/zumbi, ossos mixamorig:*) para o esqueleto
|
|
novo, exporta 3d/out/jogo/humano.glb e fotografa com a luz de zumbi-frente.png e zumbi-perto.png (3d/jogo.py).
|
|
|
|
Rodar SEMPRE com a pasta de usuário separada (a extensão e os pacotes ficam lá, fora do Blender do Evandro):
|
|
BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b -P 3d/humano.py
|
|
A extensão entra uma vez com (ver 3d/FERRAMENTAS.md):
|
|
BLENDER_USER_RESOURCES=… blender -b --factory-startup --command extension install-file -r user_default -e <zip>
|
|
"""
|
|
|
|
import hashlib
|
|
import os
|
|
import sys
|
|
import urllib.request
|
|
|
|
import bpy
|
|
from mathutils import Vector
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from blocos import RAIZ, SAIDA, falhar, ligar_cycles, sair # noqa: E402
|
|
|
|
try:
|
|
from bl_ext.user_default.mpfb.services import AssetService, HumanService, LocationService
|
|
except ImportError:
|
|
falhar("MPFB não carregou: rode com BLENDER_USER_RESOURCES=3d/ferramentas/blender-user e instale a extensão")
|
|
|
|
BAIXADOS = os.path.join(RAIZ, "3d", "ferramentas", "baixados")
|
|
CLIPES_FBX = os.path.join(RAIZ, "3d", "fontes", "zumbi") # baixados e extraídos por 3d/jogo.py
|
|
RENDERS = os.path.join(SAIDA, "jogo")
|
|
GLB = os.path.join(RENDERS, "humano.glb")
|
|
|
|
# pacote -> sha256 do zip (só pacotes CC0; https://static.makehumancommunity.org/assets/assetpacks/index.html)
|
|
PACOTES = {
|
|
"makehuman_system_assets": "b542127a8e25547c7c29c19f2d1d2adb9a664c80396ecd694095dbc8028a0107",
|
|
"shirts01": "a5a723b0e84a109bb190fcfeac7f1de4138d875da3e30fe5b3340eac9f38bcd3",
|
|
"pants01": "e4e0ec60db34f279be291a83cfd7b342a7c5cf09bb7676682a5f39f4f6ac4ad9",
|
|
"shoes01": "ded3f70428505eabbf1f6d7b5f61196a7366ef20757103d276ad0ed336c35ada",
|
|
}
|
|
# files2 (o link da página) entrega a ~10 KB/s; files é o mesmo conteúdo (mesmo tamanho e sha256)
|
|
URL_PACOTE = "https://files.makehumancommunity.org/asset_packs/{0}/{0}_cc0.zip"
|
|
|
|
# homem adulto (25 anos), magro, altura média; a raça fica no padrão do MPFB (1/3 de cada)
|
|
CORPO = {"gender": 1.0, "age": 0.5, "muscle": 0.5, "weight": 0.3, "proportions": 0.5, "height": 0.5,
|
|
"cupsize": 0.5, "firmness": 0.5, "race": {"asian": 0.33, "caucasian": 0.33, "african": 0.33}}
|
|
PELE = "skins/young_caucasian_male/young_caucasian_male.mhmat"
|
|
# (tipo do MPFB, arquivo na pasta de dados); escolhidos com cabeçalho CC0 nos .mhclo e .obj (cargo_pants.obj é AGPL)
|
|
PECAS = [
|
|
("Eyes", "eyes/low-poly/low-poly.mhclo"),
|
|
("Eyebrows", "eyebrows/eyebrow001/eyebrow001.mhclo"),
|
|
("Hair", "hair/short02/short02.mhclo"),
|
|
("Clothes", "clothes/namuhekam_male_polo_shirt/namuhekam_male_polo_shirt.mhclo"),
|
|
("Clothes", "clothes/toigo_wool_pants/toigo_wool_pants.mhclo"),
|
|
("Clothes", "clothes/toigo_ankle_boots_male/toigo_ankle_boots_male.mhclo"), # o mj_cloth_shoes é sapatilha
|
|
]
|
|
ESQUELETO = "mixamo"
|
|
MAX_TRIS = 12_000
|
|
QUADRIL = "mixamorig:Hips"
|
|
PERNA = ["UpLeg", "Leg", "Foot"]
|
|
CLIPES = {
|
|
"parado": "Zombie Idle.fbx",
|
|
"andar": "Zombie Walk.fbx",
|
|
"atacar": "Zombie Attack.fbx",
|
|
"morrer": "Zombie Dying.fbx",
|
|
"correr": "Zombie Running.fbx",
|
|
"reacao": "Zombie Reaction Hit.fbx",
|
|
}
|
|
|
|
|
|
def sha256(caminho):
|
|
h = hashlib.sha256()
|
|
with open(caminho, "rb") as f:
|
|
for bloco in iter(lambda: f.read(1 << 20), b""):
|
|
h.update(bloco)
|
|
return h.hexdigest()
|
|
|
|
|
|
def instalar_pacotes():
|
|
dados = LocationService.get_user_data()
|
|
for nome, soma in PACOTES.items():
|
|
if os.path.exists(os.path.join(dados, "packs", nome + ".json")):
|
|
continue
|
|
zip_ = os.path.join(BAIXADOS, nome + "_cc0.zip")
|
|
if not os.path.exists(zip_):
|
|
os.makedirs(BAIXADOS, exist_ok=True)
|
|
urllib.request.urlretrieve(URL_PACOTE.format(nome), zip_)
|
|
if sha256(zip_) != soma:
|
|
falhar(f"{zip_}: sha256 não confere")
|
|
erro = AssetService.fix_and_extract_asset_pack_zip(zip_, dados)
|
|
if erro:
|
|
falhar(f"{nome}: {erro}")
|
|
print(f"pacote {nome} instalado em {dados}", flush=True)
|
|
AssetService.update_all_asset_lists()
|
|
return dados
|
|
|
|
|
|
def triangulos(obj):
|
|
malha = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh()
|
|
malha.calc_loop_triangles()
|
|
n = len(malha.loop_triangles)
|
|
obj.to_mesh_clear()
|
|
return n
|
|
|
|
|
|
def criar(dados):
|
|
for obj in list(bpy.data.objects):
|
|
bpy.data.objects.remove(obj)
|
|
corpo = HumanService.create_human(macro_detail_dict=CORPO)
|
|
esqueleto = HumanService.add_builtin_rig(corpo, ESQUELETO)
|
|
HumanService.set_character_skin(os.path.join(dados, PELE), corpo, skin_type="MAKESKIN", material_instances=False)
|
|
pecas = []
|
|
for tipo, arquivo in PECAS:
|
|
caminho = os.path.join(dados, arquivo)
|
|
if not os.path.exists(caminho):
|
|
falhar(f"falta {caminho}")
|
|
pecas.append(HumanService.add_mhclo_asset(caminho, corpo, asset_type=tipo, subdiv_levels=0))
|
|
return esqueleto, corpo, pecas
|
|
|
|
|
|
def aplicar_modificadores(obj):
|
|
"""Grava na malha as máscaras (ajudantes e faces do corpo sob a roupa) e tira subdivisões; só fica o Armature."""
|
|
bpy.context.view_layer.objects.active = obj
|
|
if obj.data.shape_keys:
|
|
obj.shape_key_add(name="_mistura", from_mix=True)
|
|
for chave in list(obj.data.shape_keys.key_blocks)[:-1]:
|
|
obj.shape_key_remove(chave)
|
|
obj.shape_key_remove(obj.data.shape_keys.key_blocks[0])
|
|
for mod in list(obj.modifiers):
|
|
if mod.type == "ARMATURE":
|
|
continue
|
|
if mod.type == "SUBSURF":
|
|
obj.modifiers.remove(mod)
|
|
continue
|
|
with bpy.context.temp_override(object=obj, active_object=obj):
|
|
bpy.ops.object.modifier_apply(modifier=mod.name)
|
|
|
|
|
|
def reduzir(malhas, alvo):
|
|
"""Colapso de arestas com a mesma razão em todas as malhas (antes do Armature: pesos interpolados)."""
|
|
total = sum(triangulos(m) for m in malhas)
|
|
razao = alvo / total
|
|
for _ in range(4):
|
|
for m in malhas:
|
|
mod = m.modifiers.new("reduzir", "DECIMATE")
|
|
mod.ratio = min(1.0, razao)
|
|
mod.use_collapse_triangulate = True
|
|
m.modifiers.move(len(m.modifiers) - 1, 0)
|
|
with bpy.context.temp_override(object=m, active_object=m):
|
|
bpy.ops.object.modifier_apply(modifier=mod.name)
|
|
total = sum(triangulos(m) for m in malhas)
|
|
if total <= MAX_TRIS:
|
|
return total
|
|
razao = alvo / total
|
|
falhar(f"redução parou em {total} triângulos")
|
|
|
|
|
|
def comprimento_perna(esqueleto):
|
|
ossos = esqueleto.pose.bones
|
|
juntas = [esqueleto.matrix_world @ ossos["mixamorig:Left" + n].head for n in PERNA]
|
|
return sum((b - a).length for a, b in zip(juntas, juntas[1:]))
|
|
|
|
|
|
def contato(esqueleto):
|
|
ossos = esqueleto.pose.bones
|
|
pes = [f"mixamorig:{lado}{osso}" for lado in ("Left", "Right") for osso in ("Foot", "ToeBase")]
|
|
return min((esqueleto.matrix_world @ ossos[n].head).z - ossos[n].bone.head_local.z for n in pes)
|
|
|
|
|
|
def quadro_final(acao):
|
|
fim = int(acao.frame_range[0])
|
|
for curva in acao.layers[0].strips[0].channelbags[0].fcurves:
|
|
pontos = curva.keyframe_points
|
|
for anterior, ponto in zip(pontos, pontos[1:]):
|
|
if abs(ponto.co[1] - anterior.co[1]) > 1e-4:
|
|
fim = max(fim, round(ponto.co[0]))
|
|
return fim
|
|
|
|
|
|
def alinhar_rolagem(esqueleto, fonte):
|
|
"""Gira cada osso do MPFB no próprio eixo até o Z dele apontar como o do osso do Mixamo no repouso.
|
|
|
|
A cópia de rotação é em espaço de mundo: com a rolagem igual, o osso novo fica com a direção e a torção do
|
|
osso do clipe, mesmo com o repouso diferente (MPFB em A, Mixamo em T). A malha não muda: só o eixo do osso gira.
|
|
"""
|
|
bpy.context.view_layer.objects.active = esqueleto
|
|
bpy.ops.object.mode_set(mode="EDIT")
|
|
para_local = esqueleto.matrix_world.inverted().to_3x3()
|
|
for osso in esqueleto.data.edit_bones:
|
|
if osso.name in fonte.data.bones:
|
|
z = (fonte.matrix_world.to_3x3() @ fonte.data.bones[osso.name].matrix_local.to_3x3()).col[2]
|
|
osso.align_roll(para_local @ z)
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
|
|
|
|
def transferir(esqueleto, nome_clipe, arquivo, alinhar):
|
|
"""Como em 3d/jogo.py: rotação de mundo de todos os ossos e posição só do quadril, escalada pela perna e com o pé
|
|
de apoio no chão no primeiro quadro; assa a ação."""
|
|
antes = set(bpy.data.objects)
|
|
bpy.ops.import_scene.fbx(filepath=os.path.join(CLIPES_FBX, arquivo))
|
|
novos = [o for o in bpy.data.objects if o not in antes]
|
|
fonte = next(o for o in novos if o.type == "ARMATURE")
|
|
if alinhar:
|
|
alinhar_rolagem(esqueleto, fonte)
|
|
acao_fonte = fonte.animation_data.action
|
|
inicio, fim = int(acao_fonte.frame_range[0]), quadro_final(acao_fonte)
|
|
bpy.context.scene.frame_set(inicio)
|
|
fonte.scale *= comprimento_perna(esqueleto) / comprimento_perna(fonte)
|
|
sem_par = []
|
|
for pb in esqueleto.pose.bones:
|
|
if pb.name not in fonte.pose.bones:
|
|
sem_par.append(pb.name)
|
|
continue
|
|
c = pb.constraints.new("COPY_ROTATION")
|
|
c.target, c.subtarget = fonte, pb.name
|
|
if pb.name == QUADRIL:
|
|
c = pb.constraints.new("COPY_LOCATION")
|
|
c.target, c.subtarget = fonte, pb.name
|
|
if sem_par:
|
|
falhar(f"ossos do MPFB sem par no clipe: {sem_par}")
|
|
bpy.context.scene.frame_set(inicio)
|
|
fonte.location.z -= contato(esqueleto)
|
|
bpy.context.view_layer.objects.active = esqueleto
|
|
bpy.ops.object.mode_set(mode="POSE")
|
|
bpy.ops.pose.select_all(action="SELECT")
|
|
bpy.ops.nla.bake(frame_start=inicio, frame_end=fim, only_selected=True, visual_keying=True,
|
|
clear_constraints=True, use_current_action=False, bake_types={"POSE"},
|
|
channel_types={"LOCATION", "ROTATION"})
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
acao = esqueleto.animation_data.action
|
|
acao.name = nome_clipe
|
|
acao.use_fake_user = True
|
|
curvas = acao.layers[0].strips[0].channelbags[0].fcurves
|
|
for c in [c for c in curvas if c.data_path.endswith(".location") and f'["{QUADRIL}"]' not in c.data_path]:
|
|
curvas.remove(c)
|
|
ossos_fonte = fonte.data.bones.keys()
|
|
for o in novos:
|
|
bpy.data.objects.remove(o)
|
|
bpy.data.actions.remove(acao_fonte)
|
|
return ossos_fonte, inicio, fim
|
|
|
|
|
|
def exportar(esqueleto, malhas):
|
|
os.makedirs(RENDERS, exist_ok=True)
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
for o in [esqueleto, *malhas]:
|
|
o.select_set(True)
|
|
esqueleto.animation_data.action = None
|
|
for acao in [a for a in bpy.data.actions if a.name not in CLIPES]:
|
|
bpy.data.actions.remove(acao)
|
|
bpy.context.scene.frame_set(1)
|
|
bpy.ops.export_scene.gltf(filepath=GLB, export_format="GLB", use_selection=True, export_yup=True,
|
|
export_apply=False, export_cameras=False, export_lights=False, export_morph=False,
|
|
export_tangents=False, export_image_format="JPEG", export_jpeg_quality=80,
|
|
export_animations=True, export_animation_mode="ACTIONS",
|
|
export_anim_slide_to_zero=True, export_force_sampling=False,
|
|
export_optimize_animation_size=True)
|
|
|
|
|
|
def renderizar():
|
|
"""Reabre o GLB e fotografa com a luz de renderizar() em 3d/jogo.py: parado no quadro 30 (frente e perto) e
|
|
andando no meio do clipe."""
|
|
bpy.ops.wm.read_homefile(use_empty=True)
|
|
cena = bpy.context.scene
|
|
bpy.ops.import_scene.gltf(filepath=GLB)
|
|
esqueleto = next(o for o in cena.objects if o.type == "ARMATURE")
|
|
malhas = [o for o in cena.objects if o.type == "MESH" and o.parent == esqueleto] # sem a esfera do importador
|
|
pontos = [o.matrix_world @ v.co for o in malhas for v in o.data.vertices]
|
|
pe, topo = min(p.z for p in pontos), max(p.z for p in pontos)
|
|
mundo = bpy.data.worlds.new("neutro")
|
|
mundo.use_nodes = True
|
|
mundo.node_tree.nodes["Background"].inputs["Color"].default_value = (0.5, 0.5, 0.5, 1)
|
|
mundo.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.6
|
|
cena.world = mundo
|
|
luz = bpy.data.objects.new("principal", bpy.data.lights.new("principal", "AREA"))
|
|
luz.data.energy, luz.data.size = 300, 2
|
|
luz.location = (1.5, -3, 3)
|
|
luz.rotation_euler = (Vector((0, 0, 1.2)) - luz.location).to_track_quat("-Z", "Y").to_euler()
|
|
cena.collection.objects.link(luz)
|
|
camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
|
|
cena.collection.objects.link(camera)
|
|
cena.camera = camera
|
|
ligar_cycles(cena)
|
|
acoes = {a.name.split("_")[0]: a for a in bpy.data.actions}
|
|
for nome, clipe, quadro, alvo, distancia in (("frente", "parado", 30, 0.9, 3.2), ("perto", "parado", 30, 1.4, 1.5),
|
|
("andando", "andar", None, 0.9, 3.2)):
|
|
acao = next(a for n, a in acoes.items() if n.startswith(clipe))
|
|
esqueleto.animation_data.action = acao
|
|
cena.frame_set(quadro or int(sum(acao.frame_range) / 2))
|
|
camera.location = (0.3 * distancia / 4, -distancia, alvo + 0.1)
|
|
camera.rotation_euler = (Vector((0, 0, alvo)) - camera.location).to_track_quat("-Z", "Y").to_euler()
|
|
sair(cena, os.path.join(RENDERS, f"humano-{nome}.png"), (800, 800), "PNG", "RGB", 64, False)
|
|
return pe, topo
|
|
|
|
|
|
def main():
|
|
dados = instalar_pacotes()
|
|
esqueleto, corpo, pecas = criar(dados)
|
|
roupas = [p for p, (tipo, _) in zip(pecas, PECAS) if tipo == "Clothes"]
|
|
t_corpo_visivel = triangulos(corpo)
|
|
for mod in corpo.modifiers: # mede o corpo sem as máscaras de roupa ("Delete" do MPFB)
|
|
if mod.type == "MASK" and mod.vertex_group != "body":
|
|
mod.show_viewport = False
|
|
t_corpo = triangulos(corpo)
|
|
for mod in corpo.modifiers:
|
|
mod.show_viewport = True
|
|
t_roupa = sum(triangulos(r) for r in roupas)
|
|
t_resto = sum(triangulos(p) for p in pecas if p not in roupas)
|
|
malhas = [corpo, *pecas]
|
|
t_total = t_corpo + t_roupa + t_resto
|
|
print(f"triângulos: corpo {t_corpo} (sem as faces sob a roupa: {t_corpo_visivel}), roupa {t_roupa}, "
|
|
f"olhos+sobrancelha+cabelo {t_resto}, total {t_total}", flush=True)
|
|
for m in malhas:
|
|
aplicar_modificadores(m)
|
|
t_escondido = sum(triangulos(m) for m in malhas)
|
|
tris = reduzir(malhas, MAX_TRIS * 0.97)
|
|
print(f"triângulos: total sem as faces escondidas {t_escondido}, reduzido {tris}", flush=True)
|
|
for m in malhas:
|
|
print(f" {m.name}: {triangulos(m)}", flush=True)
|
|
ossos = [b.name for b in esqueleto.data.bones]
|
|
print(f"ossos ({len(ossos)}): {' '.join(ossos)}", flush=True)
|
|
for i, (nome, arquivo) in enumerate(CLIPES.items()):
|
|
ossos_clipe, inicio, fim = transferir(esqueleto, nome, arquivo, alinhar=i == 0)
|
|
print(f"clipe {nome} <- {arquivo} quadros {inicio}-{fim}; ossos do clipe sem par no MPFB: "
|
|
f"{sorted(set(ossos_clipe) - set(ossos))}", flush=True)
|
|
exportar(esqueleto, malhas)
|
|
print(f"humano.glb {os.path.getsize(GLB)} bytes", flush=True)
|
|
pe, topo = renderizar()
|
|
print(f"repouso: pés em {pe:.3f} m, altura {topo - pe:.3f} m", flush=True)
|
|
print(f"OK humano tris={tris} ossos={len(ossos)}", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|