Acceso eficaz a los datos de textura

Aprenda acerca de los beneficios y ventajas de las diferentes formas de acceder a los datos de píxeles de textura subyacentes en su proyecto de Unity.
Los datos de píxeles describen el color de los píxeles individuales de una textura. Unity proporciona métodos que le permiten leer o escribir en datos de píxeles con scripts C#.
Puedes utilizar estos métodos para duplicar o actualizar una textura (por ejemplo, añadir un detalle a la foto de perfil de un jugador), o utilizar los datos de la textura de una forma determinada, como leer una textura que representa un mapa del mundo para determinar dónde colocar un objeto.
Hay varias formas de escribir código que lea o escriba datos de píxeles. El que elijas dependerá de lo que pienses hacer con los datos y de las necesidades de rendimiento de tu proyecto.
Este blog y el proyecto de ejemplo que lo acompaña pretenden ayudarle a navegar por la API disponible y los errores de rendimiento más comunes. Comprender ambos aspectos le ayudará a crear una solución eficaz o a solucionar los cuellos de botella de rendimiento a medida que aparezcan.
Para la mayoría de los tipos de texturas, Unity almacena dos copias de los datos de píxeles: una en la memoria de la GPU, que es necesaria para el renderizado, y la otra en la memoria de la CPU. Esta copia es opcional y permite leer, escribir y manipular datos de píxeles en la CPU. Una textura con una copia de sus datos de píxeles almacenada en la memoria de la CPU se denomina textura legible. Un detalle a tener en cuenta es que RenderTexture sólo existe en la memoria de la GPU.
La memoria disponible para la CPU difiere de la de la GPU en la mayoría del hardware. Algunos dispositivos tienen una forma de memoria parcialmente compartida, pero para este blog asumiremos la configuración clásica de PC en la que la CPU sólo tiene acceso directo a la RAM conectada a la placa base y la GPU depende de su propia RAM de vídeo (VRAM). Cualquier dato transferido entre estos diferentes entornos tiene que pasar a través del bus PCI, que es más lento que transferir datos dentro del mismo tipo de memoria. Debido a estos costes, debes intentar limitar la cantidad de datos transferidos en cada fotograma.

El muestreo de texturas en shaders es la operación de datos de píxeles más común en la GPU. Para alterar estos datos, puedes copiar entre texturas o renderizar en una textura usando un shader. Todas estas operaciones pueden ser realizadas rápidamente por la GPU.
En algunos casos, puede ser preferible manipular los datos de textura en la CPU, que ofrece más flexibilidad en la forma de acceder a los datos. Las operaciones de datos de píxeles de la CPU sólo actúan sobre la copia de datos de la CPU, por lo que requieren texturas legibles. Si desea muestrear los datos de píxeles actualizados en un shader, primero debe copiarlos de la CPU a la GPU llamando a Aplicar. Dependiendo de la textura de que se trate y de la complejidad de las operaciones, puede resultar más rápido y sencillo ceñirse a las operaciones de la CPU (por ejemplo, al copiar varias texturas 2D en un activo Texture2DArray).
La API de Unity proporciona varios métodos para acceder o procesar datos de textura. Algunas operaciones actúan tanto en la copia de la GPU como en la de la CPU si ambas están presentes. Como resultado, el rendimiento de estos métodos varía en función de si las texturas son legibles. Se pueden utilizar distintos métodos para obtener los mismos resultados, pero cada uno tiene sus propias características de rendimiento y facilidad de uso.
Responde a las siguientes preguntas para determinar la solución óptima:
- ¿Puede la GPU realizar tus cálculos más rápido que la CPU?
- ¿Qué nivel de presión ejerce el proceso sobre las cachés de texturas? (Por ejemplo, muestrear muchas texturas de alta resolución sin utilizar mipmaps probablemente ralentizará la GPU).
- ¿El proceso requiere una textura de escritura aleatoria, o puede dar salida a un archivo adjunto de color o profundidad? (Escribir en píxeles aleatorios de una textura requiere frecuentes lavados de caché que ralentizan el proceso).
- ¿Mi proyecto ya tiene un cuello de botella en la GPU? Incluso si la GPU es capaz de ejecutar un proceso más rápido que la CPU, ¿puede la GPU permitirse asumir más trabajo sin sobrepasar su presupuesto de tiempo de cuadro?
- Si tanto el hilo principal de la GPU como el de la CPU están cerca de su límite de tiempo de fotogramas, tal vez la parte lenta de un proceso podría ser realizada por hilos de trabajo de la CPU.
- ¿Cuántos datos hay que cargar o descargar de la GPU para calcular o procesar los resultados?
- ¿Podría un shader o un trabajo de C# empaquetar los datos en un formato más pequeño para reducir el ancho de banda necesario?
- ¿Podría reducirse la resolución de una RenderTexture a una versión de menor resolución que se descargue en su lugar?
- ¿Puede realizarse el proceso por partes? (Si hay que procesar muchos datos a la vez, se corre el riesgo de que la GPU no tenga memoria suficiente para ello).
- ¿Con qué rapidez se necesitan los resultados? ¿Se pueden realizar cálculos o transferencias de datos de forma asíncrona y gestionarlos posteriormente? (Si se realiza demasiado trabajo en un solo fotograma, se corre el riesgo de que la GPU no tenga tiempo suficiente para renderizar los gráficos reales de cada fotograma).
Por defecto, las texturas que importas a tu proyecto no son legibles, mientras que las texturas creadas a partir de un script son legibles.
Las texturas legibles utilizan el doble de memoria que las no legibles porque necesitan tener una copia de sus datos de píxeles en la RAM de la CPU. Sólo deberías hacer una textura legible cuando lo necesites, y hacerlas no legibles cuando hayas terminado de trabajar con los datos en la CPU.
Para ver si un activo de textura de tu proyecto es legible y realizar ediciones, utiliza la opción Read/Write Enabled en Texture Import Settings, o la función TextureImporter.isReadable API.
Para hacer una textura no legible, llama a su método Apply con el parámetro makeNoLongerReadable a "true" (por ejemplo, Texture2D.Apply o Cubemap.Apply). Una textura no legible no se puede volver a hacer legible.
Todas las texturas son legibles para el Editor en los modos Editar y Reproducir. Llamar a Apply para hacer la textura no legible actualizará el valor de isReadable, impidiendo el acceso a los datos de la CPU. Sin embargo, algunos procesos de Unity funcionarán como si la textura fuera legible porque ven que los datos internos de la CPU son válidos.

El rendimiento difiere mucho entre las distintas formas de acceder a los datos de textura, especialmente en la CPU (aunque menos en resoluciones más bajas). El repositorio de ejemplos Unity Texture Access API en GitHub contiene una serie de ejemplos que muestran las diferencias de rendimiento entre varias APIs que permiten el acceso a, o la manipulación de, datos de textura. La interfaz de usuario sólo muestra los tiempos de CPU del subproceso principal. En algunos casos, se utilizan funciones DOTS como Burst y el sistema de trabajos para maximizar el rendimiento.
Estos son los ejemplos incluidos en el repositorio de GitHub:
- SimpleCopy: Copiar todos los píxeles de una textura a otra
- PlasmaTexture: Una textura de plasma actualizada en la CPU por fotograma
- TransferenciaGPUTextura: Transferir (copiar a un tamaño o formato diferente) todos los píxeles de la GPU de una textura a una RenderTexture.
A continuación se enumeran las mediciones de rendimiento tomadas de los ejemplos en GitHub. Estas cifras sirven de apoyo a las recomendaciones que siguen. Las mediciones proceden de un reproductor creado en un sistema con una CPU Xeon® W-2145 de 8 núcleos a 3,7 GHz y una RTX 2080.
Estos son los tiempos medios de CPU para SimpleCopy.UpdateTestCase con un tamaño de textura de 2.048.
Observe que los métodos gráficos se completan casi instantáneamente en el subproceso principal porque simplemente transfieren el trabajo al RenderThread, que posteriormente es ejecutado por la GPU. Sus resultados estarán listos cuando se renderice el siguiente fotograma.
Resultados
- 1.326 ms - foreach(mip) for(x en anchura) for(y en altura) SetPixel(x, y, GetPixel(x, y, mip), mip)
- 32,14 ms - foreach(mip) SetPixels(source.GetPixels(mip), mip)
- 6,96 ms - foreach(mip) SetPixels32(source.GetPixels32(mip), mip)
- 6.74 ms – LoadRawTextureData(source.GetRawTextureData())
- 3.54 ms – Graphics.CopyTexture(readableSource, readableTarget)
- 2,87 ms - foreach(mip) SetPixelData<byte>(mip, GetPixelData<byte>(mip))
- 2.87 ms – LoadRawTextureData(source.GetRawTextureData<byte>())
- 0.00 ms - Graphics.ConvertTexture(origen, destino)
- 0.00 ms - Graphics.CopyTexture(nonReadableSource, target)
Estos son los tiempos medios de CPU para PlasmaTexture.UpdateTestCase con un tamaño de textura de 512.
Verás que SetPixels32 es inesperadamente más lento que SetPixels. Esto se debe a tener que tomar el resultado de Color basado en flotantes del cálculo del píxel de plasma y convertirlo a la estructura Color32 basada en bytes. SetPixels32NoConversion omite esta conversión y sólo asigna un valor por defecto a la matriz de salida Color32, lo que resulta en un mejor rendimiento que SetPixels. Con el fin de superar el rendimiento de SetPixels y la conversión de color subyacente realizada por Unity, es necesario volver a trabajar el método de cálculo de píxeles en sí para dar salida directamente a un valor Color32. Una implementación simple usando SetPixelData casi garantiza mejores resultados que los cuidadosos enfoques SetPixels y SetPixels32.
Resultados
- 126,95 ms - FijarPíxel
- 113,16 ms - SetPixels32
- 88,96 ms - FijarPíxeles
- 86.30 ms - SetPixels32NoConversion
- 16.91 ms - SetPixelDataBurst
- 4.27 ms - SetPixelDataBurstParallel
Estos son los tiempos de GPU Editor para TransferGPUTexture.UpdateTestCase con un tamaño de textura de 8.196:
- Blit - 1,584 ms
- CopiarTextura - 0.882 ms
Puede acceder a los datos de píxeles de varias formas. Sin embargo, no todos los métodos admiten todos los formatos, tipos de textura o casos de uso, y algunos tardan más en ejecutarse que otros. En esta sección se repasan los métodos recomendados, y en la siguiente los que hay que utilizar con precaución.
CopyTexture es la forma más rápida de transferir datos GPU de una textura a otra. No realiza ninguna conversión de formato. Puede copiar datos parcialmente especificando una posición de origen y de destino, además de la anchura y la altura de la región. Si ambas texturas son legibles, la operación de copia también se realizará sobre los datos de la CPU, acercando el coste total de este método al de una copia sólo de CPU utilizando SetPixelData con el resultado de GetPixelData de una textura origen.
Blit es un método rápido y potente de transferir datos de la GPU a una RenderTexture utilizando un shader. En la práctica, esto tiene que configurar el estado de la API de canalización de gráficos para renderizar a la RenderTexture de destino. Tiene un pequeño coste de configuración independiente de la resolución en comparación con CopyTexture. El sombreador Blit por defecto utilizado por el método toma una textura de entrada y la renderiza en la RenderTexture de destino. Al proporcionar un material o sombreador personalizado, se pueden definir complejos procesos de renderizado de textura a textura.
GetPixelData y SetPixelData (junto con GetRawTextureData) son los métodos más rápidos a utilizar cuando sólo se tocan los datos de la CPU. Ambos métodos requieren que proporcione un tipo struct como parámetro de plantilla utilizado para reinterpretar los datos. Los métodos en sí mismos sólo necesitan esta estructura para derivar el tamaño correcto, así que puedes usar byte si no quieres definir una estructura personalizada para representar el formato de la textura.
Cuando se accede a píxeles individuales, es una buena idea definir una estructura personalizada con algunos métodos de utilidad para facilitar su uso. Por ejemplo, una estructura de formato R5G5B5A1 podría estar formada por un miembro de datos ushort y algunos métodos get/set para acceder a los canales individuales como bytes.
Tipo de bloque desconocido "codeBlock", especifique un serializador para él en la propiedad `serializers.types`.
El código anterior es un ejemplo de una implementación de un objeto que representa un píxel en el formato R5G5B5A5A1; los correspondientes definidores de propiedades se omiten por brevedad.
SetPixelData se puede utilizar para copiar un nivel mip completo de datos en la textura de destino. GetPixelData devolverá un NativeArray que en realidad apunta a un nivel mip de los datos de textura internos del CPU de Unity. Esto le permite leer/escribir directamente esos datos sin necesidad de realizar ninguna operación de copia. La pega es que el NativeArray devuelto por GetPixelData sólo está garantizado para ser válido hasta que el código de usuario que llama a GetPixelData devuelva el control a Unity, como cuando MonoBehaviour.Update devuelve. En lugar de almacenar el resultado de GetPixelData entre fotogramas, tienes que obtener el NativeArray correcto de GetPixelData para cada fotograma desde el que quieras acceder a estos datos.
El método Apply vuelve una vez que los datos de la CPU se han cargado en la GPU. El parámetro makeNoLongerReadable debe establecerse en "true" siempre que sea posible para liberar la memoria de los datos de la CPU después de la carga.
La dirección RequestIntoNativeArray y RequestIntoNativeSlice descargan de forma asíncrona los datos de la GPU de la Textura especificada en (una porción de) un NativeArray proporcionado por el usuario.
La llamada a los métodos devolverá un manejador de petición que puede indicar si los datos solicitados se han terminado de descargar. La compatibilidad se limita a unos pocos formatos, por lo que debe utilizar SystemInfo.IsFormatSupported con FormatUsage.ReadPixels para comprobar la compatibilidad de formatos. En AsyncGPUReadback también tiene una clase Solicitud que asigna un NativeArray. Si necesitas repetir esta operación, obtendrás un mejor rendimiento si en su lugar asignas un NativeArray que reutilices.
Hay una serie de métodos que deben utilizarse con precaución debido a los efectos potencialmente significativos sobre el rendimiento. Veámoslos con más detalle.
Estos métodos realizan conversiones de formato de píxel de complejidad variable. Las variantes Pixels32 son las más eficaces del grupo, pero incluso ellas pueden realizar conversiones de formato si el formato subyacente de la textura no coincide perfectamente con la estructura Color32. Al utilizar los siguientes métodos, es mejor tener en cuenta que su impacto en el rendimiento aumenta significativamente en distintos grados a medida que crece el número de píxeles:
GetRawTextureData y LoadRawTextureData son métodos exclusivos de Texture2D que trabajan con matrices que contienen los datos de píxeles sin procesar de todos los niveles mip, uno tras otro. El diseño va de mayor a menor mip, siendo cada mip una cantidad de píxeles de "altura" y "anchura". Estas funciones permiten acceder rápidamente a los datos de la CPU. GetRawTextureData tiene un problema: la variante sin plantilla devuelve una copia de los datos. Esto es un poco más lento, y no permite la manipulación directa del buffer subyacente gestionado por Unity. GetPixelData no tiene esta peculiaridad y sólo puede devolver un NativeArray apuntando al buffer subyacente que permanece válido hasta que el código de usuario devuelve el control a Unity.
ConvertirTextura es una forma de transferir los datos de la GPU de una textura a otra, cuando las texturas de origen y destino no tienen el mismo tamaño o formato. Este proceso de conversión es lo más eficaz posible dadas las circunstancias, pero no es barato. Este es el proceso interno:
Asigna una RenderTexture temporal que coincida con la textura de destino.
Realiza un Blit desde la textura origen a la RenderTexture temporal.
Copia el resultado Blit de la RenderTexture temporal a la textura de destino.
Responda a las siguientes preguntas para determinar si este método se adapta a su caso de uso:
- ¿Es necesario realizar esta conversión?
- ¿Puedo asegurarme de que la textura de origen se crea en el tamaño/formato deseado para la plataforma de destino en el momento de la importación?
- ¿Puedo cambiar mis procesos para que utilicen los mismos formatos, permitiendo que el resultado de un proceso se utilice directamente como entrada para otro proceso?
- ¿Puedo crear y utilizar una RenderTexture como destino? Esto reduciría el proceso de conversión a un único Blit a la RenderTexture de destino.
En LeerPixeles descarga de forma sincrónica los datos de la GPU del RenderTexture activo (RenderTexture.active) a los datos de la CPU de un Texture2D. Permite almacenar o procesar el resultado de una operación de renderizado. La compatibilidad está limitada a unos pocos formatos, por lo que se debe utilizar SystemInfo.IsFormatSupported con FormatUsage.ReadPixels para comprobar la compatibilidad de formatos.
La descarga de datos desde la GPU es un proceso lento. Antes de empezar, ReadPixels tiene que esperar a que la GPU complete todo el trabajo anterior. Es mejor evitar este método, ya que no devolverá hasta que los datos solicitados estén disponibles, lo que ralentizará el rendimiento. La usabilidad también es un problema porque necesitas que los datos de la GPU estén en una RenderTexture, que tiene que estar configurada como la activa en ese momento. Tanto la usabilidad como el rendimiento son mejores cuando se utilizan los métodos AsyncGPUReadback comentados anteriormente.
El sitio ImageConversion tiene métodos para convertir entre Texture2D y varios formatos de archivo de imagen. CargarImagen es capaz de cargar datos JPG, PNG o EXR (desde 2023.1) en una Texture2D y cargarla en la GPU por ti. Los datos de píxeles cargados pueden comprimirse sobre la marcha en función del formato original de Texture2D. Otros métodos pueden convertir una matriz de datos Texture2D o de píxeles en una matriz de datos JPG, PNG, TGA o EXR.
Estos métodos no son especialmente rápidos, pero pueden ser útiles si su proyecto necesita pasar datos de píxeles a través de formatos de archivo de imagen comunes. Los casos de uso típicos incluyen cargar el avatar de un usuario desde el disco y compartirlo con otros jugadores a través de una red.
Hay muchos recursos disponibles para aprender más sobre optimización de gráficos, temas relacionados y mejores prácticas en Unity. La sección de rendimiento y perfiles gráficos de la documentación es un buen punto de partida.
También puede consultar varios libros electrónicos técnicos para usuarios avanzados, como Ultimate guide to profiling Unity games, Optimize your mobile game performancey Optimiza el rendimiento de tus juegos para consola y PC.
Encontrará muchas más prácticas recomendadas avanzadas en el centro de instrucciones de Unity.
He aquí un resumen de los puntos clave que conviene recordar:
- Al manipular texturas, el primer paso es evaluar qué operaciones pueden realizarse en la GPU para obtener un rendimiento óptimo. La carga de trabajo existente en la CPU/GPU y el tamaño de los datos de entrada/salida son factores clave a tener en cuenta.
- El uso de funciones de bajo nivel como GetRawTextureData para implementar una ruta de conversión específica cuando sea necesario puede ofrecer un mejor rendimiento que los métodos más convenientes que realizan copias y conversiones (a menudo redundantes).
- Las operaciones más complejas, como las grandes lecturas y los cálculos de píxeles, sólo son viables en la CPU cuando se realizan de forma asíncrona o en paralelo. La combinación de Burst y el sistema de trabajos permite a C# realizar ciertas operaciones que, de otro modo, sólo serían realizables en una GPU.
- Perfil con frecuencia: Durante el desarrollo puede encontrarse con muchos escollos, desde conversiones inesperadas e innecesarias hasta bloqueos por esperar a otro proceso. Algunos problemas de rendimiento sólo empezarán a aparecer cuando el juego aumente de escala y ciertas partes del código se utilicen más. El proyecto de ejemplo demuestra cómo aumentos aparentemente pequeños en la resolución de las texturas pueden hacer que determinadas API se conviertan en un problema de rendimiento.
Comparte con nosotros tus comentarios sobre los datos de texturas en los foros de Scripting o Gráficos generales. Asegúrate de estar atento a los nuevos blogs técnicos de otros desarrolladores de Unity como parte de la serie Tech from the Trenches.
