"""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 """ 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()