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.
@@ -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`.