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.
1095 lines
53 KiB
Python
1095 lines
53 KiB
Python
"""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()
|