"""Zumbi do jogo em /jogo: baixa o "Male City Zombie" (Rikindle3D, CC0), reduz e exporta public/jogo/zumbi.glb. Rodar da raiz do repositório: blender -b -P 3d/jogo.py Baixa o .7z para 3d/fontes/ (se faltar), confere o sha256, extrai com o 7z do sistema, reduz a malha para no máximo 12 mil triângulos, repinta pele e roupa por assado com as texturas de 3d/texturas/ (a textura original é uma crosta igual em tudo), passa as animações do Mixamo para o esqueleto do modelo (no lugar) e exporta o GLB (Y para cima, metros, pés na origem, de frente para +Z) com a roupa A; as roupas B e C saem em public/jogo/roupa-b.jpg e roupa-c.jpg (mesmas UVs). O `atacar` é recortado para um golpe só (preparo, impacto, volta, fechando em laço) e o instante do impacto vai para lib/jogo/golpe.json. Grava public/jogo/CREDITOS.json, a capa da página (public/jogo/capa.jpg) e renderiza 3d/out/jogo/zumbi-frente.png, zumbi-perto.png, zumbi-variacoes.png e golpe.png para avaliar a aparência. """ import hashlib import json import os import struct import subprocess import sys import tempfile import urllib.request import bmesh import bpy import numpy as np from mathutils import Vector from mathutils.bvhtree import BVHTree sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from blocos import RAIZ, SAIDA, falhar, ligar_cycles, sair # noqa: E402 FONTES = os.path.join(RAIZ, "3d", "fontes") PACOTE = os.path.join(FONTES, "Male City Zombie.7z") EXTRAIDO = os.path.join(FONTES, "zumbi") PUBLICO = os.path.join(RAIZ, "public", "jogo") GLB = os.path.join(PUBLICO, "zumbi.glb") CREDITOS = os.path.join(PUBLICO, "CREDITOS.json") CAPA = os.path.join(PUBLICO, "capa.jpg") RENDERS = os.path.join(SAIDA, "jogo") GOLPE = os.path.join(RAIZ, "lib", "jogo", "golpe.json") PAGINA = "https://opengameart.org/content/male-city-zombie-ready-for-use-in-game-engines" URL = "https://opengameart.org/sites/default/files/Male%20City%20Zombie.7z" SHA256 = "2858c0c4968740b372108afc64ba009ef4d0e0425edbb5dc3dc50d46f4d0a661" MAX_TRIS, MAX_BYTES, LADO_TEXTURA, QUALIDADE_JPEG = 12_000, 1_500_000, 1024, 80 TOLERANCIA_CHAVE = 2e-3 # componente de quatérnio (~0,2°) ou metro no quadril ALTURA = (1.7, 1.9) OSSO_MODELO, OSSO_ANIM = "CityDeadOutfit:", "mixamorig:" QUADRIL = OSSO_MODELO + "Hips" PERNA = ["UpLeg", "Leg", "Foot"] # da coxa ao tornozelo: mede a perna para ajustar a altura do quadril DURACAO_GOLPE = 1.4 # s: a lógica toca o golpe em 0,8 s; recorte mais longo encurta preparo e volta MISTURA = (4, 5, 6) # quadros do fim do recorte que voltam à pose do primeiro para o laço fechar # clipe do jogo <- arquivo do pacote; ficam de fora Walking, Zombie Attack2/3, Zombie Idle2 e Zombie Scream 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", } MATERIAIS = { # material do FBX -> nome no GLB, aspereza e mapa de normal original (os olhos não têm) "City_Infected_Man_Body1": ("corpo", 0.7, "InfectedCityMan_City_Infected_Man_Body1_Normal.png"), "City_Man_Infected_Outfit1": ("roupa", 0.9, "InfectedCityMan_City_Man_Infected_Outfit1_Normal.png"), "defaultPolygonShader1": ("olhos", 0.4, None), } FORCA_NORMAL = 0.3 # o normal original a 1,0 é a "crosta"; fraco, só marca dobras e costuras TEXTURAS_JOGO = os.path.join(RAIZ, "3d", "texturas") LADO = {"corpo": LADO_TEXTURA, "roupa": LADO_TEXTURA, "olhos": 256} PECAS = ["pele", "camisa", "calca", "olho", "dente", "gengiva"] ESCALA_POSICAO = 0.25 # posição de objeto (|x| < 1, z < 2 m) codificada em 0..1 no assado ROUPAS = { # cor média da camisa e da calça (sRGB) por variação; a "a" vai no GLB, as outras em JPEG ao lado "a": ((0.80, 0.77, 0.69), (0.20, 0.20, 0.21)), # branca encardida, chumbo "b": ((0.62, 0.74, 0.86), (0.55, 0.48, 0.34)), # azul-clara, cáqui "c": ((0.27, 0.31, 0.21), (0.07, 0.07, 0.075)), # verde-musgo, preta } MAX_BYTES_JPEG = 220_000 # roupas B e C e capa SANGUE_SECO = (0.25, 0.05, 0.035) COBERTURA_SANGUE = 1 / 3 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 obter_fontes(): os.makedirs(FONTES, exist_ok=True) if not os.path.exists(PACOTE): print(f"baixando {URL}", flush=True) urllib.request.urlretrieve(URL, PACOTE) if sha256(PACOTE) != SHA256: falhar(f"sha256 de {PACOTE} não confere; apague o arquivo e rode de novo") subprocess.run(["7z", "x", "-y", f"-o{EXTRAIDO}", PACOTE], check=True, stdout=subprocess.DEVNULL) faltando = [n for n in ["InfectedCityMan.fbx", *CLIPES.values()] if not os.path.exists(os.path.join(EXTRAIDO, n))] if faltando: falhar(f"faltam no pacote: {faltando}") def importar_fbx(nome): antes = set(bpy.data.objects) bpy.ops.import_scene.fbx(filepath=os.path.join(EXTRAIDO, nome)) return [o for o in bpy.data.objects if o not in antes] 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 preparar_modelo(): novos = importar_fbx("InfectedCityMan.fbx") for o in [o for o in novos if o.type not in {"ARMATURE", "MESH"}]: # a câmera "bottom" que veio no FBX bpy.data.objects.remove(o) esqueleto = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None) corpo = next((o for o in bpy.data.objects if o.type == "MESH"), None) if not esqueleto or not corpo or not corpo.vertex_groups: falhar("o FBX do modelo não trouxe esqueleto e malha com pesos") for acao in list(bpy.data.actions): # o "Take 001" de 2 quadros do arquivo do modelo bpy.data.actions.remove(acao) # escala 0,01 e rotação de 90° do FBX viram transformação aplicada: esqueleto e malha em metros, Z para cima for o in (esqueleto, corpo): o.select_set(True) bpy.context.view_layer.objects.active = esqueleto bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) return esqueleto, corpo def ligar_texturas(corpo): """Renomeia os materiais, liga o normal original (fraco) e põe o especular no padrão do Principled. A cor vem depois, do assado em pintar(). Com especular e tinta padrão o exportador não escreve KHR_materials_specular, que obrigaria o three.js a usar o material físico (mais pesado no celular). """ for slot in corpo.material_slots: nome, aspereza, normal = MATERIAIS[slot.material.name] slot.material.name = nome nos, links = slot.material.node_tree.nodes, slot.material.node_tree.links bsdf = nos["Principled BSDF"] for entrada, valor in ( ("Metallic", 0.0), ("Roughness", aspereza), ("IOR", 1.5), ("Specular IOR Level", 0.5), ("Specular Tint", (1, 1, 1, 1)), ("Emission Strength", 0.0), ): for link in bsdf.inputs[entrada].links: links.remove(link) bsdf.inputs[entrada].default_value = valor if not normal: continue imagem = nos.new("ShaderNodeTexImage") imagem.image = carregar(normal, "Non-Color") mapa = next((n for n in nos if n.type == "NORMAL_MAP"), None) or nos.new("ShaderNodeNormalMap") mapa.inputs["Strength"].default_value = FORCA_NORMAL links.new(imagem.outputs["Color"], mapa.inputs["Color"]) links.new(mapa.outputs["Normal"], bsdf.inputs["Normal"]) def carregar(nome, espaco): imagem = bpy.data.images.load(os.path.join(EXTRAIDO, nome)) imagem.colorspace_settings.name = espaco if max(imagem.size) > LADO_TEXTURA: escala = LADO_TEXTURA / max(imagem.size) imagem.scale(round(imagem.size[0] * escala), round(imagem.size[1] * escala)) return imagem def afundar_pele(corpo, folga=0.012): """Empurra para dentro a pele que fura a roupa (ombros, manga, calça; piora com a redução): todo vértice do corpo com roupa por cima, num raio de 5 cm ao longo da normal, fica pelo menos `folga` metros abaixo dela. A pele fora da roupa (raio não acerta) não se mexe, então golas e barras não ganham degrau. """ bm = bmesh.new() bm.from_mesh(corpo.data) roupa = [s.material.name for s in corpo.material_slots].index("roupa") so_roupa = bm.copy() bmesh.ops.delete(so_roupa, geom=[f for f in so_roupa.faces if f.material_index != roupa], context="FACES") arvore = BVHTree.FromBMesh(so_roupa) so_roupa.free() corpo_idx = [s.material.name for s in corpo.material_slots].index("corpo") movidos = 0 for v in bm.verts: if not v.link_faces or v.link_faces[0].material_index != corpo_idx: continue inicio = v.co - v.normal * 0.03 # parte de dentro: o vértice que já furou também acha a roupa acerto = arvore.ray_cast(inicio, v.normal, 0.08)[0] if acerto is None: continue distancia = (acerto - v.co).dot(v.normal) if distancia < folga: v.co -= v.normal * (folga - distancia) movidos += 1 bm.to_mesh(corpo.data) bm.free() return movidos def marcar_pecas(corpo, esqueleto): """Separa as partes soltas da malha e grava a peça de cada vértice no atributo "peca" ((código + 1) / 8). Roupa: camisa e calça são partes soltas (a barra da camisa cobre o cós, então a altura não separa): acima do quadril é camisa. O material dos olhos também tem dentes e gengivas: olhos são as partes mais altas, gengiva o que tem fundo (mais de 2 cm em Y), o resto é dente. Devolve as medidas usadas para posicionar sangue e sujeira. """ bm = bmesh.new() bm.from_mesh(corpo.data) bm.verts.ensure_lookup_table() nomes = [s.material.name for s in corpo.material_slots] partes, visto = [], set() for v in bm.verts: if v.index in visto or not v.link_faces: continue visto.add(v.index) pilha, parte = [v], [] while pilha: u = pilha.pop() parte.append(u.index) for aresta in u.link_edges: w = aresta.other_vert(u) if w.index not in visto: visto.add(w.index) pilha.append(w) co = np.array([bm.verts[i].co for i in parte]) partes.append((parte, nomes[v.link_faces[0].material_index], co)) bm.free() quadril = esqueleto.data.bones[QUADRIL].head_local.z topo_olhos = max(co[:, 2].mean() for _, mat, co in partes if mat == "olhos") codigo = np.zeros(len(corpo.data.vertices)) geo = {"olhos": [], "boca": [], "camisa": [], "calca": []} for parte, mat, co in partes: if mat == "roupa": peca = "camisa" if co[:, 2].mean() > quadril else "calca" elif mat == "olhos": if co[:, 2].mean() > topo_olhos - 0.03: peca = "olho" else: peca = "gengiva" if np.ptp(co[:, 1]) > 0.02 else "dente" else: peca = "pele" codigo[parte] = (PECAS.index(peca) + 1) / 8 if peca == "olho": geo["olhos"].append(co.mean(0)) elif peca in ("dente", "gengiva"): geo["boca"].append(co.mean(0)) elif peca in ("camisa", "calca"): geo[peca].append(co) atributo = corpo.data.color_attributes.new("peca", "FLOAT_COLOR", "POINT") atributo.data.foreach_set("color", np.repeat(codigo, 4).astype(np.float32)) camisa, calca = np.concatenate(geo.pop("camisa")), np.concatenate(geo.pop("calca")) geo["boca"] = np.mean(geo["boca"], axis=0) geo["punhos"] = camisa[:, 0].min(), camisa[:, 0].max() # uma manga é curta e a outra vai até o antebraço geo["barras"] = camisa[:, 2].min(), calca[:, 2].min() geo["ossos"] = {b.name.removeprefix(OSSO_MODELO): np.array(b.head_local) for b in esqueleto.data.bones} return geo def assar(corpo): """Assa em cada material, nas UVs da malha reduzida e na pose de repouso (braços abertos), quatro mapas por texel: posição e normal de objeto, peça e oclusão (raio de 8 cm: olhos, boca, dedos, golas). Só os texels usados voltam. """ cena = bpy.context.scene ligar_cycles(cena) deformar = next(m for m in corpo.modifiers if m.type == "ARMATURE") deformar.show_render = False bpy.ops.object.select_all(action="DESELECT") corpo.select_set(True) bpy.context.view_layer.objects.active = corpo mapas = {s.material.name: {} for s in corpo.material_slots} for mapa, amostras in (("peca", 1), ("pos", 1), ("normal", 1), ("ao", 32)): cena.cycles.samples = amostras temporarios = [] for slot in corpo.material_slots: mat = slot.material nos, links = mat.node_tree.nodes, mat.node_tree.links emissao = nos.new("ShaderNodeEmission") if mapa == "peca": fonte = nos.new("ShaderNodeAttribute") fonte.attribute_name = "peca" saida = fonte.outputs["Color"] elif mapa == "ao": fonte = nos.new("ShaderNodeAmbientOcclusion") fonte.inputs["Distance"].default_value = 0.08 saida = fonte.outputs["AO"] else: geometria = nos.new("ShaderNodeNewGeometry") fonte = nos.new("ShaderNodeVectorMath") fonte.operation = "MULTIPLY_ADD" escala = ESCALA_POSICAO if mapa == "pos" else 0.5 fonte.inputs[1].default_value, fonte.inputs[2].default_value = (escala,) * 3, (0.5,) * 3 links.new(geometria.outputs["Position" if mapa == "pos" else "Normal"], fonte.inputs[0]) saida = fonte.outputs[0] temporarios.append((mat, geometria)) links.new(saida, emissao.inputs["Color"]) final = next(n for n in nos if n.type == "OUTPUT_MATERIAL") links.new(emissao.outputs["Emission"], final.inputs["Surface"]) lado = LADO[mat.name] alvo = nos.new("ShaderNodeTexImage") alvo.image = bpy.data.images.new(f"{mat.name}-{mapa}", lado, lado, float_buffer=True) alvo.image.colorspace_settings.name = "Non-Color" nos.active = alvo temporarios += [(mat, emissao), (mat, fonte), (mat, alvo)] bpy.ops.object.bake(type="EMIT", margin=8, use_clear=True) for mat, no in temporarios: if no.type == "TEX_IMAGE": lado = LADO[mat.name] pixels = np.empty(lado * lado * 4, np.float32) no.image.pixels.foreach_get(pixels) mapas[mat.name][mapa] = pixels.reshape(-1, 4)[:, :3] bpy.data.images.remove(no.image) mat.node_tree.nodes.remove(no) for slot in corpo.material_slots: nos = slot.material.node_tree.nodes final = next(n for n in nos if n.type == "OUTPUT_MATERIAL") slot.material.node_tree.links.new(nos["Principled BSDF"].outputs["BSDF"], final.inputs["Surface"]) deformar.show_render = True for nome, m in mapas.items(): m["lado"] = LADO[nome] codigo = np.rint(m["peca"][:, 0] * 8).astype(int) - 1 # -1: texel fora das ilhas (nem na margem) m["idx"] = np.flatnonzero(codigo >= 0) m["peca"] = codigo[m["idx"]] m["pos"] = (m["pos"][m["idx"]] - 0.5) / ESCALA_POSICAO n = m["normal"][m["idx"]] * 2 - 1 m["normal"] = n / np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-6) m["ao"] = m["ao"][m["idx"], 0] return mapas def ler_textura(nome, reducao): """Lê a textura e tira a média de blocos `reducao` × `reducao`: na escala da caixa um texel do mapa cobre uns 4 pixels da textura, e amostrar sem essa média vira chuvisco (era o aspecto de casca na camisa e na calça).""" imagem = bpy.data.images.load(os.path.join(TEXTURAS_JOGO, f"{nome}.jpg")) largura, altura = imagem.size pixels = np.empty(largura * altura * 4, np.float32) imagem.pixels.foreach_get(pixels) # imagem de bytes em sRGB: os valores saem como estão no arquivo bpy.data.images.remove(imagem) pixels = pixels.reshape(altura // reducao, reducao, largura // reducao, reducao, 4) return pixels.mean((1, 3))[..., :3] def borrar(a, raio): for eixo in (0, 1): for _ in range(2): a = sum(np.roll(a, k, eixo) for k in range(-raio, raio + 1)) / (2 * raio + 1) return a def amostrar(tex, u, v): """Bilinear, repetindo a textura (todas emendáveis).""" altura, largura = tex.shape[:2] x, y = u * largura - 0.5, v * altura - 0.5 x0, y0 = np.floor(x).astype(np.int64), np.floor(y).astype(np.int64) fx, fy = (x - x0)[:, None], (y - y0)[:, None] x1, y1 = (x0 + 1) % largura, (y0 + 1) % altura x0, y0 = x0 % largura, y0 % altura return (tex[y0, x0] * (1 - fx) + tex[y0, x1] * fx) * (1 - fy) + (tex[y1, x0] * (1 - fx) + tex[y1, x1] * fx) * fy def caixa(tex, m, tamanho, desvio=0.0): """Projeção de caixa: a textura cobre `tamanho` metros; cada face mistura os três planos pela normal.""" p, pesos = m["pos"] / tamanho, np.abs(m["normal"]) ** 4 pesos /= pesos.sum(1, keepdims=True) + 1e-6 cor = 0 for eixo, (a, b) in enumerate(((1, 2), (0, 2), (0, 1))): cor = cor + pesos[:, eixo, None] * amostrar(tex, p[:, a] + desvio + 0.37 * eixo, p[:, b] + 0.61 * desvio) return cor def capsula(p, a, b, raio): """Peso 1 no segmento a-b, caindo suave até 0 a `raio` metros dele.""" a, b = np.asarray(a, float), np.asarray(b, float) t = np.clip((p - a) @ (b - a) / ((b - a) @ (b - a) + 1e-9), 0, 1) f = np.clip(1 - np.linalg.norm(p - a - t[:, None] * (b - a), axis=1) / raio, 0, 1) return f * f * (3 - 2 * f) def luminancia(cor): return cor @ np.array((0.2126, 0.7152, 0.0722)) def ensanguentar(cor, m, tex, regiao, desvio): """Sangue seco: a máscara de sangue.jpg só aparece onde a região pesa; no centro dela as manchas se juntam.""" mancha, borrada, tom = caixa(tex["sangue"], m, 0.4, desvio).T sangue = np.maximum(mancha * np.clip(regiao * 2, 0, 1), np.clip((borrada * 1.4 + regiao - 1.2) * 4, 0, 1)) sangue *= regiao > 0.02 seco = np.outer(0.45 + 0.75 * tom, SANGUE_SECO) * (0.7 + 0.3 * luminancia(cor) / luminancia(cor).mean())[:, None] return cor + (seco - cor) * (0.95 * sangue)[:, None], sangue def pintar_corpo(m, tex, geo): p, o = m["pos"], geo["ossos"] cor = caixa(tex["pele"], m, 0.3) * (0.3 + 0.7 * m["ao"])[:, None] olheira = sum(capsula(p, c, c, 0.035) for c in geo["olhos"]) boca = geo["boca"] + (0, -0.025, 0) labio = capsula(p, boca - (0.025, 0, 0), boca + (0.025, 0, 0), 0.018) cor *= 1 - (np.clip(olheira, 0, 1) * 0.55 + labio * 0.4)[:, None] * (1, 1.1, 0.95) regiao = capsula(p, boca, boca - (0, -0.01, 0.07), 0.05) # boca e queixo regiao = np.maximum(regiao, 0.8 * capsula(p, boca + (0, 0.03, -0.08), boca + (0, 0.05, -0.18), 0.07)) # pescoço for lado in ("Left", "Right"): antebraco, mao = o[lado + "ForeArm"], o[lado + "Hand"] direcao = (mao - antebraco) / np.linalg.norm(mao - antebraco) regiao = np.maximum(regiao, 0.9 * capsula(p, mao, mao + 0.2 * direcao, 0.07)) regiao = np.maximum(regiao, 0.6 * capsula(p, antebraco + 0.08 * direcao, mao, 0.06)) return ensanguentar(cor, m, tex, regiao, 0.0) def pintar_roupa(m, tex, geo, variacao): p, camisa = m["pos"], (m["peca"] == PECAS.index("camisa")) tinta_camisa, tinta_calca = (np.array(c) for c in ROUPAS[variacao]) desvio = {"a": 0.0, "b": 0.31, "c": 0.67}[variacao] cor = np.where( camisa[:, None], caixa(tex["camisa"], m, 0.45, desvio) / tex["camisa_media"] * tinta_camisa, caixa(tex["calca"], m, 0.3, desvio) / tex["calca_media"] * tinta_calca, ) barra_camisa, barra_calca = geo["barras"] ruido = np.clip(luminancia(caixa(tex["pele"], m, 0.7, 0.5)) / tex["pele_media"], 0.5, 1.5) - 0.5 sujeira = np.where( camisa, 0.3 * np.clip(1 - (p[:, 2] - barra_camisa) / 0.1, 0, 1), np.clip(1 - (p[:, 2] - barra_calca) / 0.2, 0, 1) ) cor += (np.array((0.24, 0.21, 0.17)) - cor) * (0.7 * sujeira**1.5 * ruido)[:, None] cor *= (0.5 + 0.5 * m["ao"])[:, None] esquerdo, direito = ( np.clip(1 - (geo["punhos"][1] - p[:, 0]) / 0.08, 0, 1) * camisa, np.clip(1 - (p[:, 0] - geo["punhos"][0]) / 0.08, 0, 1) * camisa, ) # metros, modelo em repouso: frente em -Y, peito em z 1,2-1,45, coxa em 0,6-0,85, joelho em 0,45 if variacao == "a": # escorreu da boca pelo peito; os dois punhos regiao = np.maximum(capsula(p, (0, -0.13, 1.45), (0.03, -0.13, 1.17), 0.13) * camisa, 0.9 * np.maximum(esquerdo, direito)) elif variacao == "b": # ombro e lado esquerdos, punho direito, coxa direita regiao = np.maximum(capsula(p, (0.15, -0.11, 1.42), (0.2, -0.08, 1.22), 0.11) * camisa, 0.9 * direito) regiao = np.maximum(regiao, 0.8 * capsula(p, (-0.1, -0.11, 0.82), (-0.1, -0.1, 0.62), 0.08) * ~camisa) else: # barriga, punho esquerdo, joelho esquerdo regiao = np.maximum(capsula(p, (-0.06, -0.14, 1.13), (0.04, -0.13, 1.03), 0.11) * camisa, 0.9 * esquerdo) regiao = np.maximum(regiao, 0.8 * capsula(p, (0.09, -0.09, 0.52), (0.09, -0.08, 0.42), 0.08) * ~camisa) return ensanguentar(cor, m, tex, regiao, desvio + 0.13) def pintar_olhos(m, geo): """Esclera leitosa e íris apagada (sem pupila preta: olho morto e turvo); dentes amarelados, gengiva escura.""" p, peca = m["pos"], m["peca"] cor = np.where((peca == PECAS.index("dente"))[:, None], (0.58, 0.52, 0.38), (0.30, 0.08, 0.07)) olho = peca == PECAS.index("olho") cor[olho] = (0.78, 0.76, 0.68) for c in geo["olhos"]: d = np.hypot(p[:, 0] - c[0], p[:, 2] - c[2]) iris = np.clip((0.0075 - d) / 0.0012, 0, 1) * olho * (p[:, 1] < c[1]) anel = np.clip(1 - np.abs(d - 0.0068) / 0.001, 0, 1) tom = np.array((0.52, 0.56, 0.56)) - anel[:, None] * 0.14 cor += (tom - cor) * iris[:, None] return cor * (0.4 + 0.6 * m["ao"])[:, None] def imagem_nova(nome, m, cor): lado = m["lado"] pixels = np.empty((lado * lado, 4), np.float32) pixels[:, :3], pixels[:, 3] = cor.mean(0), 1 # fora das ilhas: cor lisa, que o JPEG comprime a quase nada pixels[m["idx"], :3] = np.clip(cor, 0, 1) imagem = bpy.data.images.new(nome, lado, lado) imagem.pixels.foreach_set(pixels.ravel()) return imagem def cobertura(corpo, mapas, sangues): """Fração da área de cada peça com sangue, sorteando 100 mil pontos por material na superfície (peso pela área).""" malha = corpo.data malha.calc_loop_triangles() n = len(malha.loop_triangles) lacos, area, material = np.empty(n * 3, np.int32), np.empty(n, np.float32), np.empty(n, np.int32) malha.loop_triangles.foreach_get("loops", lacos) malha.loop_triangles.foreach_get("area", area) malha.loop_triangles.foreach_get("material_index", material) uv = np.empty(len(malha.loops) * 2, np.float32) malha.uv_layers.active.data.foreach_get("uv", uv) uv = uv.reshape(-1, 2)[lacos.reshape(-1, 3)] sorteio, fracoes = np.random.default_rng(1), {} for i, slot in enumerate(corpo.material_slots): nome = slot.material.name if nome not in sangues: continue lado, m = LADO[nome], mapas[nome] peca, sangue = np.full(lado * lado, -1), np.zeros(lado * lado) peca[m["idx"]], sangue[m["idx"]] = m["peca"], sangues[nome] tris = np.flatnonzero(material == i) tri = sorteio.choice(tris, 100_000, p=area[tris] / area[tris].sum()) r1, r2 = np.sqrt(sorteio.random(len(tri))), sorteio.random(len(tri)) ponto = (uv[tri] * np.stack([1 - r1, r1 * (1 - r2), r1 * r2], 1)[:, :, None]).sum(1) texel = (np.clip(ponto, 0, 0.99999) * lado).astype(int) texel = texel[:, 1] * lado + texel[:, 0] for codigo in set(peca[texel]) - {-1}: fracoes[PECAS[codigo]] = float((sangue[texel][peca[texel] == codigo] > 0.5).mean()) return fracoes def gravar_jpeg(imagem, caminho): imagem.file_format = "JPEG" for qualidade in range(90, 59, -5): imagem.save(filepath=caminho, quality=qualidade) if os.path.getsize(caminho) <= MAX_BYTES_JPEG: return qualidade falhar(f"{caminho} passa de {MAX_BYTES_JPEG} bytes mesmo com qualidade 60") def pintar(corpo, esqueleto): """Refaz a cor dos três materiais por assado e grava as roupas B e C (mesmas UVs) em public/jogo/.""" geo = marcar_pecas(corpo, esqueleto) mapas = assar(corpo) corpo.data.color_attributes.remove(corpo.data.color_attributes["peca"]) tex = {nome: ler_textura(nome, 2 if nome == "sangue" else 4) for nome in ("pele", "camisa", "calca", "sangue")} for nome in ("pele", "camisa", "calca"): tex[f"{nome}_media"] = luminancia(tex[nome]).mean() mancha = np.clip((0.85 - tex["sangue"][..., 1]) / 0.5, 0, 1) # fundo branco: o verde some onde há sangue borrada = borrar(mancha, 5) tex["sangue"] = np.stack([mancha, borrada / np.percentile(borrada, 99.5), tex["sangue"][..., 0]], -1) cores = {"olhos": pintar_olhos(mapas["olhos"], geo)} cores["corpo"], sangue_corpo = pintar_corpo(mapas["corpo"], tex, geo) for variacao in ROUPAS: cor, sangue = pintar_roupa(mapas["roupa"], tex, geo, variacao) fracoes = cobertura(corpo, mapas, {"corpo": sangue_corpo, "roupa": sangue}) print(f"sangue na roupa {variacao}: " + ", ".join(f"{k} {v:.0%}" for k, v in fracoes.items()), flush=True) if max(fracoes.values()) > COBERTURA_SANGUE: falhar(f"sangue cobre mais de um terço de uma peça na roupa {variacao}: {fracoes}") if variacao == "a": cores["roupa"] = cor else: caminho = os.path.join(PUBLICO, f"roupa-{variacao}.jpg") qualidade = gravar_jpeg(imagem_nova(f"roupa-{variacao}", mapas["roupa"], cor), caminho) print(f"roupa-{variacao}.jpg qualidade {qualidade}, {os.path.getsize(caminho)} bytes", flush=True) for slot in corpo.material_slots: nos = slot.material.node_tree.nodes imagem = nos.new("ShaderNodeTexImage") imagem.image = imagem_nova(slot.material.name, mapas[slot.material.name], cores[slot.material.name]) slot.material.node_tree.links.new(imagem.outputs["Color"], nos["Principled BSDF"].inputs["Base Color"]) def reduzir(corpo): """Colapso de arestas antes do modificador de esqueleto: os pesos dos grupos de vértices são interpolados.""" esqueleto_mod = next(m for m in corpo.modifiers if m.type == "ARMATURE") esqueleto_mod.show_viewport = False origem = triangulos(corpo) razao = (MAX_TRIS - 50) / origem for _ in range(8): mod = corpo.modifiers.new("reduzir", "DECIMATE") mod.decimate_type, mod.ratio, mod.use_collapse_triangulate = "COLLAPSE", razao, True corpo.modifiers.move(len(corpo.modifiers) - 1, 0) n = triangulos(corpo) if n <= MAX_TRIS: break corpo.modifiers.remove(mod) razao *= 0.99 * MAX_TRIS / n else: falhar(f"a redução não chegou a {MAX_TRIS} triângulos") bpy.context.view_layer.objects.active = corpo bpy.ops.object.modifier_apply(modifier=mod.name) esqueleto_mod.show_viewport = True return origem, n def comprimento_perna(esqueleto, prefixo): ossos = esqueleto.pose.bones juntas = [esqueleto.matrix_world @ ossos[prefixo + "Left" + n].head for n in PERNA] return sum((b - a).length for a, b in zip(juntas, juntas[1:])) def quadro_final(acao): """Os FBX declaram mais quadros do que animam (o correr anima 25 e declara 122): corta no último que muda.""" 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, int(round(ponto.co[0]))) return fim def contato(esqueleto): """Altura do pé mais baixo acima da que ele tem no repouso: 0 quando um pé (dedos ou tornozelo) está no chão.""" pes = [OSSO_MODELO + lado + osso for lado in ("Left", "Right") for osso in ("Foot", "ToeBase")] ossos = esqueleto.pose.bones return min((esqueleto.matrix_world @ ossos[n].head).z - ossos[n].bone.head_local.z for n in pes) def transferir(esqueleto, nome_clipe, arquivo): """Copia a pose do esqueleto do arquivo de animação para o do modelo, quadro a quadro, e assa a ação. O FBX de animação não tem malha, então o "repouso" dele é um quadro qualquer, e as proporções são de outro personagem: a cópia é em espaço de mundo, rotação de todos os ossos e posição só do quadril, com o esqueleto de origem escalado pela razão entre as pernas e baixado até o pé de apoio tocar o chão no primeiro quadro (todos começam de pé; sem isso cinco dos seis clipes flutuam ou afundam uns 6 cm). """ novos = importar_fbx(arquivo) fonte = next(o for o in novos if o.type == "ARMATURE") 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, OSSO_MODELO) / comprimento_perna(fonte, OSSO_ANIM) for pb in esqueleto.pose.bones: alvo = pb.name.replace(OSSO_MODELO, OSSO_ANIM) if alvo not in fonte.pose.bones: falhar(f"{arquivo}: falta o osso {alvo}") c = pb.constraints.new("COPY_ROTATION") c.target, c.subtarget = fonte, alvo if pb.name == QUADRIL: c = pb.constraints.new("COPY_LOCATION") c.target, c.subtarget = fonte, alvo 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 curva in [c for c in curvas if c.data_path.endswith(".location") and f'["{QUADRIL}"]' not in c.data_path]: curvas.remove(curva) # só o quadril se desloca; nos outros ossos a posição é a do repouso for curva in curvas: # um quadro por chave: linear exporta como LINEAR, sem as tangentes do CUBICSPLINE for ponto in curva.keyframe_points: ponto.interpolation = "LINEAR" for o in novos: bpy.data.objects.remove(o) bpy.data.actions.remove(acao_fonte) no_lugar(esqueleto, acao, inicio, fim) enxugar(curvas) return inicio, fim def enxugar(curvas): """Tira as chaves que a interpolação linear entre as vizinhas mantidas já reproduz (canais parados ficam com 2). Os canais de um mesmo osso (as 4 do quatérnio, as 3 da posição) perdem as mesmas chaves, como o glTF pede. """ grupos = {} for curva in curvas: grupos.setdefault(curva.data_path, []).append(curva) for grupo in grupos.values(): quadros = [p.co[0] for p in grupo[0].keyframe_points] valores = [[p.co[1] for p in c.keyframe_points] for c in grupo] mantidas = [0] for j in range(2, len(quadros)): a = mantidas[-1] for k in range(a + 1, j): t = (quadros[k] - quadros[a]) / (quadros[j] - quadros[a]) if any(abs(v[a] + (v[j] - v[a]) * t - v[k]) > TOLERANCIA_CHAVE for v in valores): mantidas.append(j - 1) break mantidas.append(len(quadros) - 1) fora = sorted(set(range(len(quadros))) - set(mantidas), reverse=True) for curva in grupo: for i in fora: curva.keyframe_points.remove(curva.keyframe_points[i], fast=True) curva.update() def no_lugar(esqueleto, acao, inicio, fim): """Prende o quadril na horizontal do primeiro quadro quando o clipe se desloca (só o morrer: cai ~1 m à frente). Os clipes em ciclo já começam e terminam no mesmo ponto e o balanço lateral deles fica. """ esqueleto.animation_data.action = acao cena = bpy.context.scene osso = esqueleto.data.bones[QUADRIL] posicoes = [] for f in range(inicio, fim + 1): cena.frame_set(f) posicoes.append(esqueleto.pose.bones[QUADRIL].head.copy()) # esqueleto = mundo (transformação aplicada) deriva = posicoes[-1] - posicoes[0] if Vector((deriva.x, deriva.y)).length < 0.05: return curvas = acao.layers[0].strips[0].channelbags[0].fcurves loc = [curvas.find(f'pose.bones["{QUADRIL}"].location', index=i) for i in range(3)] para_local = osso.matrix_local.to_3x3().inverted() # o pai do quadril (root) não se mexe for f, p in zip(range(inicio, fim + 1), posicoes): correcao = para_local @ Vector((posicoes[0].x - p.x, posicoes[0].y - p.y, 0)) for i in range(3): ponto = next(k for k in loc[i].keyframe_points if int(round(k.co[0])) == f) ponto.co[1] += correcao[i] ponto.handle_left[1] += correcao[i] ponto.handle_right[1] += correcao[i] for c in loc: c.update() def posicoes_ossos(esqueleto, acao, inicio, fim): """Posição de mundo da cabeça de cada osso, quadro a quadro: (quadros, ossos, 3). A frente do zumbi é -Y.""" esqueleto.animation_data.action = acao saida = [] for f in range(inicio, fim + 1): bpy.context.scene.frame_set(f) saida.append([esqueleto.matrix_world @ b.head for b in esqueleto.pose.bones]) return np.array(saida) def misturar(valores, alvo, n): """Leva os n últimos quadros (eixo 0) até `alvo`, com peso suave (smoothstep): o último fica igual a ele.""" valores = valores.copy() for k in range(1, n + 1): t = k / n i = len(valores) - 1 - n + k valores[i] += t * t * (3 - 2 * t) * (alvo[i] - valores[i]) return valores def tranco(posicoes, n): """Maior aceleração média dos ossos (m/quadro²) do quadro antes da mistura até o depois da emenda do laço.""" laco = np.concatenate([posicoes[:-1], posicoes[:3]]) # o último quadro é o primeiro acel = np.linalg.norm(laco[2:] - 2 * laco[1:-1] + laco[:-2], axis=2).mean(1) return acel[len(posicoes) - n - 2 :].max() def recortar_golpe(esqueleto, inicio, fim): """Deixa o `atacar` com um golpe só e grava lib/jogo/golpe.json. Extensão de uma mão = distância dela à frente do quadril; cada pico é um impacto e o golpe é o do maior. O golpe natural vai do último quadro quase parado antes do pico até a volta à pose dele. Mais longo que DURACAO_GOLPE, o recorte encurta preparo e volta: entre os cortes com o impacto no meio do clipe, fica o de menor tranco na emenda depois de misturar os últimos quadros de volta ao primeiro. """ acao = bpy.data.actions["atacar"] fps = bpy.context.scene.render.fps nomes = [b.name for b in esqueleto.pose.bones] pos = posicoes_ossos(esqueleto, acao, inicio, fim) quadril = nomes.index(QUADRIL) ossos = {"esquerda": nomes.index(OSSO_MODELO + "LeftHand"), "direita": nomes.index(OSSO_MODELO + "RightHand")} extensao = {lado: pos[:, quadril, 1] - pos[:, i, 1] for lado, i in ossos.items()} for lado, e in extensao.items(): picos = [f for f in range(len(e)) if max(0, f - 3) + np.argmax(e[max(0, f - 3) : f + 4]) == f] lista = ", ".join(f"q{inicio + f} {e[f]:.3f} m a {pos[f, ossos[lado], 2]:.2f} m de altura" for f in picos) print(f"picos da mão {lado}: {lista}", flush=True) lado = max(extensao, key=lambda m: extensao[m].max()) pico = int(np.argmax(extensao[lado])) alcance = float(extensao[lado][pico]) outra = "esquerda" if lado == "direita" else "direita" mao = "ambas" if extensao[outra][pico] >= 0.9 * alcance else lado velocidade = np.r_[0, np.linalg.norm(np.diff(pos, axis=0), axis=2).mean(1)] # [f]: de f - 1 a f a0 = pico while a0 > 0 and velocidade[a0] >= 0.15 * velocidade.max(): a0 -= 1 distancia = np.linalg.norm(pos - pos[a0], axis=2).mean(1) b0 = next((f for f in range(pico + 1, len(pos)) if distancia[f] < 0.02), len(pos) - 1) quadros = min(b0 - a0, int(DURACAO_GOLPE * fps)) cortes = [] for a in range(a0, pico): b = a + quadros if b <= b0 and 0.25 <= (pico - a) / quadros <= 0.75: seg = pos[a : b + 1] cortes += [(tranco(misturar(seg, np.broadcast_to(seg[0], seg.shape), n), n), a, b, n) for n in MISTURA] if not cortes: falhar(f"nenhum recorte de {quadros} quadros entre q{inicio + a0} e q{inicio + b0} com o impacto no meio") estimado, a, b, n = min(cortes) curvas = acao.layers[0].strips[0].channelbags[0].fcurves grupos = {} for curva in curvas: grupos.setdefault(curva.data_path, []).append(curva) for caminho, grupo in grupos.items(): grupo.sort(key=lambda c: c.array_index) valores = np.array([[c.evaluate(inicio + f) for c in grupo] for f in range(a, b + 1)]) alvo = np.repeat(valores[:1], len(valores), 0) quaternio = caminho.endswith("rotation_quaternion") if quaternio: # q e -q são a mesma rotação: mistura com a do mesmo lado alvo[(valores * alvo).sum(1) < 0] *= -1 valores = misturar(valores, alvo, n) if quaternio: valores /= np.linalg.norm(valores, axis=1, keepdims=True) for curva, coluna in zip(grupo, valores.T): curva.keyframe_points.clear() curva.keyframe_points.add(len(coluna)) for f, (ponto, v) in enumerate(zip(curva.keyframe_points, coluna)): ponto.co = (f, v) # do quadro 0: o exportador grava tempo = quadro / fps e o clipe começa em 0 s ponto.interpolation = "LINEAR" curva.update() enxugar(curvas) novo = posicoes_ossos(esqueleto, acao, 0, b - a) real = tranco(novo, n) if int(np.argmax(novo[:, quadril, 1] - novo[:, ossos[lado], 1])) != pico - a: falhar("no clipe recortado a mão não vai mais à frente no quadro do impacto") acel = np.linalg.norm(pos[2:] - 2 * pos[1:-1] + pos[:-2], axis=2).mean(1).max() print( f"golpe natural q{inicio + a0}-q{inicio + b0} ({(b0 - a0) / fps:.3f} s), impacto q{inicio + pico}; " f"recorte q{inicio + a}-q{inicio + b}, mistura de {n} quadros; tranco na emenda {real:.3f} m/quadro² " f"(estimado {estimado:.3f}; o golpe original chega a {acel:.3f})", flush=True, ) golpe = { "duracao": round((b - a) / fps, 3), "impacto": round((pico - a) / fps, 3), "mao": mao, "alcance": round(alcance, 3), } if not 0.25 * golpe["duracao"] <= golpe["impacto"] <= 0.75 * golpe["duracao"]: falhar(f"impacto em {golpe['impacto']} s fora do meio do golpe de {golpe['duracao']} s") with open(GOLPE, "w", encoding="utf-8") as f: json.dump(golpe, f, ensure_ascii=False, indent=2) f.write("\n") return golpe def exportar(esqueleto, corpo): os.makedirs(PUBLICO, exist_ok=True) bpy.ops.object.select_all(action="DESELECT") for o in (esqueleto, corpo): 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]: # sobras vazias da importação e do assado 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=QUALIDADE_JPEG, export_animations=True, export_animation_mode="ACTIONS", export_anim_slide_to_zero=True, export_force_sampling=False, export_optimize_animation_size=True, ) def ler_glb(): with open(GLB, "rb") as f: bruto = f.read() tamanho = struct.unpack_from(" MAX_TRIS: falhar(f"{tris} triângulos no GLB (máximo {MAX_TRIS})") if tamanho > MAX_BYTES: falhar(f"GLB com {tamanho} bytes (máximo {MAX_BYTES})") extensoes = dados.get("extensionsUsed", []) if dados.get("cameras") or "KHR_lights_punctual" in extensoes: falhar("o GLB saiu com câmera ou luz") if "KHR_materials_specular" in extensoes: falhar("o GLB saiu com KHR_materials_specular (o three.js passaria a usar o material físico)") materiais = sorted(m["name"] for m in dados["materials"]) if materiais != ["corpo", "olhos", "roupa"]: falhar(f"materiais no GLB: {materiais}") faltando = [n for n in ["parado", "andar", "atacar", "morrer"] if n not in clipes] if faltando or len(clipes) != len(CLIPES): falhar(f"clipes no GLB: {clipes}") return tris, tamanho, clipes def renderizar(): """Reabre o GLB exportado (o que o jogo vai ver), mede o repouso e fotografa de frente, parado, com luz branca.""" bpy.ops.wm.read_factory_settings(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") corpo = next(o for o in cena.objects if o.type == "MESH") esqueleto.data.pose_position = "REST" bpy.context.view_layer.update() malha = corpo.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh() pontos = [corpo.matrix_world @ v.co for v in malha.vertices] corpo.to_mesh_clear() pe, topo = min(p.z for p in pontos), max(p.z for p in pontos) ossos = esqueleto.data.bones # no Blender a frente do glTF (+Z) é -Y: o dedão fica à frente do tornozelo de_frente = ossos[OSSO_MODELO + "LeftToeBase"].head_local.y < ossos[OSSO_MODELO + "LeftFoot"].head_local.y esqueleto.data.pose_position = "POSE" esqueleto.animation_data.action = next(a for a in bpy.data.actions if a.name.startswith("parado")) cena.frame_set(30) 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) os.makedirs(RENDERS, exist_ok=True) for nome, alvo, distancia in (("frente", 0.9, 3.2), ("perto", 1.4, 1.5)): 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"zumbi-{nome}.png"), (800, 800), "PNG", "RGB", 64, False) return pe, topo, de_frente def sala(zumbis, camera_em, alvo, lente): """Monta a luz do jogo, sala escura azulada e uma lanterna na câmera, com uma cópia do GLB por zumbi. zumbis: (variação, (x, y), clipe, atraso em quadros); o atraso desencontra o passo de quem anda junto. """ bpy.ops.wm.read_factory_settings(use_empty=True) cena = bpy.context.scene for variacao, (x, y), clipe, atraso in zumbis: antes = set(cena.objects) bpy.ops.import_scene.gltf(filepath=GLB) novos = [o for o in cena.objects if o not in antes] esqueleto = next(o for o in novos if o.type == "ARMATURE") corpo = next(o for o in novos if o.type == "MESH") esqueleto.location.x, esqueleto.location.y = x, y esqueleto.animation_data.action = None faixa = esqueleto.animation_data.nla_tracks.new() faixa.strips.new(clipe, 1 - atraso, next(a for a in bpy.data.actions if a.name == clipe)) if variacao != "a": slot = next(s for s in corpo.material_slots if s.material.name.startswith("roupa")) slot.material = slot.material.copy() base = slot.material.node_tree.nodes["Principled BSDF"].inputs["Base Color"].links[0].from_node base.image = bpy.data.images.load(os.path.join(PUBLICO, f"roupa-{variacao}.jpg")) cena.frame_set(30) mundo = bpy.data.worlds.new("sala") mundo.use_nodes = True mundo.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.03, 0.06, 1) mundo.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.6 cena.world = mundo piso = bpy.data.materials.new("piso") piso.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.025, 0.027, 0.03, 1) piso.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.85 for nome, local, rotacao in (("chao", (0, 0, 0), (0, 0, 0)), ("parede", (0, 2.5, 3), (1.5708, 0, 0))): bpy.ops.mesh.primitive_plane_add(size=10, location=local, rotation=rotacao) bpy.context.object.name = nome bpy.context.object.data.materials.append(piso) camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera")) camera.data.lens = lente camera.location = camera_em camera.rotation_euler = (Vector(alvo) - camera.location).to_track_quat("-Z", "Y").to_euler() cena.collection.objects.link(camera) cena.camera = camera lanterna = bpy.data.objects.new("lanterna", bpy.data.lights.new("lanterna", "SPOT")) lanterna.data.energy, lanterna.data.spot_size, lanterna.data.spot_blend = 250, 0.9, 0.5 lanterna.data.color = (1.0, 0.94, 0.84) lanterna.location = Vector(camera_em) + Vector((0.15, 0.1, -0.15)) # na mão, um pouco abaixo do olho lanterna.rotation_euler = (Vector(alvo) - lanterna.location).to_track_quat("-Z", "Y").to_euler() cena.collection.objects.link(lanterna) ligar_cycles(cena) return cena def renderizar_variacoes(): """As três roupas lado a lado, paradas, de corpo inteiro.""" zumbis = [(v, ((i - 1) * 0.9, 0), "parado", 0) for i, v in enumerate(ROUPAS)] cena = sala(zumbis, (0, -3.4, 1.55), (0, 0, 0.95), 40) sair(cena, os.path.join(RENDERS, "zumbi-variacoes.png"), (1200, 800), "PNG", "RGB", 64, False) def renderizar_capa(): """Abertura da página: as três vindo para a câmera; o terço esquerdo fica vazio e escuro para o texto.""" zumbis = [("a", (0.35, -0.5), "andar", 0), ("b", (1.25, 0.3), "andar", 11), ("c", (0.0, 1.1), "andar", 23)] cena = sala(zumbis, (0, -3.6, 1.5), (0.6, 0, 1.0), 35) with tempfile.TemporaryDirectory() as pasta: png = os.path.join(pasta, "capa.png") sair(cena, png, (1600, 900), "PNG", "RGB", 96, False) render = bpy.data.images.load(png) imagem = bpy.data.images.new("capa", *render.size) # imagem carregada regrava no formato de origem imagem.pixels.foreach_set(np.array(render.pixels, np.float32)) qualidade = gravar_jpeg(imagem, CAPA) print(f"capa.jpg qualidade {qualidade}, {os.path.getsize(CAPA)} bytes", flush=True) def renderizar_golpe(golpe): """Confere no GLB a duração do `atacar` e que a mão vai mais à frente no impacto; renderiza de perfil o início, o meio do preparo, o impacto (com moldura), o meio da volta e o fim, lado a lado, em golpe.png.""" dados, _ = ler_glb() animacao = next(a for a in dados["animations"] if a["name"] == "atacar") tempos = [dados["accessors"][s["input"]] for s in animacao["samplers"]] comeco, duracao = min(t["min"][0] for t in tempos), max(t["max"][0] for t in tempos) if comeco > 1e-3 or abs(duracao - golpe["duracao"]) > 1e-3: falhar(f"atacar vai de {comeco:.3f} a {duracao:.3f} s no GLB; golpe.json diz 0 a {golpe['duracao']} s") cena = sala([("a", (0, 0), "atacar", 0)], (3.4, -0.25, 1.0), (0, -0.25, 0.95), 45) # perfil: frente à esquerda esqueleto = next(o for o in cena.objects if o.type == "ARMATURE") acao = next(a for a in bpy.data.actions if a.name == "atacar") por_segundo = (acao.frame_range[1] - acao.frame_range[0]) / duracao # quadros da cena por segundo do clipe ossos = esqueleto.pose.bones nomes_maos = {"direita": ["RightHand"], "esquerda": ["LeftHand"], "ambas": ["RightHand", "LeftHand"]} maos = [OSSO_MODELO + m for m in nomes_maos[golpe["mao"]]] def no_instante(t): f = 1 + t * por_segundo cena.frame_set(int(f), subframe=f - int(f)) quadril = (esqueleto.matrix_world @ ossos[QUADRIL].head).y return max(quadril - (esqueleto.matrix_world @ ossos[m].head).y for m in maos) extensoes = [no_instante(k / 30) for k in range(round(duracao * 30) + 1)] impacto = int(np.argmax(extensoes)) / 30 if abs(impacto - golpe["impacto"]) > 1e-3: falhar(f"no GLB a mão vai mais à frente em {impacto:.3f} s, não em {golpe['impacto']} s") print(f"no GLB: mão mais à frente em {impacto:.3f} s, {max(extensoes):.3f} m à frente do quadril", flush=True) largura, altura, borda = 440, 600, 8 quadros = [] with tempfile.TemporaryDirectory() as pasta: for i, t in enumerate([0, impacto / 2, impacto, (impacto + duracao) / 2, duracao]): no_instante(t) png = os.path.join(pasta, f"{i}.png") sair(cena, png, (largura, altura), "PNG", "RGB", 32, False) quadros.append(np.array(bpy.data.images.load(png).pixels, np.float32).reshape(altura, largura, 4)) moldura = quadros[2] for faixa in (moldura[:borda], moldura[-borda:], moldura[:, :borda], moldura[:, -borda:]): faixa[..., :3] = (1.0, 0.2, 0.1) imagem = bpy.data.images.new("golpe", largura * len(quadros), altura) imagem.pixels.foreach_set(np.concatenate(quadros, 1).ravel()) imagem.filepath_raw = os.path.join(RENDERS, "golpe.png") imagem.file_format = "PNG" imagem.save() def main(): obter_fontes() bpy.ops.wm.read_factory_settings(use_empty=True) bpy.context.scene.render.fps = 30 esqueleto, corpo = preparar_modelo() ligar_texturas(corpo) tris_origem, tris_reduzidos = reduzir(corpo) print(f"triângulos {tris_origem} -> {tris_reduzidos}", flush=True) print(f"pele afundada sob a roupa: {afundar_pele(corpo)} vértices", flush=True) pintar(corpo, esqueleto) esqueleto.animation_data_create() quadros = {} for nome, arquivo in CLIPES.items(): quadros[nome] = transferir(esqueleto, nome, arquivo) print(f"clipe {nome} <- {arquivo} quadros {quadros[nome][0]}-{quadros[nome][1]}", flush=True) golpe = recortar_golpe(esqueleto, *quadros["atacar"]) exportar(esqueleto, corpo) proprios = [{"arquivo": "zumbi.glb", "origem": PAGINA, "autor": "Rikindle3D", "licenca": "CC0-1.0"}] + [ { "arquivo": f"roupa-{variacao}.jpg", "origem": "gerada para este jogo", "autor": "Evandro Fonseca Junior", "licenca": "propria", } for variacao in ROUPAS if variacao != "a" ] + [ { "arquivo": "capa.jpg", "origem": "render feito para este jogo a partir de zumbi.glb", "autor": "Evandro Fonseca Junior", "licenca": "propria", } ] creditos = [] if os.path.exists(CREDITOS): # outros scripts também creditam arquivos de public/jogo/: mantém os deles with open(CREDITOS, encoding="utf-8") as f: nossos = {c["arquivo"] for c in proprios} creditos = [c for c in json.load(f) if c["arquivo"] not in nossos] with open(CREDITOS, "w", encoding="utf-8") as f: json.dump(proprios + creditos, f, ensure_ascii=False, indent=2) f.write("\n") tris, tamanho, clipes = conferir() pe, topo, de_frente = renderizar() renderizar_variacoes() renderizar_capa() renderizar_golpe(golpe) altura = topo - pe if abs(pe) > 0.01 or not ALTURA[0] <= altura <= ALTURA[1]: falhar(f"pés em {pe:.3f} m e altura {altura:.3f} m no repouso") if not de_frente: falhar("o zumbi não está de frente para +Z") print(f"repouso: pés em {pe:.3f} m, altura {altura:.3f} m", flush=True) print(f"golpe duracao={golpe['duracao']:.3f} impacto={golpe['impacto']:.3f} mao={golpe['mao']}", flush=True) print(f"OK zumbi tris={tris} bytes={tamanho} clipes={','.join(clipes)}", flush=True) if __name__ == "__main__": main()