Files
portifolio-evandrodevbr/3d/jogo.py
T
evandro 67b32fcab3
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
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.
2026-11-10 19:37:10 -03:00

1095 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("<I", bruto, 12)[0]
return json.loads(bruto[20 : 20 + tamanho]), len(bruto)
def conferir():
dados, tamanho = ler_glb()
tris = sum(dados["accessors"][p["indices"]]["count"] // 3 for m in dados["meshes"] for p in m["primitives"])
clipes = [a["name"] for a in dados.get("animations", [])]
if tris > 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()