El flujo desde Blender: unwrap, bake, exportar
Los pasos concretos para hornear un lightmap y un mapa de oclusión en Blender y cargarlos en Three.js, con los ajustes exactos y los tres puntos donde el flujo se rompe.
El horneado es la parte de Three.js donde más tiempo se pierde por no saber exactamente qué botón hace qué, porque el error no se manifiesta hasta el final del proceso: unwrap, bake de cinco minutos, exportar, cargar, y entonces descubres que el pase era el equivocado y hay que repetirlo todo. Este es el recorrido completo con los ajustes concretos, y con los tres sitios exactos donde el flujo se rompe.
- Crear un segundo juego de UVs en Blender adecuado para lightmap.
- Configurar un bake de Cycles del pase correcto y con la resolución correcta.
- Exportar a glTF de forma que el segundo juego de UVs llegue como
TEXCOORD_1. - Diagnosticar los tres fallos típicos por su síntoma visual.
Paso 1: el segundo juego de UVs
En el panel Object Data Properties, sección UV Maps, cada objeto tiene una lista. El primero, normalmente llamado UVMap, es el de material. Añade un segundo con el botón + y llámalo Lightmap.
Con el nuevo mapa seleccionado, en el modo edición y con toda la geometría seleccionada:
U→ Smart UV Project para geometría orgánica o compleja. Ajusta Angle Limit a unos 66 grados, Island Margin a 0.02 y activa Scale to Bounds.U→ Lightmap Pack para arquitectura de caras planas. Reparte en el cuadrado con márgenes uniformes y suele dar mejor aprovechamiento en interiores.
El Island Margin es el parámetro que decide si vas a tener costuras. Su valor está en fracción del espacio UV, así que depende de la resolución final: para un mapa de 1024, un margen de 0.02 son unos 20 texels, que es generoso y correcto si vas a usar mipmaps.
Antes de seguir, mira el unwrap en el editor de UVs y comprueba dos cosas: que ninguna isla se sale del cuadrado [0,1], y que la densidad es razonablemente uniforme —una pared grande debe ocupar más espacio UV que una silla pequeña, en proporción a su superficie real—.
El exportador de glTF numera los mapas UV por su posición en la lista, no por su nombre. El primero se exporta como TEXCOORD_0 y el segundo como TEXCOORD_1. Si por lo que sea el lightmap acabó el primero, en Three.js llegará como uv y el de material como uv1, con el desastre correspondiente. Arrastra para reordenar antes de exportar.
Paso 2: el bake en Cycles
El horneado solo existe en Cycles, no en EEVEE. En Render Properties, cambia Render Engine a Cycles.
Crea la textura destino: en el editor de shaders del material, añade un nodo Image Texture, pulsa New, dale nombre, resolución —1024 o 2048— y desactiva Alpha. Deja ese nodo seleccionado y sin conectar a nada: Cycles hornea en el nodo de imagen activo del material, y conectarlo lo convertiría en parte del shading.
En Render Properties → Bake, los ajustes que importan:
| Ajuste | Valor para lightmap | Valor para AO |
|---|---|---|
| Bake Type | Diffuse |
Ambient Occlusion |
| Contributions | solo Indirect | no aplica |
| Influence: Direct | desactivado | no aplica |
| Influence: Color | desactivado | no aplica |
| Margin Type | Adjacent Faces |
Adjacent Faces |
| Margin | 16 px | 16 px |
| Samples (en Sampling → Render) | 256 a 1024 | 64 a 128 |
| Denoise | activado | activado |
La fila crítica es Color desactivado. Con Color activo, el resultado incluye el albedo de la superficie, y como el shader de Three.js vuelve a multiplicar por el albedo, el color se eleva al cuadrado y la escena sale oscura y saturada. Un lightmap correcto es una textura de tonos de luz, casi sin color propio salvo el que aportan los rebotes.
La fila de Direct depende de qué flujo elijas. Si vas a mantener la DirectionalLight encendida en Three.js —porque necesitas sombras dinámicas—, hornea solo Indirect y deja que el directo se calcule en tiempo real. Si vas a apagar todas las luces, hornea Direct e Indirect.
Pulsa Bake. Cuando termine, en el editor de imágenes: Image → Save As, en formato PNG de 16 bits si vas a hacer post-proceso, o WebP si vas directo a producción.
Paso 3: exportar
En File → Export → glTF 2.0, los ajustes que hay que revisar:
- Format:
glTF Binary (.glb)si quieres un solo fichero,glTF Separatesi prefieres controlar las texturas a mano. - Include → Selected Objects si solo exportas parte de la escena.
- Data → Mesh → UVs: activado. Sin esto no viaja ningún juego de UVs.
- Data → Material → Materials:
Exportpara que viajen los materiales.
El lightmap no se exporta solo. glTF 2.0 no tiene un slot estándar para lightmaps, así que el exportador no sabe qué hacer con él. Hay dos caminos:
Camino A: cargar el lightmap aparte. Exportas el modelo sin lightmap y lo aplicas en Three.js. Es lo más común y lo más controlable.
const gltf = await new GLTFLoader().loadAsync( '/modelos/salon.glb' );
const lightmap = await new THREE.TextureLoader().loadAsync( '/bake/salon_lm.webp' );
lightmap.flipY = false;
lightmap.colorSpace = THREE.SRGBColorSpace;
lightmap.channel = 1; // lee de uv1
gltf.scene.traverse( ( obj ) => {
if ( ! obj.isMesh ) return;
obj.material.lightMap = lightmap;
obj.material.lightMapIntensity = 1.0;
obj.material.needsUpdate = true;
} );
scene.add( gltf.scene );
Camino B: colarlo como oclusión. En Blender, conectas el lightmap al slot de Occlusion del material y activas el segundo UV. El exportador lo mete en occlusionTexture con su texCoord: 1, y GLTFLoader lo carga como aoMap. Después, en Three.js, lo mueves al slot correcto. Funciona, pero es un apaño y confunde a quien lea el fichero.
texture.channel selecciona de qué juego de UVs lee esa textura concreta: 0 para uv, 1 para uv1. Es más limpio que asumir que el slot lightMap siempre lee de uv1, y es lo que usa GLTFLoader internamente para respetar el texCoord de glTF.
Los tres puntos de rotura, por su síntoma
La iluminación aparece del revés, con la luz de la ventana en el suelo. Es flipY. Los lightmaps cargados con TextureLoader llevan flipY = true por defecto y hay que ponerlo a false si las UVs vinieron de un glTF. Si el lightmap viene dentro del propio glTF, GLTFLoader ya lo gestiona y no hay que tocar nada.
La escena sale mucho más oscura y saturada de lo que se veía en Blender. Es el albedo contado dos veces. Vuelve al bake y desactiva Color en las contribuciones del pase Diffuse.
Hay líneas oscuras en todas las aristas. Es margen insuficiente, y empeora al alejarse porque los mipmaps amplían el radio de contaminación. Sube el Margin del bake a 16 o 32 píxeles, o desactiva los mipmaps del lightmap con lightmap.generateMipmaps = false y lightmap.minFilter = THREE.LinearFilter como parche provisional.
El flujo que enseñan todos los tutoriales es hornear un lightmap por objeto: una textura para el suelo, otra para cada pared, otra para la mesa. Es lo más fácil de montar y lo peor que le puedes hacer a tu escena. Cada lightmap es una textura distinta, y dos objetos con texturas distintas no se pueden fusionar en un draw call, ni siquiera si comparten todo lo demás. Un interior con treinta objetos horneados por separado son treinta materiales, treinta texturas y treinta draw calls como suelo absoluto, además de treinta peticiones de red y treinta unidades de textura consumidas. Lo que hace un motor de verdad es lo contrario: un único atlas de lightmap para toda la escena estática, donde cada objeto ocupa su región del cuadrado UV. Con eso, los treinta objetos comparten material y textura y se pueden fusionar en uno solo con BufferGeometryUtils.mergeGeometries, con lo que la escena estática entera pasa a un draw call. En Blender esto se consigue seleccionando todos los objetos estáticos, entrando en modo edición con todos a la vez y haciendo el unwrap del segundo juego de UVs con la selección completa: Smart UV Project reparte las islas de todos los objetos en el mismo cuadrado, y el bake posterior escribe todos en la misma imagen si comparten el mismo nodo de textura destino. Cuesta más de montar y obliga a decidir la resolución del atlas de golpe, pero convierte la parte estática de la escena en algo que se dibuja de una sola vez. La diferencia en un móvil entre treinta draw calls y uno no es del veinte por ciento: en una escena limitada por CPU, es todo.