claude-code-arcane 2.6.1 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +8 -4
- package/dist/cli.js +349 -143
- package/docs/SKILLS-CATALOG.md +19 -3
- package/docs/USER-GUIDE.md +3 -3
- package/hooks/validate-commit.sh +36 -0
- package/package.json +1 -1
- package/profiles/blender.yaml +49 -0
- package/profiles/freelance.yaml +1 -0
- package/rules/blender-pipeline.md +95 -0
- package/skills/arcane-add/SKILL.md +29 -2
- package/skills/blender-animation/SKILL.md +167 -0
- package/skills/blender-context/SKILL.md +145 -0
- package/skills/blender-export/SKILL.md +143 -0
- package/skills/blender-export/scripts/validate_gltf.py +362 -0
- package/skills/blender-materials/SKILL.md +165 -0
- package/skills/blender-modeling/SKILL.md +160 -0
- package/skills/fiverr-gig/SKILL.md +147 -0
- package/skills/freelance-scan/references/platforms.md +2 -0
- package/skills/meshy-derive/SKILL.md +3 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: blender-export
|
|
3
|
+
description: "Exporta desde Blender al engine y valida el resultado: eleccion de formato (glTF/GLB, FBX, .blend), settings que importan, export headless reproducible y validacion automatica de tris, UVs, escala, texturas y clips con validate_gltf.py. Usar para: exportar de blender, glb, fbx, validar export, blender a unity, blender a ue5."
|
|
4
|
+
category: "gamedev"
|
|
5
|
+
argument-hint: "[asset-slug] [--target unity|ue5|web] [--budget-tris <n>]"
|
|
6
|
+
user-invocable: true
|
|
7
|
+
allowed-tools: Read, Glob, Grep, Bash, Write, Edit
|
|
8
|
+
---
|
|
9
|
+
# blender-export — Salida al engine
|
|
10
|
+
|
|
11
|
+
Saca el asset de Blender y **verifica que lo que salio es lo que se esperaba**. Este skill cierra
|
|
12
|
+
el pipeline: `asset-spec` → Meshy o modelado → Blender → **export validado** → `asset-audit`.
|
|
13
|
+
|
|
14
|
+
> Requiere `/blender-context` (destino y escala definidos). Si el asset tiene rig, requiere el bake
|
|
15
|
+
> de `/blender-animation` hecho: los constraints no viajan.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Phase 1: Elegir formato
|
|
20
|
+
|
|
21
|
+
| Formato | Cuando | Cuidado |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| **glTF 2.0 (`.glb`)** | Default para Unity URP, web, y cualquier cosa moderna | Un solo archivo, texturas embebidas, Y-up automatico |
|
|
24
|
+
| **FBX** | Pipelines de UE5 y Unity legacy, skeletal meshes | Hay que fijar ejes y escala a mano, formato cerrado |
|
|
25
|
+
| **`.blend` directo** | Prototipo rapido en Unity | Unity necesita Blender instalado para importarlo. **No usar en CI ni en un repo de equipo** |
|
|
26
|
+
| **USD** | Pipelines de VFX / interop DCC | Soporte irregular en engines de juego |
|
|
27
|
+
|
|
28
|
+
Regla practica: **glTF salvo que el destino lo impida.** FBX es la excepcion, no el default, y
|
|
29
|
+
existe casi siempre por el lado del engine, no por el de Blender.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Phase 2: Settings que importan
|
|
34
|
+
|
|
35
|
+
### glTF / GLB
|
|
36
|
+
|
|
37
|
+
| Setting | Valor | Por que |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| Format | `glTF Binary (.glb)` | Un archivo, texturas adentro |
|
|
40
|
+
| Include | `Selected Objects` | Un export por asset, no la escena entera |
|
|
41
|
+
| `+Y Up` | On | Es la convencion de glTF y de Unity |
|
|
42
|
+
| Apply Modifiers | On | Lo que no se aplica no existe en el archivo |
|
|
43
|
+
| Materials | `Export` | |
|
|
44
|
+
| Images | `Automatic` | |
|
|
45
|
+
| Compression (Draco) | Off al exportar | Se comprime despues, como paso propio |
|
|
46
|
+
| Animation → Sampling | On, y **una accion por clip** | |
|
|
47
|
+
|
|
48
|
+
### FBX
|
|
49
|
+
|
|
50
|
+
| Setting | Valor | Por que |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| Scale | `1.0` con `Apply Unit` | El engine hace el resto |
|
|
53
|
+
| Forward / Up | Segun el engine destino | UE5: `-Z Forward`, `Y Up` |
|
|
54
|
+
| Apply Scalings | `FBX All` | Evita escalas dobles |
|
|
55
|
+
| Bake Animation | On, con `NLA Strips` off si se exporta una sola accion | |
|
|
56
|
+
| Add Leaf Bones | **Off** | Ensucia el esqueleto en el engine |
|
|
57
|
+
|
|
58
|
+
Lo que **nunca** se exporta: huesos de control, objetos de referencia, camaras y luces de trabajo,
|
|
59
|
+
collections de scratch. Si estan en el archivo, van a aparecer en el engine.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Phase 3: Export headless
|
|
64
|
+
|
|
65
|
+
Reproducible y versionable, a diferencia de exportar a mano desde la UI:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
blender --background <archivo.blend> --python <export_script.py> -- \
|
|
69
|
+
--out assets/3d/<slug>/export/<slug>.glb
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
El script hace: seleccionar los objetos que corresponden, limpiar la seleccion de lo que no va, y
|
|
73
|
+
llamar a `bpy.ops.export_scene.gltf(...)` con los settings de la Phase 2 explicitos — **ninguno
|
|
74
|
+
por default**, porque los defaults del operador cambian entre versiones de Blender.
|
|
75
|
+
|
|
76
|
+
El script vive en el repo (`assets/3d/_scripts/` o al lado del asset), no en el chat. Un export que
|
|
77
|
+
solo existe como pasos manuales se hace distinto cada vez.
|
|
78
|
+
|
|
79
|
+
**Pedir aprobacion antes de escribir** el script y antes del primer export a una ruta que ya tiene
|
|
80
|
+
un archivo: un `.glb` sobrescrito no se recupera si el `.blend` cambio.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Phase 4: Validar el resultado
|
|
85
|
+
|
|
86
|
+
Nunca dar un export por bueno sin medirlo. El validador es stdlib puro, no instala nada:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
python scripts/validate_gltf.py assets/3d/<slug>/export/<slug>.glb \
|
|
90
|
+
--budget-tris 8000 --max-materials 2 --require-uv1 --target unity
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Flag | Para que |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `--budget-tris <n>` | Presupuesto declarado en `/blender-modeling` |
|
|
96
|
+
| `--max-materials <n>` | Tope de draw calls |
|
|
97
|
+
| `--require-uv1` | Exigir lightmap UV cuando el asset recibe luz bakeada |
|
|
98
|
+
| `--target unity\|ue5\|web` | Avisos propios del destino (normal maps DirectX en UE5, compresion en web) |
|
|
99
|
+
| `--json` | Salida parseable, para encadenarlo en CI |
|
|
100
|
+
|
|
101
|
+
Que chequea, y por que cada uno esta:
|
|
102
|
+
|
|
103
|
+
1. **Contenedor** — magic y chunks del GLB. Un archivo truncado se detecta aca, no en el engine.
|
|
104
|
+
2. **Presupuesto de tris** — contando indices reales por primitiva, no estimando.
|
|
105
|
+
3. **UV0 / UV1** — sin UV0 no hay textura; sin UV1 el lightmap sale manchado.
|
|
106
|
+
4. **Escala** — nodos con escala 100 o 0.01 son conversion de unidades sin aplicar.
|
|
107
|
+
5. **Materiales** — cantidad contra el tope, mas nombres autogenerados (`Material.001`) que en el
|
|
108
|
+
engine son imposibles de rastrear.
|
|
109
|
+
6. **Texturas** — imagenes referenciadas fuera de un `.glb` (llegan roto al engine) y resoluciones
|
|
110
|
+
que no son potencia de dos.
|
|
111
|
+
7. **Clips** — cantidad y nombres; un clip sin nombre entra al engine como `Animation_0`.
|
|
112
|
+
|
|
113
|
+
Exit code 0 si el verdict es PASS o CONCERNS, 1 si es FAIL. Eso lo hace usable como gate.
|
|
114
|
+
|
|
115
|
+
> El validador lee glTF/GLB, **no FBX**. FBX binario no se puede inspeccionar sin dependencias, y
|
|
116
|
+
> este repo no las agrega. Si el destino es FBX, exportar **tambien** un `.glb` de control y
|
|
117
|
+
> validar ese: la geometria y los materiales son los mismos.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Phase 5: Resumen
|
|
122
|
+
|
|
123
|
+
Reportar: formato y ruta de salida, settings usados, y la tabla completa del validador con su
|
|
124
|
+
verdict.
|
|
125
|
+
|
|
126
|
+
**Verdict:**
|
|
127
|
+
- **PASS** — el validador da PASS y el asset esta listo para integrar.
|
|
128
|
+
- **CONCERNS** — exportado con avisos (no potencia de dos, clips sin nombre, avisos del target).
|
|
129
|
+
Listar cada uno y decir si se acepta o se corrige.
|
|
130
|
+
- **FAIL** — presupuesto excedido, sin UVs, escala sin aplicar, texturas externas en un `.glb`, o
|
|
131
|
+
contenedor invalido. **No integrar**: indicar el fix y volver al skill que corresponde
|
|
132
|
+
(`/blender-modeling`, `/blender-materials`, `/blender-animation`).
|
|
133
|
+
|
|
134
|
+
## Next Steps
|
|
135
|
+
|
|
136
|
+
- `/asset-audit` para validar naming, formatos y huerfanos ya dentro del proyecto
|
|
137
|
+
- `/meshy-derive` si hacen falta mas targets del mismo master (web, print)
|
|
138
|
+
- En UE5, `/ue-animation-system` o `/ue-materials-rendering` para el lado del engine
|
|
139
|
+
- Si el verdict fue FAIL, volver al skill del area que fallo — no parchear el archivo exportado
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
> → Formatos, ejes, escala y reglas de Git LFS estan en `.claude/rules/blender-pipeline.md`.
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Valida un .glb / .gltf exportado desde Blender contra el presupuesto del proyecto.
|
|
3
|
+
|
|
4
|
+
Chequea lo que se rompe siempre y no se ve hasta que el asset ya esta en el engine:
|
|
5
|
+
triangulos fuera de presupuesto, UVs ausentes, lightmap UV1 faltante, escalas sin
|
|
6
|
+
aplicar, texturas que no son potencia de dos, imagenes externas dentro de un .glb,
|
|
7
|
+
y materiales de sobra.
|
|
8
|
+
|
|
9
|
+
Solo stdlib: corre en el proyecto del usuario sin instalar nada.
|
|
10
|
+
|
|
11
|
+
python validate_gltf.py export/prop.glb --budget-tris 2000 --target unity
|
|
12
|
+
python validate_gltf.py export/hero.glb --budget-tris 40000 --require-uv1 --json
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import struct
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
GLB_MAGIC = 0x46546C67 # 'glTF'
|
|
22
|
+
CHUNK_JSON = 0x4E4F534A # 'JSON'
|
|
23
|
+
CHUNK_BIN = 0x004E4942 # 'BIN\0'
|
|
24
|
+
MODE_TRIANGLES = 4
|
|
25
|
+
|
|
26
|
+
# Escalas que delatan una conversion de unidades sin aplicar en lugar de una
|
|
27
|
+
# decision de diseno.
|
|
28
|
+
SUSPICIOUS_SCALES = (100.0, 0.01)
|
|
29
|
+
SCALE_TOLERANCE = 0.001
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Report:
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.checks = []
|
|
35
|
+
|
|
36
|
+
def add(self, level, check, message):
|
|
37
|
+
self.checks.append({"level": level, "check": check, "message": message})
|
|
38
|
+
|
|
39
|
+
def ok(self, check, message):
|
|
40
|
+
self.add("PASS", check, message)
|
|
41
|
+
|
|
42
|
+
def warn(self, check, message):
|
|
43
|
+
self.add("WARN", check, message)
|
|
44
|
+
|
|
45
|
+
def fail(self, check, message):
|
|
46
|
+
self.add("FAIL", check, message)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def verdict(self):
|
|
50
|
+
levels = {c["level"] for c in self.checks}
|
|
51
|
+
if "FAIL" in levels:
|
|
52
|
+
return "FAIL"
|
|
53
|
+
if "WARN" in levels:
|
|
54
|
+
return "CONCERNS"
|
|
55
|
+
return "PASS"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_gltf(path):
|
|
59
|
+
"""Devuelve (json_dict, bin_chunk_or_None). Acepta .glb y .gltf."""
|
|
60
|
+
with open(path, "rb") as handle:
|
|
61
|
+
head = handle.read(4)
|
|
62
|
+
handle.seek(0)
|
|
63
|
+
if len(head) == 4 and struct.unpack("<I", head)[0] == GLB_MAGIC:
|
|
64
|
+
return _load_glb(handle)
|
|
65
|
+
return json.load(handle), None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_glb(handle):
|
|
69
|
+
magic, version, total = struct.unpack("<III", handle.read(12))
|
|
70
|
+
if magic != GLB_MAGIC:
|
|
71
|
+
raise ValueError("no es un GLB: falta el magic 'glTF'")
|
|
72
|
+
if version != 2:
|
|
73
|
+
raise ValueError("version de GLB no soportada: %d (se espera 2)" % version)
|
|
74
|
+
|
|
75
|
+
doc = None
|
|
76
|
+
binary = None
|
|
77
|
+
while handle.tell() < total:
|
|
78
|
+
header = handle.read(8)
|
|
79
|
+
if len(header) < 8:
|
|
80
|
+
break
|
|
81
|
+
length, kind = struct.unpack("<II", header)
|
|
82
|
+
payload = handle.read(length)
|
|
83
|
+
if kind == CHUNK_JSON:
|
|
84
|
+
doc = json.loads(payload.decode("utf-8"))
|
|
85
|
+
elif kind == CHUNK_BIN:
|
|
86
|
+
binary = payload
|
|
87
|
+
if doc is None:
|
|
88
|
+
raise ValueError("el GLB no tiene chunk JSON")
|
|
89
|
+
return doc, binary
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def image_size(blob):
|
|
93
|
+
"""Dimensiones de un PNG o JPEG en bytes, o None si no se reconoce."""
|
|
94
|
+
if blob[:8] == b"\x89PNG\r\n\x1a\n" and len(blob) >= 24:
|
|
95
|
+
width, height = struct.unpack(">II", blob[16:24])
|
|
96
|
+
return width, height
|
|
97
|
+
if blob[:2] == b"\xff\xd8":
|
|
98
|
+
offset = 2
|
|
99
|
+
while offset + 9 < len(blob):
|
|
100
|
+
if blob[offset] != 0xFF:
|
|
101
|
+
offset += 1
|
|
102
|
+
continue
|
|
103
|
+
marker = blob[offset + 1]
|
|
104
|
+
# SOF0-3, SOF5-7, SOF9-11, SOF13-15 llevan las dimensiones.
|
|
105
|
+
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
|
|
106
|
+
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
|
107
|
+
height, width = struct.unpack(">HH", blob[offset + 5:offset + 9])
|
|
108
|
+
return width, height
|
|
109
|
+
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
|
|
110
|
+
offset += 2
|
|
111
|
+
continue
|
|
112
|
+
segment = struct.unpack(">H", blob[offset + 2:offset + 4])[0]
|
|
113
|
+
offset += 2 + segment
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_power_of_two(value):
|
|
118
|
+
return value > 0 and (value & (value - 1)) == 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def count_triangles(doc):
|
|
122
|
+
"""(tris, primitivas_no_triangulo) sumando todas las meshes del documento."""
|
|
123
|
+
accessors = doc.get("accessors", [])
|
|
124
|
+
tris = 0
|
|
125
|
+
non_triangles = 0
|
|
126
|
+
for mesh in doc.get("meshes", []):
|
|
127
|
+
for prim in mesh.get("primitives", []):
|
|
128
|
+
if prim.get("mode", MODE_TRIANGLES) != MODE_TRIANGLES:
|
|
129
|
+
non_triangles += 1
|
|
130
|
+
continue
|
|
131
|
+
if "indices" in prim:
|
|
132
|
+
count = accessors[prim["indices"]].get("count", 0)
|
|
133
|
+
else:
|
|
134
|
+
position = prim.get("attributes", {}).get("POSITION")
|
|
135
|
+
count = accessors[position].get("count", 0) if position is not None else 0
|
|
136
|
+
tris += count // 3
|
|
137
|
+
return tris, non_triangles
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def node_scale(node):
|
|
141
|
+
"""Escala de un nodo, venga como 'scale' o dentro de 'matrix'."""
|
|
142
|
+
if "scale" in node:
|
|
143
|
+
return [float(v) for v in node["scale"]]
|
|
144
|
+
matrix = node.get("matrix")
|
|
145
|
+
if not matrix:
|
|
146
|
+
return [1.0, 1.0, 1.0]
|
|
147
|
+
# glTF guarda la matriz por columnas; la norma de las 3 primeras es la escala.
|
|
148
|
+
return [
|
|
149
|
+
(matrix[i] ** 2 + matrix[i + 1] ** 2 + matrix[i + 2] ** 2) ** 0.5
|
|
150
|
+
for i in (0, 4, 8)
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_geometry(doc, report, budget):
|
|
155
|
+
tris, non_triangles = count_triangles(doc)
|
|
156
|
+
if budget is None:
|
|
157
|
+
report.warn("presupuesto", "%d tris, sin presupuesto declarado (--budget-tris)" % tris)
|
|
158
|
+
elif tris > budget:
|
|
159
|
+
report.fail("presupuesto", "%d tris contra un presupuesto de %d (+%d)" % (tris, budget, tris - budget))
|
|
160
|
+
else:
|
|
161
|
+
report.ok("presupuesto", "%d tris dentro del presupuesto de %d" % (tris, budget))
|
|
162
|
+
|
|
163
|
+
if non_triangles:
|
|
164
|
+
report.warn("primitivas", "%d primitivas no triangulares (lines/points) - el engine puede ignorarlas" % non_triangles)
|
|
165
|
+
|
|
166
|
+
if not doc.get("meshes"):
|
|
167
|
+
report.fail("meshes", "el archivo no tiene ninguna mesh")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def check_uvs(doc, report, require_uv1):
|
|
171
|
+
missing_uv0 = []
|
|
172
|
+
missing_uv1 = []
|
|
173
|
+
for mesh in doc.get("meshes", []):
|
|
174
|
+
name = mesh.get("name", "<sin nombre>")
|
|
175
|
+
for prim in mesh.get("primitives", []):
|
|
176
|
+
attrs = prim.get("attributes", {})
|
|
177
|
+
if "TEXCOORD_0" not in attrs:
|
|
178
|
+
missing_uv0.append(name)
|
|
179
|
+
if "TEXCOORD_1" not in attrs:
|
|
180
|
+
missing_uv1.append(name)
|
|
181
|
+
|
|
182
|
+
if missing_uv0:
|
|
183
|
+
report.fail("uv0", "sin UV0, no se puede texturizar: %s" % ", ".join(sorted(set(missing_uv0))[:5]))
|
|
184
|
+
else:
|
|
185
|
+
report.ok("uv0", "todas las primitivas tienen UV0")
|
|
186
|
+
|
|
187
|
+
if require_uv1:
|
|
188
|
+
if missing_uv1:
|
|
189
|
+
report.fail("uv1", "sin UV1 para lightmap: %s" % ", ".join(sorted(set(missing_uv1))[:5]))
|
|
190
|
+
else:
|
|
191
|
+
report.ok("uv1", "todas las primitivas tienen UV1")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_scales(doc, report):
|
|
195
|
+
offenders = []
|
|
196
|
+
for node in doc.get("nodes", []):
|
|
197
|
+
scale = node_scale(node)
|
|
198
|
+
for value in scale:
|
|
199
|
+
if any(abs(value - s) < SCALE_TOLERANCE for s in SUSPICIOUS_SCALES):
|
|
200
|
+
offenders.append((node.get("name", "<sin nombre>"), scale))
|
|
201
|
+
break
|
|
202
|
+
if offenders:
|
|
203
|
+
detail = ", ".join("%s=%s" % (name, [round(v, 4) for v in scale]) for name, scale in offenders[:5])
|
|
204
|
+
report.fail("escala", "escala sin aplicar (conversion de unidades): %s" % detail)
|
|
205
|
+
else:
|
|
206
|
+
report.ok("escala", "sin escalas de conversion sin aplicar")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def check_materials(doc, report, max_materials):
|
|
210
|
+
materials = doc.get("materials", [])
|
|
211
|
+
unnamed = [i for i, m in enumerate(materials) if not m.get("name")]
|
|
212
|
+
autogen = [m["name"] for m in materials if m.get("name", "").split(".")[-1].isdigit()]
|
|
213
|
+
|
|
214
|
+
if max_materials is not None and len(materials) > max_materials:
|
|
215
|
+
report.fail("materiales", "%d materiales contra un maximo de %d (= draw calls)" % (len(materials), max_materials))
|
|
216
|
+
else:
|
|
217
|
+
report.ok("materiales", "%d materiales" % len(materials))
|
|
218
|
+
|
|
219
|
+
if unnamed:
|
|
220
|
+
report.warn("materiales", "%d materiales sin nombre" % len(unnamed))
|
|
221
|
+
if autogen:
|
|
222
|
+
report.warn("materiales", "nombres autogenerados sin renombrar: %s" % ", ".join(autogen[:5]))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def check_images(doc, binary, report, base_dir, is_glb):
|
|
226
|
+
images = doc.get("images", [])
|
|
227
|
+
if not images:
|
|
228
|
+
report.warn("texturas", "el archivo no trae texturas")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
buffer_views = doc.get("bufferViews", [])
|
|
232
|
+
external = []
|
|
233
|
+
not_pot = []
|
|
234
|
+
unreadable = 0
|
|
235
|
+
|
|
236
|
+
for index, image in enumerate(images):
|
|
237
|
+
label = image.get("name") or image.get("uri") or "image[%d]" % index
|
|
238
|
+
blob = None
|
|
239
|
+
|
|
240
|
+
uri = image.get("uri")
|
|
241
|
+
if uri and not uri.startswith("data:"):
|
|
242
|
+
if is_glb:
|
|
243
|
+
external.append(label)
|
|
244
|
+
continue
|
|
245
|
+
path = os.path.join(base_dir, uri)
|
|
246
|
+
if not os.path.exists(path):
|
|
247
|
+
external.append("%s (no existe)" % label)
|
|
248
|
+
continue
|
|
249
|
+
with open(path, "rb") as handle:
|
|
250
|
+
blob = handle.read(32)
|
|
251
|
+
elif "bufferView" in image and binary is not None:
|
|
252
|
+
view = buffer_views[image["bufferView"]]
|
|
253
|
+
start = view.get("byteOffset", 0)
|
|
254
|
+
blob = binary[start:start + min(view.get("byteLength", 0), 4096)]
|
|
255
|
+
|
|
256
|
+
if not blob:
|
|
257
|
+
unreadable += 1
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
size = image_size(blob)
|
|
261
|
+
if size is None:
|
|
262
|
+
unreadable += 1
|
|
263
|
+
elif not (is_power_of_two(size[0]) and is_power_of_two(size[1])):
|
|
264
|
+
not_pot.append("%s (%dx%d)" % (label, size[0], size[1]))
|
|
265
|
+
|
|
266
|
+
if external:
|
|
267
|
+
level = report.fail if is_glb else report.warn
|
|
268
|
+
level("texturas", "imagenes fuera del archivo: %s" % ", ".join(external[:5]))
|
|
269
|
+
if not_pot:
|
|
270
|
+
report.warn("texturas", "no son potencia de dos: %s" % ", ".join(not_pot[:5]))
|
|
271
|
+
if unreadable:
|
|
272
|
+
report.warn("texturas", "%d imagenes cuyo tamano no se pudo leer (formato no PNG/JPEG)" % unreadable)
|
|
273
|
+
if not external and not not_pot and not unreadable:
|
|
274
|
+
report.ok("texturas", "%d texturas, todas embebidas y potencia de dos" % len(images))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def check_animations(doc, report):
|
|
278
|
+
animations = doc.get("animations", [])
|
|
279
|
+
if not animations:
|
|
280
|
+
return
|
|
281
|
+
autogen = [a.get("name", "") for a in animations if a.get("name", "").split(".")[-1].isdigit()]
|
|
282
|
+
unnamed = sum(1 for a in animations if not a.get("name"))
|
|
283
|
+
report.ok("animaciones", "%d clips: %s" % (
|
|
284
|
+
len(animations),
|
|
285
|
+
", ".join(a.get("name") or "<sin nombre>" for a in animations[:8]),
|
|
286
|
+
))
|
|
287
|
+
if unnamed:
|
|
288
|
+
report.warn("animaciones", "%d clips sin nombre - llegan al engine como Animation_N" % unnamed)
|
|
289
|
+
if autogen:
|
|
290
|
+
report.warn("animaciones", "nombres autogenerados: %s" % ", ".join(autogen[:5]))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def check_target(doc, report, target):
|
|
294
|
+
if target == "ue5":
|
|
295
|
+
if doc.get("materials"):
|
|
296
|
+
report.warn(
|
|
297
|
+
"target ue5",
|
|
298
|
+
"glTF trae normal maps OpenGL (+Y) y UE5 espera DirectX (-Y): invertir el canal "
|
|
299
|
+
"verde o marcarlo en el import",
|
|
300
|
+
)
|
|
301
|
+
report.warn("target ue5", "UE5 usa centimetros: verificar el factor de escala en el import")
|
|
302
|
+
elif target == "web":
|
|
303
|
+
report.warn("target web", "para web, correr draco/meshopt sobre el .glb antes de publicar")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def main():
|
|
307
|
+
parser = argparse.ArgumentParser(description="Valida un .glb/.gltf exportado desde Blender.")
|
|
308
|
+
parser.add_argument("path", help="archivo .glb o .gltf")
|
|
309
|
+
parser.add_argument("--budget-tris", type=int, default=None, help="presupuesto de triangulos")
|
|
310
|
+
parser.add_argument("--max-materials", type=int, default=None, help="maximo de materiales (= draw calls)")
|
|
311
|
+
parser.add_argument("--require-uv1", action="store_true", help="exigir UV1 para lightmap")
|
|
312
|
+
parser.add_argument("--target", choices=("unity", "ue5", "web"), default=None)
|
|
313
|
+
parser.add_argument("--json", action="store_true", help="salida JSON en lugar de texto")
|
|
314
|
+
args = parser.parse_args()
|
|
315
|
+
|
|
316
|
+
if not os.path.exists(args.path):
|
|
317
|
+
print("no existe: %s" % args.path, file=sys.stderr)
|
|
318
|
+
return 2
|
|
319
|
+
|
|
320
|
+
report = Report()
|
|
321
|
+
try:
|
|
322
|
+
doc, binary = load_gltf(args.path)
|
|
323
|
+
except (ValueError, KeyError, struct.error, json.JSONDecodeError) as error:
|
|
324
|
+
report.fail("contenedor", "no se pudo leer: %s" % error)
|
|
325
|
+
emit(report, args, None)
|
|
326
|
+
return 1
|
|
327
|
+
|
|
328
|
+
is_glb = args.path.lower().endswith(".glb")
|
|
329
|
+
base_dir = os.path.dirname(os.path.abspath(args.path))
|
|
330
|
+
|
|
331
|
+
check_geometry(doc, report, args.budget_tris)
|
|
332
|
+
check_uvs(doc, report, args.require_uv1)
|
|
333
|
+
check_scales(doc, report)
|
|
334
|
+
check_materials(doc, report, args.max_materials)
|
|
335
|
+
check_images(doc, binary, report, base_dir, is_glb)
|
|
336
|
+
check_animations(doc, report)
|
|
337
|
+
if args.target:
|
|
338
|
+
check_target(doc, report, args.target)
|
|
339
|
+
|
|
340
|
+
emit(report, args, doc)
|
|
341
|
+
return 1 if report.verdict == "FAIL" else 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def emit(report, args, doc):
|
|
345
|
+
generator = (doc or {}).get("asset", {}).get("generator", "desconocido")
|
|
346
|
+
if args.json:
|
|
347
|
+
print(json.dumps({
|
|
348
|
+
"file": args.path,
|
|
349
|
+
"generator": generator,
|
|
350
|
+
"verdict": report.verdict,
|
|
351
|
+
"checks": report.checks,
|
|
352
|
+
}, indent=2, ensure_ascii=False))
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
print("%s (%s)" % (os.path.basename(args.path), generator))
|
|
356
|
+
for check in report.checks:
|
|
357
|
+
print(" [%s] %-14s %s" % (check["level"], check["check"], check["message"]))
|
|
358
|
+
print(" VERDICT: %s" % report.verdict)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
sys.exit(main())
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: blender-materials
|
|
3
|
+
description: "Materiales y texturas en Blender para assets de juego: Principled BSDF, UVs y lightmap UV1, bake de procedurales a textura, color space, normal maps OpenGL vs DirectX, atlas y draw calls. Usar para: materiales blender, texturas, UV unwrap, bake, shader nodes, normal map."
|
|
4
|
+
category: "gamedev"
|
|
5
|
+
argument-hint: "[asset-slug] [--bake] [--target unity|ue5|web]"
|
|
6
|
+
user-invocable: true
|
|
7
|
+
allowed-tools: Read, Glob, Grep, Bash, Write, Edit
|
|
8
|
+
---
|
|
9
|
+
# blender-materials — Materiales y texturas
|
|
10
|
+
|
|
11
|
+
Deja el asset con materiales que **sobreviven al export**. La mitad de los bugs de look en el
|
|
12
|
+
engine se originan aca, y casi todos son color space o convencion de normal map.
|
|
13
|
+
|
|
14
|
+
> Requiere `/blender-context`. El destino (Unity / UE5 / web) cambia decisiones concretas de este
|
|
15
|
+
> skill, no es un detalle.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Phase 1: Que sobrevive al export
|
|
20
|
+
|
|
21
|
+
| En Blender | glTF / FBX | Que hacer |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| Principled BSDF con texturas | **Sobrevive** | Es el camino |
|
|
24
|
+
| Principled BSDF con valores planos | Sobrevive | Ok para assets sin textura |
|
|
25
|
+
| Nodos procedurales (Noise, Voronoi, Musgrave) | **No sobrevive** | Bakear a textura |
|
|
26
|
+
| Node groups custom | No sobrevive | Bakear |
|
|
27
|
+
| Shader mixes, Geometry / Object Info | No sobrevive | Bakear |
|
|
28
|
+
| Emission | Sobrevive (canal propio) | |
|
|
29
|
+
| Displacement real | No sobrevive | Bakear a normal, o meterlo en la malla |
|
|
30
|
+
|
|
31
|
+
Regla practica: **si no es Principled BSDF alimentado por Image Textures, no existe fuera de
|
|
32
|
+
Blender.** Antes de pulir un shader procedural, decidir si se bakea; si no se bakea, es tiempo
|
|
33
|
+
tirado.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Phase 2: UVs
|
|
38
|
+
|
|
39
|
+
Diagnostico headless:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
blender --background <archivo.blend> --python-expr "
|
|
43
|
+
import bpy
|
|
44
|
+
for o in bpy.data.objects:
|
|
45
|
+
if o.type != 'MESH': continue
|
|
46
|
+
uvs = [l.name for l in o.data.uv_layers]
|
|
47
|
+
print(f'{o.name}: uv_layers={uvs or \"NINGUNA\"} materials={len(o.material_slots)}')
|
|
48
|
+
"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **Sin UVs no hay texturas.** Es lo primero que se resuelve.
|
|
52
|
+
- **UV0**: el mapa de texturas. Puede solaparse si la textura es tileable; **no puede solaparse si
|
|
53
|
+
se va a bakear**.
|
|
54
|
+
- **UV1**: lightmaps. Unity y UE5 lo esperan **sin solapamiento y con margen** entre islas. Si el
|
|
55
|
+
asset va a recibir luz bakeada y no tiene UV1, el lightmap sale con manchas y nadie sabe por que.
|
|
56
|
+
- Texel density consistente dentro de un mismo set de assets. Un prop con el doble de densidad que
|
|
57
|
+
el de al lado se nota mas que un prop con menos poligonos.
|
|
58
|
+
- Margen entre islas proporcional a la resolucion: 2px a 1K, 4px a 2K. Menos margen = bleeding.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Phase 3: Color space — el bug silencioso
|
|
63
|
+
|
|
64
|
+
Esto no es preferencia, es correcto o incorrecto:
|
|
65
|
+
|
|
66
|
+
| Textura | Color space |
|
|
67
|
+
|---|---|
|
|
68
|
+
| Base Color / Albedo / Emission | **sRGB** |
|
|
69
|
+
| Normal | **Non-Color** |
|
|
70
|
+
| Roughness / Metallic / AO / Displacement / Mask | **Non-Color** |
|
|
71
|
+
|
|
72
|
+
Un Roughness en sRGB se ve *casi* bien en Blender y **mal** en el engine, con un error que no
|
|
73
|
+
parece de textura. Verificar antes de exportar:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
blender --background <archivo.blend> --python-expr "
|
|
77
|
+
import bpy
|
|
78
|
+
for img in bpy.data.images:
|
|
79
|
+
print(f'{img.name}: colorspace={img.colorspace_settings.name} size={tuple(img.size)}')
|
|
80
|
+
"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Y de paso: resoluciones potencia de dos. Una textura de 1000x1000 no se comprime igual que una de
|
|
84
|
+
1024x1024 y en mobile la diferencia es real.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Phase 4: Normal maps
|
|
89
|
+
|
|
90
|
+
| Destino | Convencion | Canal verde |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| Blender | OpenGL (+Y) | Como sale |
|
|
93
|
+
| glTF / GLB | OpenGL (+Y) | Como sale |
|
|
94
|
+
| Unity | OpenGL (+Y) | Como sale |
|
|
95
|
+
| **UE5** | **DirectX (-Y)** | **Invertido** |
|
|
96
|
+
|
|
97
|
+
Si el destino es UE5, hay dos salidas: invertir el canal verde en la textura, o marcarlo en el
|
|
98
|
+
import del engine. **Elegir una y anotarla** — el sintoma de tenerlo mal es iluminacion que parece
|
|
99
|
+
venir del lado opuesto, y se diagnostica tarde.
|
|
100
|
+
|
|
101
|
+
El nodo Normal Map en Blender necesita el Image Texture en Non-Color y su propio `Strength`. Un
|
|
102
|
+
Strength distinto de 1 no se exporta como tal: se hornea o se pierde.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Phase 5: Bake
|
|
107
|
+
|
|
108
|
+
Cuando hay procedurales que hay que conservar:
|
|
109
|
+
|
|
110
|
+
1. **UV0 sin solapamiento** (requisito, no sugerencia).
|
|
111
|
+
2. Crear la Image Texture destino con la resolucion final.
|
|
112
|
+
3. Bake en Cycles (EEVEE no bakea todo). Tipos utiles: `Diffuse` sin luces para albedo,
|
|
113
|
+
`Normal`, `Roughness`, `AO`, `Combined` solo si se quiere luz pegada a la textura.
|
|
114
|
+
4. **Margen** de bake para evitar costuras.
|
|
115
|
+
5. Si se bakea de high-poly a low-poly: `Selected to Active` con **cage** y `Extrusion` ajustada.
|
|
116
|
+
Sin cage, los bordes salen sucios.
|
|
117
|
+
6. Guardar la imagen a disco. Una imagen bakeada que quedo solo en memoria se pierde al cerrar y
|
|
118
|
+
no avisa.
|
|
119
|
+
|
|
120
|
+
Despues del bake, reemplazar los procedurales por las Image Textures y **verificar que el look no
|
|
121
|
+
cambio**. Si cambio, el bake esta mal, no el shader.
|
|
122
|
+
|
|
123
|
+
Un bake escribe imagenes al repo y puede pisar texturas existentes. **Pedir aprobacion antes de
|
|
124
|
+
escribir**, listando los archivos destino, y nunca sobrescribir una textura fuente sin confirmarlo.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Phase 6: Materiales y draw calls
|
|
129
|
+
|
|
130
|
+
Cantidad de materiales = cantidad de draw calls por objeto. Antes de sumar un material:
|
|
131
|
+
|
|
132
|
+
- ¿Se puede resolver con una mascara en la misma textura?
|
|
133
|
+
- ¿Se puede atlasear con los assets del mismo set?
|
|
134
|
+
|
|
135
|
+
Un prop con 6 materiales y uno con 1 material atlaseado se ven igual y cuestan distinto. En mobile
|
|
136
|
+
y VR la diferencia es la que decide si el frame entra.
|
|
137
|
+
|
|
138
|
+
Nombrar los materiales igual que el asset (`M_<asset>_<parte>`); un `Material.001` que llega al
|
|
139
|
+
engine es imposible de rastrear despues.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Phase 7: Resumen
|
|
144
|
+
|
|
145
|
+
Reportar por objeto: UV layers, materiales con nombre, texturas con su color space y resolucion,
|
|
146
|
+
que se bakeo, y convencion de normal map elegida.
|
|
147
|
+
|
|
148
|
+
**Verdict:**
|
|
149
|
+
- **PASS** — UVs presentes, color space correcto, sin procedurales sin bakear, potencias de dos.
|
|
150
|
+
- **CONCERNS** — funciona pero con deuda: falta UV1 y el asset recibe luz bakeada, materiales de
|
|
151
|
+
sobra, texturas no potencia de dos. Listar cada punto.
|
|
152
|
+
- **FAIL** — sin UVs, color space invertido, procedurales sin bakear que el look necesita, o
|
|
153
|
+
normal map con la convencion equivocada para el destino. Indicar el fix.
|
|
154
|
+
|
|
155
|
+
## Next Steps
|
|
156
|
+
|
|
157
|
+
- `/blender-animation` si el asset lleva rig
|
|
158
|
+
- `/blender-export` cuando materiales y UVs estan cerrados
|
|
159
|
+
- `/asset-audit` para validar naming y formatos ya integrado
|
|
160
|
+
- `/shader-dev` si el look necesita un shader propio en el engine, no una textura
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
> → Color space, convenciones de normal map y presupuesto de materiales estan en
|
|
165
|
+
> `.claude/rules/blender-pipeline.md`.
|