Rendering
¿Cómo renderiza UE4 un frame? Conocer el proceso de rendering permite poner orden a todas las técnicas que se usan en UE4 además de cuales son las partes más sensibles para optimizar.
Este post es un resumen de la masterclass de Homam Bahnassi, diseccionado y ampliado con apuntes tomados de la documentación oficial.
Los hilos
El rendering es un proceso extremadamente paralelo. Ocurre en múltiples hilos. Los hilos principales son CPU (Game), CPU (Draw) y GPU.
Empecemos por el primero de ellos:
CPU (Game)
En CPU (Game) es responsable de calcular toda la lógica y las transformadas de los objetos:
- Animaciones
- Posiciones de modelos y objetos
- Físicas
- AI
- Spawn & Destroy, Hide & Show
En definitiva, cualquier cosa relacionada con la posición de los objetos a cambiar.
CPU (Draw)
Ahora UE4 conoce la posición de cada objeto, pero antes de enviar los datos (texturas, modelos, etc) a la GPU se hace un filtrado, esto es, decir que modelos vamos a incluir para renderizar y cuales ignorar (ignorar este filtrado podría hacer excesivamente caro el hilo de la GPU).
Para ello el Engine nos ofrece distintas técnicas de culling:
- Distance Culling
- Frustum Culling
- Precomputed Visibility
- Occlusion Culling
Distance Culling
Como su propio nombre indica, los objetos no se renderizan si están a más de cierta distancia. También se puede hacer que no se rendericen si están a menos de cierta distancia. Estas distancias se pueden cambiar en todos los actores en el parámetro:
Por defecto no se hace Distance Culling (tanto el parámetro Min Draw Distance como el Desired Max Draw Distance, están por defecto a 0, en el caso del segundo el valor 0 significa infinito).
En vez de usar esta propiedad por cada actor, se puede hacer Distance Culling de una manera más genérica usando el actor Cull Distance Volume.
Todos los actores contenidos en este volumen serán culled en función de su tamaño (la arista más larga en su bounding box) y su distancia a la cámara.
Estos tamaños y distancias a la cámara se configuran en el actor Cull Distance Volume:
En el ejemplo, se definen 3 tamaños cercanos: 50, 120 y 300 cada uno asociado a unas respectivas distancias.
Todos los actores contenidos en el volumen con el tamaño más cercano a 50 unidades (es decir, cuyo tamaño sea menor de 85, ya que si es mayor de 85 el tamaño sería más cercano a los 120 que es la segunda entrada del array) serán ocluidos si están a una distancia de 500 unidades o más lejano de la cámara.
Todos los actores contenidos en el volumen con el tamaño más cercano a 120 (esto es, entre 85 y 210, ya que menos de 85 el tamaño sería más cercano a 50 y más de 210 el tamaño sería más cercano a 300) cuya distancia a la cámara sea mayor de 1000 serán ocluidos.
Por último, todos los objetos contenidos en el volumen cuyo tamaño sea el más cercano a 300 (es decir, 210 o superior) nunca serán ocluidos. Aquí la distancia cero significa infinito.
Frustum Culling
Frustum Culling chequea los objetos en frente de la cámara. Cuando mayor FoV más objetos a renderizar y menos culled. Esta técnica de culling se hace automáticamente. Puedes ver el frustum de la cámara en el menu Show del editor en Advanced.
Precomputed Visibility
Es una técnica compleja, básicamente divide la escena en un grid y para cada celda recuerda que es visible en esa localización.
Gracias a ello se pueden responder preguntas complejas como que objetos están ocluidos por cuales otros en función del ángulo y posición de la cámara. Al ser una técnica de precalculado solo se hace con objetos estáticos.
Para hacer uso de la técnica hay que colocar el volumen Precomputed Visibility Volume. Una vez colocado hay que precomputar (al igual que se hace con las luces) en Build > Precompute Visiblity. Se puede ver el grid en el menu Show del editor en Advanced.
Occlusion Culling
Por último, el Dynamic Occlusion Culling que chequea el estado de visibilidad de cada objeto o si está ocluido por otro.
Aunque se hace mayoritariamente en CPU también hay algunas partes gestionadas por la GPU.
Algunos comandos interesantes para ver el culling:
freezerendering
Este comando congela el proceso de rendering y por tanto puedes mover la cámara libremente y ver que partes se han ocluido.
Con el comando:
stat initviews
Nos muestra el coste.
En este punto UE4 sabe la posición de cada objeto y que objetos serán renderizados o no.
GPU
El siguiente paso, ahora en GPU, es el Early Z Pass.
Del mismo modo que hemos excluido objetos para renderizar, necesitamos hacer algo parecido para los pixels.
Para hacer esto se genera un depth pass y se usa para determinar que pixels están visibles y cuales ocluidos.
Drawcalls
El siguiente paso para renderizar la geometría son los drawcall.
La GPU renderiza en drawcall por drawcall, no por triangulo a triángulo. Un drawcall, por tanto, es un grupo de triángulos que comparten algunas propiedades.
Los drawcall son preparados por el hilo CPU (Draw) y enviados a la GPU uno a uno.
En la imagen superior, la geometría de la izquierda se puede pintar con un drawcall, los triángulos de lo tres cilindros comparten todos los mismas propiedades (mismo material), mientras que en la derecha al menos necesita dos drawcall (el último cilindro tiene dos materiales).
Aquí la lista de drawcall en una escena de ejemplo:
Los drawcall son caros, entre 2000-3000 drawcalls son razonables en un PC/consola, más de 5000 son demasiados y más de 10.000 son un problema.
En móviles es otra historia, unos cientos como máximo. Los drawcalls son determinados por los objetos visibles.
En UE4 se pueden medir con el comando
stat RHI
Para optimizar el número de drawcalls:
- Intentar usar LowPoly, compartir mismo material, mezclar meshes del mismo area.
- Usar HLOD (Hierarical Level Of Detail). En LOD un modelo se intercambia por otro más simple (en nºtris y material) a partir de una determinada distancia. En HLOD además se mezclan modelos en la distancia y por tanto son menos drawcalls.
- Usar Instancied Rendering: agrupar manualmente objetos juntos dentro de un simple drawcall. En UE4 con el component Intancied Static Mesh.
Y entonces entra el juego el vertex shader + fragment shader para rasterizar la imagen.
Vertex & Fragment Shader
En un pixel puede haber influido (escrito el color) el fragment shader de distintos drawcalls. A esto se le llama quad overdraw y puede visualizarse en UE en el menú show.
En deferred shading el resultado de VS+FS es almacenado en el geometry buffer (GBuffer) que también se pueden visualizar en UE en el menú show y se usa para calcular la iluminación en la escena y, en definitiva, asignar el color final de cada pixel.
Empezamos con las luces y sombras dinámicas:
La dynamic lighting (deferred shading) es calculada en los fragment shaders. Cada luz puntual tiene un máscara en forma de esfera para indicar que pixels están afectados.
Considerando los atributos de la luz (color, falloff, intensidad, etc.,) el pixel shader hace:
En este punto ya tenemos la escena iluminada de manera básica. El siguiente paso: las sombras.
Sombras
La técnica más común para calcular las sombras en real-time es el Shadow Maps. La idea básica es chequear para cada pixel si es visible para una luz dada. Si el pixel está ocluido desde el punto de vista de la luz entonces es una sombra. Se calcula un depth buffer desde el punto de vista de la luz.
En la imagen superior la escena, a la izquierda el depth buffer desde el punto de vista de la cámara y a la derecha desde el punto de vista de la luz.
Las luces y sombras dinámicas tienen una serie de pros y contras:
Pros
- Es renderizado en tiempo real usando el GBuffer
- Las luces pueden cambiar, mover, añadirse o eliminarse
- No necesita ninguna preparación especial del modelo
Cons
- Las sombras dinámicas son MUY caras
Las luces y sombras estáticas son precalculadas y almacenadas en unas texturas llamadas lightmaps. Estas texturas son multiplicadas por el basecolor.
Aquí más información de como funciona la iluminación en UE4.