Imagen de cabecera: los edificios de Manhattan, descargados desde Cesium ion y dibujados con el nuevo renderizado de Evergine.Cesium

Hace unos meses presentamos el add-on Evergine.Cesium, que permite llevar a cualquier aplicación .NET una Tierra digital completa, descargada en streaming. Desde entonces hemos dedicado casi todo el trabajo a una sola cosa: que vaya más rápido. Hemos rehecho de arriba abajo la forma en que el add-on convierte los tiles de Cesium en píxeles, y el resultado es un renderizado que carga con más fluidez, dibuja muchos más tiles con el mismo tiempo de CPU y consume memoria de forma mucho más predecible.

La idea de fondo es sencilla: los tiles han dejado de ser entidades de la escena. Su geometría se guarda en un gran buffer compartido y sus imágenes en un único atlas de texturas. Es la GPU la que decide qué se dibuja, y lo hace con un solo draw indirecto para todos los tiles visibles, mientras Cesium Native trabaja en su propio hilo sin quitarle tiempo al frame. En las próximas secciones te contamos por qué hemos tomado cada decisión y cómo encajan todas las piezas.

En este artículo

Un globo terráqueo no es un nivel de videojuego

Un globo 3D se descarga por partes, los llamados tiles. Cesium Native sabe dónde está la cámara y, a partir de ahí, decide qué trozos de terreno, de imagen y de edificios hacen falta y con cuánto detalle. Basta con sobrevolar una ciudad para tener cientos de tiles en pantalla a la vez, y en cuanto la cámara se mueve muchos de ellos cambian: llegan otros nuevos por la red y los que ya no se ven se descartan.

Eso no se parece en nada a lo que hace un videojuego típico. Un nivel carga sus mallas al principio y se pasa minutos dibujándolas. Un globo, en cambio, está creando y destruyendo recursos de GPU sin parar mientras renderiza: sube cada tile que llega, libera cada tile que se va y tiene que hacerlo todo sin que se resientan los frames por segundo. Por eso no basta con dibujar rápido. Lo que de verdad marca la diferencia es lo poco que cueste traer un tile, mantenerlo en memoria y deshacerse de él.

Los límites del enfoque anterior

La primera versión del add-on optó por el camino más directo: tratar cada tile como un modelo más de Evergine. Cesium nos daba el tile en formato glTF; nosotros lo escribíamos en memoria como un fichero GLB y lo volvíamos a leer con el cargador de modelos de Evergine. Ese cargador montaba una entidad con su malla y su material, la entidad se añadía a la escena y cada imagen acababa siendo una textura independiente que después se asignaba al material que le tocaba. Y todo ello, incluida la actualización de Cesium y las colas que añadían y quitaban entidades, ocurría en el hilo principal.

Era una solución sencilla que funcionaba bien en escenas pequeñas, pero que no escalaba. Cada tile pagaba la conversión a GLB y la creación de entidades, mallas, materiales y texturas nuevas, así que cuando llegaban muchos de golpe el frame daba tirones. Además, cada tile se dibujaba por separado en cada pasada: con cientos de ellos en pantalla, la CPU se pasaba buena parte del frame enviando draw calls y, con las sombras activadas, el coste se multiplicaba. A eso había que sumar los miles de buffers y texturas pequeñas que se creaban y destruían cada minuto, que hacían imposible prever cuánta memoria se iba a usar, y el hecho de que todo compartía hilo con la propia aplicación.

El nuevo renderizado, de un vistazo

Ahora los tiles se tratan como datos, no como entidades. En lugar de crear recursos nuevos para cada uno, el add-on reserva unos pocos recursos grandes al arrancar y va repartiendo trozos de ellos según los tiles entran y salen.

Diagrama: antes, cada tile se convertía en un GLB, una entidad, una malla, un material y una textura con su propio draw call; ahora todos los tiles comparten un buffer y un atlas, y se dibujan con un único draw indirecto

Para tu aplicación apenas cambia nada: añades un CesiumCoordinator a la escena y el globo aparece, como siempre. Lo que ha cambiado, y mucho, es lo que pasa por debajo.

Qué ha cambiado

Toda la geometría en un mismo buffer

La geometría de todos los tiles vive ahora en un único vertex buffer y un único index buffer que se crean una sola vez. Cuando llega un tile, el add-on lee sus posiciones, normales, coordenadas de textura e índices directamente de los accessors glTF que ofrece Cesium, sin pasar por GLB. Después los empaqueta en un formato de vértice intercalado y los copia tal cual en el hueco que le corresponde dentro de los buffers compartidos.

Repartir esos huecos tiene que ser muy rápido, porque se hace con cada tile que llega. Para eso usamos una adaptación a C# del OffsetAllocator de Sebastian Aaltonen, pensado justo para este tipo de trabajo. Agrupa el espacio libre por tamaños, de forma que reservar o liberar un bloque cuesta siempre lo mismo, esté el buffer vacío o casi lleno, y la fragmentación se mantiene baja. Cada tile tiene además una pequeña ficha en un buffer de metadatos en la GPU, donde se apunta dónde están sus vértices, sus índices y sus imágenes y cuál es su transformación. Así, añadir o quitar un tile solo afecta a su propia ficha.

Todas las imágenes en un mismo atlas

Las imágenes, como las fotos de satélite, se aplican encima del terreno, y Cesium las va entregando en trozos pequeños. Antes cada trozo era una textura; ahora el add-on los coloca todos en un único atlas de gran tamaño. El atlas se organiza por filas, a modo de estanterías, y cada imagen se coloca en la fila donde mejor encaja. Cuando un tile se libera, su hueco queda disponible para la siguiente imagen.

Las imágenes llegan al atlas sin frenar a la GPU. Los hilos de carga de Cesium las escriben en un staging buffer, y hay dos que se van turnando: mientras la CPU llena uno, la GPU lee del otro. Una vez por frame, un compute shader coloca cada imagen pendiente en su sitio dentro del atlas y, de paso, convierte sus colores de sRGB a espacio lineal. Cada imagen va rodeada además de un borde de cuatro texels que repite los píxeles del contorno; así el filtrado nunca mezcla colores de imágenes vecinas y no aparecen costuras entre tiles.

La GPU decide qué se dibuja

Aquí está el cambio más importante. En un renderizado clásico, la CPU recorre todos los objetos y prepara un draw call para cada uno. Con cientos de tiles y varias pasadas, ese bucle acaba siendo el cuello de botella. Por eso lo hemos llevado a la GPU.

Cuando Cesium ya ha elegido los tiles de la vista actual, a la CPU solo le queda escribir una máscara de visibilidad con un bit por tile. En la GPU, un compute shader lanza un hilo por tile y mira ese bit. Cada tile visible se reserva un hueco y escribe ahí su comando de dibujo, dentro de un buffer de argumentos indirectos. Ese mismo shader aprovecha para calcular las matrices de mundo y de mundo-vista-proyección de cada tile, así el vertex shader no tiene que repetir la multiplicación vértice a vértice. Al final, un único draw indirecto dibuja todos los tiles visibles de una vez, tomando la geometría de los buffers compartidos y las imágenes del atlas.

Diagrama: el hilo de Cesium escribe una máscara de visibilidad, un compute shader la convierte en comandos de dibujo y transformaciones, y un único draw indirecto pinta todos los tiles visibles

La consecuencia es que el trabajo de la CPU ya no depende de cuántos tiles haya en pantalla. Da igual que sean cincuenta o quinientos: escribe una pequeña máscara de bits, lanza un compute shader y emite un draw por pasada. Y si en algún momento necesitas comparar resultados o perseguir un problema de drivers, la opción UseCPUDrawCalls vuelve a generar los comandos de dibujo en la CPU, sin renunciar a los buffers compartidos ni al atlas.

Cesium, en su propio hilo

Cesium Native tiene ahora un hilo para él solo. En cada frame, el hilo principal le pasa la posición de la cámara, y el hilo de Cesium elige los tiles, pone en marcha las cargas y prepara una nueva máscara de visibilidad. Al terminar cada actualización, los dos hilos se intercambian las máscaras, así que lo que se dibuja es siempre un conjunto de tiles completo y coherente.

Ese hilo trabaja con prioridad baja, de modo que, si la máquina va justa, tu frame tiene preferencia. Si una actualización tarda más de lo que dura un frame, no pasa nada: se sigue dibujando el último conjunto completo de tiles hasta que Cesium se pone al día. Las consultas de altura del terreno también pasan por este hilo, y sus resultados te llegan en la actualización de la escena.

Totalmente integrado en Evergine

Que los tiles se dibujen a su manera no significa que vayan por libre. Usan un efecto de ciudad propio que se apoya en la iluminación estándar de Evergine y participan en las pasadas habituales del motor. Proyectan y reciben sombras, así que se llevan bien con el nuevo sistema de sombras (artículo en inglés) y con un sol que va cambiando de posición a lo largo del día, y entran en la pre-pasada de profundidad como cualquier otra geometría opaca. Todo funciona en DirectX 11, DirectX 12 y Vulkan.

Saber qué hay en la GPU

Cuando se trabaja con buffers de tamaño fijo, conviene saber en todo momento cuánto queda libre. CesiumCoordinator.Diagnostics te dice cuántos tiles se ven en cada tileset y cuántos hay cargados en total, además de la capacidad y el uso del vertex buffer, del index buffer y del atlas de texturas. Con esos datos es muy fácil montar tu propio panel de profiling. El atlas, por su parte, está disponible en TextureAtlas, así que puedes mostrarlo en pantalla y ver en directo cómo se van colocando las imágenes.

Resultados

Para medir el efecto de todos estos cambios, comparamos la versión anterior del add-on con la nueva en igualdad de condiciones: la misma configuración de Cesium, con la cámara abarcando todo Madrid en una pantalla 4K, en un equipo con un Intel Core i7-14700K y una NVIDIA GeForce RTX 4080 SUPER. Para que la comparación se centrara en el dibujado de los tiles, desactivamos la pre-pasada de profundidad, la pasada de G-buffer y el postprocesado, y medimos los tiempos de frame con CapFrameX.

La mejora superó nuestras expectativas. De media, pasamos de 40,4 a 332,4 frames por segundo; dicho de otro modo, cada frame tarda ahora unos 3 ms en vez de casi 25. Es decir, unas 8,2 veces más rápido.

MétricaVersión anteriorNuevo renderizado
Media40,4 FPS (24,75 ms)332,4 FPS (3,01 ms)
P9544,1 FPS (22,68 ms)355,5 FPS (2,81 ms)
P537,8 FPS (26,46 ms)290,1 FPS (3,45 ms)
P135,9 FPS (27,86 ms)281,2 FPS (3,56 ms)
Media del 1% más lento33,8 FPS (29,59 ms)274,9 FPS (3,64 ms)
Media del 0,1% más lento30,7 FPS (32,57 ms)257,4 FPS (3,89 ms)

Pero la media no lo cuenta todo. Lo que de verdad se nota al usar una aplicación son los frames más lentos, los que provocan tirones, y para eso están las dos últimas filas, que promedian el 1% y el 0,1% de los frames más lentos. Ahí la mejora es igual de clara: esos frames han pasado de más de 32 ms a menos de 4. De hecho, el peor frame del nuevo renderizado es más de cinco veces más rápido que el mejor del anterior. Y la diferencia entre los frames rápidos (P95) y los más lentos (0,1%) ha bajado de unos 10 ms a apenas 1 ms. En resumen, el globo no solo va más rápido: también va mucho más estable.

Cómo empezar

Si ya usas el add-on, te basta con actualizar el paquete NuGet Evergine.Cesium para tener el nuevo renderizado. Si empiezas desde cero, añade un CesiumCoordinator a tu escena junto con los tilesets que quieras cargar:

using Evergine.Cesium;
using Evergine.Cesium.Core;
using Evergine.Cesium.Utils;
using Evergine.Framework;

public class MyScene : Scene
{
    public override void RegisterManagers()
    {
        base.RegisterManagers();

        // La escena necesita una entidad con la etiqueta "MainCamera" y un componente Camera3D.
        var cesium = new CesiumCoordinator()
        {
            AccessToken = "<TU_TOKEN_DE_CESIUM_ION>",
            GeocodingService = new AzureMapsGeocodingService("<TU_CLAVE_DE_AZURE_MAPS_OPCIONAL>"),
            EntityManager = this.Managers.EntityManager,
        };

        // Cesium World Terrain con imágenes de Bing Maps y los edificios de OpenStreetMap
        cesium.AddOverlayedTileset(1, RasterOverlayProvider.BingAerial, "Terrain");
        cesium.AddTileset(96188, "Buildings");

        this.Managers.AddManager(cesium);
    }
}

Si vienes de la versión anterior

El nuevo renderizado trae algunos cambios en la API. Los tilesets se añaden ahora de forma explícita: AddOverlayedTileset para terreno con imágenes encima y AddTileset para geometría, como los edificios. Puedes añadir todos los que necesites y quitarlos más tarde por nombre o por ID de asset.

La geocodificación también es ahora intercambiable. La propiedad AzureMapsKey desaparece en favor de GeocodingService, que admite cualquier implementación de IGeocodingService. El add-on trae AzureMapsGeocodingService y un nuevo GoogleMapsGeocodingService, y nada te impide escribir el tuyo.

Como los tiles ya no son entidades, si tu código los buscaba en la escena, ya no los va a encontrar. Para saber qué hay cargado tienes Diagnostics, y para consultar la altura del terreno, QueryTerrainMinHeight. Por último, el nuevo renderizado depende de compute shaders y efectos que se distribuyen con los assets del add-on, así que asegúrate de que el ensamblado y los assets sean de la misma versión.

Pruébalo y cuéntanos qué tal

¿Listo para volar más rápido? Actualiza Evergine, añade la última versión del paquete Evergine.Cesium y date una vuelta por tu ciudad favorita. Si es la primera vez que usas el add-on, la presentación de Evergine.Cesium es un buen punto de partida, y en la documentación de Evergine tienes la referencia completa. Si encuentras algún problema o se te ocurre una mejora, cuéntanoslo en el repositorio de feedback de Evergine.

Estamos deseando ver qué construyes con él.

José Antonio Collado Araque
Autor
José Antonio Collado Araque
Graphics Engineer - Researcher