Introducción a los flujos de trabajo de activos 3D
En el desarrollo de videojuegos modernos, la gestión eficiente de activos 3D, desde su creación hasta su renderizado final, es fundamental para el rendimiento y la calidad visual. Este artículo explora los pipelines y sistemas centrales ivnolucrados en el procesamiento de datos de malla dentro de un motor de videojuegos comercial.
Generación de bases tangentes y espacio de tengentes
El espacio de tangentes es un sistema de coordenadas local ligado a la superficie de un modelo, crucial para el mapeo de normales. Está definido por tres vectores: la normal (perpendicular a la superficie), la tangente (alineada con el eje U de textura) y la bitangente (alineada con el eje V). El cálculo de estos vectores se realiza típicamente en la CPU durante la importación o en el editor.
A continuación, se presenta un ejemplo de cómo podría estructurarse un constructor de bases tangetnes:
// Clase para generar la base ortonormal del espacio de tangentes por triángulo
class TangentBasisBuilder
{
public:
struct Basis
{
Vec3 normal;
Vec3 tangent;
Vec3 bitangent;
};
static Basis compute_for_triangle(const Vec3 p[3], const Vec2 uv[3])
{
Basis basis;
// Vectores de los bordes del triángulo
Vec3 edge1 = p[1] - p[0];
Vec3 edge2 = p[2] - p[0];
// Diferencias en coordenadas UV
Vec2 delta_uv1 = uv[1] - uv[0];
Vec2 delta_uv2 = uv[2] - uv[0];
// Cálculo del determinante para la inversión
float det = delta_uv1.x * delta_uv2.y - delta_uv2.x * delta_uv1.y;
if (std::abs(det) < 1e-6f) det = 1.0f; // Evitar división por cero
float inv_det = 1.0f / det;
// Cálculo de los vectores tangente y bitangente (Mikktspace)
basis.tangent.x = inv_det * (delta_uv2.y * edge1.x - delta_uv1.y * edge2.x);
basis.tangent.y = inv_det * (delta_uv2.y * edge1.y - delta_uv1.y * edge2.y);
basis.tangent.z = inv_det * (delta_uv2.y * edge1.z - delta_uv1.y * edge2.z);
basis.bitangent.x = inv_det * (-delta_uv2.x * edge1.x + delta_uv1.x * edge2.x);
basis.bitangent.y = inv_det * (-delta_uv2.x * edge1.y + delta_uv1.x * edge2.y);
basis.bitangent.z = inv_det * (-delta_uv2.x * edge1.z + delta_uv1.x * edge2.z);
// La normal se calcula como el producto cruz de los bordes
basis.normal = edge1.cross(edge2).normalized();
// Gram-Schmidt: reortogonalizar la tangente respecto a la normal
basis.tangent = (basis.tangent - basis.normal * basis.normal.dot(basis.tangent)).normalized();
// Verificar la mano (handedness) y corregir si es necesario
if (basis.normal.cross(basis.tangent).dot(basis.bitangent) < 0.0f)
{
basis.tangent = basis.tangent * -1.0f;
}
return basis;
}
};
En el shader de vértices, estos vectores se transforman del espacio local al espacio mundial para permitir el cálculo de iluminación con mapas de normales.
// Estructuras de datos y shader de vértices (ejemplo conceptual)
struct VertexIn
{
float3 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
float3 tangentOS : TANGENT;
};
struct VertexOut
{
float4 positionCS : SV_Position;
float3 positionWS : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float2 uv : TEXCOORD2;
float3x3 TBN : TEXCOORD3; // Matriz Tangent-Bitangent-Normal en espacio mundial
};
VertexOut VS_Main(VertexIn v, uint instID : SV_InstanceID)
{
VertexOut o;
// Obtener matrices de transformación del mundo y cámara
float4x4 worldMat = GetInstanceWorldMatrix(instID);
float4x4 viewProjMat = GetCameraViewProjection();
// Transformar posición a espacio de clip
o.positionCS = mul(float4(v.positionOS, 1.0f), mul(worldMat, viewProjMat));
o.positionWS = mul(float4(v.positionOS, 1.0f), worldMat).xyz;
// Transformar vectores base usando la matriz del mundo (considerar la inversa transpuesta para normales)
float3x3 worldMat3 = (float3x3)worldMat;
o.normalWS = normalize(mul(v.normalOS, worldMat3));
float3 tangentWS = normalize(mul(v.tangentOS, worldMat3));
// Recalcular bitangente en el mundo para asegurar ortogonalidad
float3 bitangentWS = normalize(cross(o.normalWS, tangentWS));
// Construir matriz TBN
o.TBN = float3x3(tangentWS, bitangentWS, o.normalWS);
o.uv = v.uv;
return o;
}
float4 PS_Main(VertexOut i) : SV_Target
{
// Muestrear normal del mapa en espacio tangente y transformar a espacio mundial
float3 normalTS = normalize(tex2D(normalMapSampler, i.uv).rgb * 2.0f - 1.0f);
float3 finalNormal = normalize(mul(normalTS, i.TBN));
// Cálculo de iluminación difusa simple (ejemplo)
float3 lightDir = normalize(lightPosition - i.positionWS);
float NdotL = saturate(dot(finalNormal, lightDir));
float3 albedo = tex2D(albedoSampler, i.uv).rgb;
return float4(albedo * NdotL * lightColor, 1.0f);
}
Organización de datos de malla y buffer
Los motores comerciales gestionan los datos geométricos mediante buffers optimizados para la GPU. Los vértices se agrupan en buffers de vértices y los índices en buffers de índices, separando a menudo los atributos para reducir el tamaño de los lotes y mejorar la coherencia de caché.
Una estructura de vértice típica podría verse así, con campos para esqueleto y peso:
// Definición compacta de un vértice con soporte para animación esquelética
struct MeshVertex
{
// Posición y normal (12 bytes + 12 bytes)
float px, py, pz;
float nx, ny, nz;
// Coordenadas UV y tangente (8 bytes + 12 bytes)
float u, v;
float tx, ty, tz;
// Color de vértice y datos de hueso (4 bytes + 8 bytes)
uint32_t color;
uint8_t boneIndices[4];
float boneWeights[4];
};
El sistema de buffers se encarga de la creación, actualización y liberación de los recursos de la GPU. Los buffers pueden ser estáticos (para modelos inmutables) o dinámicos (para mallas deformables como personajes).
Sistema de componentes para renderizado
El renderizado de mallas se encapsula típicamente en un componente que se adjunta a un actor o entidad en la escena. Este componente gestiona el recurso de malla, sus materiales, el nivel de detalle (LOD) y los comandos de dibujo.
// Componente que gestiona el renderizado de una malla para una entidad
class MeshRenderComponent
{
private:
MeshAssetID meshAsset;
std::vector<MaterialInstanceID> materialSlots;
int currentLODIndex;
bool castsShadow;
bool receivesShadows;
// Matriz de transformación local al mundo, actualizada por el sistema de transformación
const Matrix4x4* worldTransformPtr;
public:
void submit_draw_calls(RenderQueue& queue, const Frustum& cameraFrustum)
{
if (!worldTransformPtr) return;
const auto& lodMesh = get_mesh_for_lod(currentLODIndex);
for (const auto& submesh : lodMesh.submeshes)
{
// Prueba de frustum para cada submalla
BoundingBox worldAABB = submesh.localAABB.transform(*worldTransformPtr);
if (!cameraFrustum.test_aabb(worldAABB)) continue;
DrawCommand cmd;
cmd.vertexBuffer = lodMesh.vertexBufferHandle;
cmd.indexBuffer = lodMesh.indexBufferHandle;
cmd.indexOffset = submesh.firstIndex;
cmd.indexCount = submesh.indexCount;
cmd.material = materialSlots[submesh.materialSlotIndex];
cmd.worldMatrix = *worldTransformPtr;
cmd.castShadow = castsShadow;
queue.push_back(cmd);
}
}
};
Pipeline de importación y procesamiento de FBX
FBX es un formato de intercambio común. El proceso de importación implica analizar el archivo, extraer la geometría, los materiales, las animaciones y las jerarquías de nodos, y convertirlo a un formato interno optimizado del motor.
El flujo general de importación podría seguir estos pasos:
- Inicializar el lector/parser de FBX.
- Leer la escena y aplicar transformaciones de sistema de coordenadas (por ejemplo, de Z-Up a Y-Up).
- Triangular todas las mallas si es necesario.
- Iterar sobre los nodos de la escena y extraer los datos de malla asociados a cada malla geométrica.
- Para cada vértice, extraer posición, normal, UV, color de vértice y datos de peso/esqueleto.
- Manejar múltiples canales de UV y mapas de vértices.
- Extraer los materiales y sus texturas asociadas, y convertirlos a los shaders y formatos de textura del motor.
- Calcular o validar las normales y el espacio de tangentes.
- Generar niveles de detalle (LOD) automáticamente si se solicita.
- Empaquetar todo en los formatos binarios de activos del motor (.mesh, .mat, .tex).
Este proceso suele estar automatizado en una herramienta de línea de comandos o integrado en el editor, permitiendo reimportar activos cuando cambian los archivos fuente.
Sistema de texturas y muestreo
Las texturas son uno de los recursos más pesados. Un sistema robusto incluye:
- Gestión de formatos: Soporte para formatos comprimidos en bloque (BCn) para ahorrar memoria y ancho de banda.
- Generación de Mipmaps: Creación automática de cadenas de mip para reducir aliasing y mejorar el muestreo a distancia.
- Streaming de texturas: Carga y descarga dinámica de niveles de mip basada en la distancia de la cámara al objeto, para gestionar la memoria de textura de manera inteligente.
- Atlas de texturas: Agrupación de múltiples texturas pequeñas en una sola textura grande para reducir los cambios de estado (binds) de la GPU durante el renderizado.
- Pool de samplers: Caché de objetos sampler de la GPU para configuraciones de filtrado y direccionamiento comunes, evitando su creación repetida.
// Ejemplo simplificado de un gestor de texture streaming
class TextureStreamer
{
struct StreamingTask
{
TextureID texture;
int requestedMipLevel;
float priority; // Calculada basado en visibilidad, tamaño en pantalla, etc.
};
std::priority_queue<StreamingTask> taskQueue;
public:
void update(const Camera& cam)
{
// 1. Para cada textura visible, calcular su nivel de mip ideal basado en su tamaño en pantalla.
// 2. Comparar con el nivel de mip actualmente cargado en GPU.
// 3. Si difiere, añadir una tarea a la cola con prioridad.
// 4. Procesar la cola, cargando/descargando niveles de mip asíncronamente.
}
};
Validación y optimización de activos
Antes de que un modelo 3D se utilice en el juego, debe pasar por un proceso de validación y optimización. Esto incluye:
- Condiciones geométricas: Verificar que no haya triángulos degenerados, vértices aislados o normales inconsistentes.
- Requisitos de UV: Asegurar que las coordenadas UV estén dentro de rangos razonables y que no haya solapamientos no deseados.
- Rendimiento: Optimizar la topología de la malla (decimación para LODs), comprimir texturas al formato adecuado y verificar el número total de vértices y triángulos por lote.
- Convenciones del motor: Validar la escala (ej., 1 unidad = 1 metro), la orientación del eje frontal y los nombres de los materiales y texturas.
Las herramientas de línea de comandos o scripts del editor pueden automatizar esta validación, proporcionando informes de errores y advertencias para que los artistas corrijan los problemas.