Shaders con AGSL: efectos por píxel y su precio
Programar la GPU desde una app Android: qué es AGSL y cómo se ejecuta un programa por píxel y por frame, el paso de uniformes desde Kotlin, su integración en Compose como ShaderBrush o como RenderEffect sobre el contenido, y el análisis de coste y compatibilidad que decide si un shader merece la pena.
Todo lo anterior consistía en emitir órdenes: pinta este camino, rellena con este pincel, mezcla así. Con AGSL se cruza una frontera y se pasa a escribir el programa que decide el color de cada píxel, ejecutado en paralelo por la GPU millones de veces por frame. Es la herramienta más potente del nivel y también la más fácil de usar mal, porque su coste no depende de la complejidad de tu figura sino del número de píxeles que cubre, una métrica que la API no te muestra en ninguna parte.
- Entender el modelo de ejecución de un shader de fragmento y qué significa AGSL en Android.
- Pasar datos desde Kotlin al shader mediante uniformes y actualizarlos por frame.
- Integrar un shader en Compose como pincel de relleno o como efecto sobre el contenido.
- Evaluar el coste por píxel, la compilación inicial y la degradación en versiones antiguas.
Un programa que corre una vez por píxel
AGSL, el lenguaje de shaders de gráficos de Android, es un dialecto emparentado con el de Skia y muy próximo al GLSL de fragmento que se usa en la web y en los videojuegos. Un shader escrito en él es una función pura con una forma canónica: recibe las coordenadas del píxel que se está resolviendo y devuelve un color con sus cuatro componentes, habitualmente declarada como half4 main con un parámetro float2 de coordenadas.
El modelo mental correcto no es el de un bucle sino el de una función aplicada en paralelo masivo. No hay orden, no hay estado compartido, no puedes consultar el píxel vecino que ya calculaste ni acumular nada entre invocaciones: cada ejecución conoce su coordenada, los uniformes que le pasaste y nada más. Esa restricción es la que permite a la GPU resolver un millón de píxeles a la vez, y explica por qué los algoritmos gráficos se expresan como fórmulas cerradas en lugar de como recorridos.
private const val FUENTE = """
uniform float2 uSize;
uniform float uTime;
layout(color) uniform half4 uColor;
half4 main(float2 coord) {
float2 uv = coord / uSize;
float onda = 0.5 + 0.5 * sin(uv.x * 12.0 + uTime * 2.0);
float halo = smoothstep(0.9, 0.2, distance(uv, float2(0.5, 0.5)));
return half4(uColor.rgb * onda * halo, uColor.a * halo);
}
"""
Tres detalles de ese programa merecen fijarse. La normalización de la coordenada dividiendo por el tamaño convierte los píxeles en un espacio de cero a uno, que es lo que hace que el efecto sea independiente de la resolución. La anotación de color sobre un uniforme indica al sistema que ese valor debe convertirse al espacio de color correcto en lugar de tratarse como cuatro números crudos. Y el color devuelto se espera premultiplicado por su alfa, un requisito que, si se olvida, produce halos claros en los bordes translúcidos.
Uniformes: el puente desde Kotlin
Un shader sin entradas es un adorno fijo. Los uniformes son las variables que se mantienen constantes durante un frame y cambian entre frames: el tamaño del área, el tiempo transcurrido, un color de tema, la posición del dedo, el progreso de una animación. Desde Kotlin se crea una instancia de RuntimeShader con el código fuente y se asignan los valores antes de cada pintado.
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun FondoOndulado(color: Color, modifier: Modifier = Modifier) {
val shader = remember { RuntimeShader(FUENTE) }
val tiempo = rememberInfiniteTransition(label = "onda").animateFloat(
initialValue = 0f,
targetValue = 6.28f,
animationSpec = infiniteRepeatable(tween(4000, easing = LinearEasing)),
label = "t",
)
Box(
modifier.drawWithCache {
shader.setFloatUniform("uSize", size.width, size.height)
shader.setColorUniform("uColor", color.toArgb())
val pincel = ShaderBrush(shader)
onDrawBehind {
shader.setFloatUniform("uTime", tiempo.value)
drawRect(pincel)
}
}
)
}
El reparto entre los dos bloques vuelve a ser la clave. El tamaño y el color se asignan en el bloque de caché porque solo cambian cuando cambia el nodo; el tiempo se asigna en el frame porque cambia siempre. Y ShaderBrush convierte el RuntimeShader en un Brush cualquiera, lo que significa que se puede usar en drawRect, en drawPath, en el relleno de un texto o en cualquier sitio donde antes ponías un color plano.
Como pincel, el shader genera color desde cero y rellena una figura: fondos animados, ruido, degradados imposibles. Como efecto, en cambio, recibe el contenido ya dibujado del nodo mediante un shader de entrada y lo transforma: distorsiones, desenfoques propios, ondas sobre una imagen. La segunda vía se conecta con RenderEffect.createRuntimeShaderEffect y se aplica dentro de graphicsLayer, y su coste es mayor porque exige capturar el contenido en una textura intermedia.
flowchart TD K[codigo Kotlin] --> U[uniformes por frame] U --> S[RuntimeShader compilado] S --> B[ShaderBrush rellena una figura] S --> R[RenderEffect transforma el contenido] B --> G[GPU ejecuta main por cada pixel] R --> G style G fill:#a6e3a1,color:#11111b
La vía del efecto tiene su propia mecánica. El shader declara un uniforme de tipo shader que representa el contenido ya dibujado, lo muestrea en la coordenada que decida —desplazada, girada o distorsionada— y devuelve el color resultante. Desde Kotlin se enlaza ese uniforme con el contenido del nodo y se cuelga el efecto de la capa de render.
val efecto = remember(shader) {
RenderEffect
.createRuntimeShaderEffect(shader, "uContenido")
.asComposeRenderEffect()
}
Image(
painter = pintura,
contentDescription = null,
modifier = Modifier.graphicsLayer { renderEffect = efecto },
)
El nombre que se pasa como segundo argumento debe coincidir con el del uniforme declarado en el shader, y a partir de ahí el sistema se encarga de capturar el contenido en una textura y ofrecérsela al programa. Ese paso intermedio es justo el que encarece la vía del efecto respecto a la del pincel.
El coste real
La aritmética del coste es despiadadamente simple y casi nadie la hace. Un shader a pantalla completa en un móvil de mil doscientos por dos mil seiscientos píxeles ejecuta su función unos tres millones de veces por frame; a ciento veinte hercios, son trescientos setenta millones de ejecuciones por segundo. Si dentro hay una raíz, dos senos y un muestreo de textura, multiplica. La GPU aguanta ese volumen porque para eso está, pero el consumo se paga en batería y en temperatura, y compite con todo lo demás que la aplicación quiera dibujar.
Hay además tres costes que no aparecen en esa cuenta. El primero es la compilación del shader, que ocurre la primera vez que se usa y puede provocar un salto perceptible justo al abrir la pantalla; se mitiga creando el RuntimeShader antes de necesitarlo y dibujando una vez fuera de la vista. El segundo es el sobredibujo: un shader aplicado a un nodo tapado por otro se ejecuta igualmente si no se recorta. El tercero es el muestreo de contenido, mucho más caro que la aritmética pura, sobre todo si cada píxel lee varias muestras vecinas como hacen los desenfoques.
Merece la pena
Efectos imposibles de otro modo: distorsiones continuas, ruido procedural, transiciones que deforman contenido, degradados con banding corregido. Cuando el efecto es la identidad visual del producto y cubre un área acotada.
Piénsalo dos veces
Fondos animados a pantalla completa que corren siempre, aunque no haya interacción. Consumen batería de forma continua y rara vez añaden valor proporcional a su coste.
No compensa
Todo lo que un gradiente, una imagen precalculada o unas primitivas resuelven igual de bien. Un degradado con Brush cuesta una fracción y funciona en todas las versiones del sistema.
Cómo verificarlo
Perfilador de la GPU y barra de renderizado con la app en un dispositivo de gama media real, midiendo con y sin el efecto. La intuición sobre coste gráfico es notoriamente mala.
Compatibilidad y degradación
AGSL en tiempo de ejecución llega con Android 13, nivel de API treinta y tres. En dispositivos anteriores la clase RuntimeShader sencillamente no existe, así que cualquier uso debe quedar detrás de una comprobación de versión y una ruta alternativa. Esa ruta no es un castigo: casi siempre es un Brush de gradiente estático que se parece razonablemente al efecto y que, además, consume mucho menos.
@Composable
fun FondoDeMarca(color: Color, modifier: Modifier = Modifier) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
FondoOndulado(color, modifier)
} else {
Box(modifier.background(Brush.verticalGradient(listOf(color, color.copy(alpha = 0.4f)))))
}
}
Diseñar así obliga a una disciplina sana: el efecto tiene que ser prescindible por construcción. Si la información que transmite el shader no existe en la versión de respaldo, el efecto estaba haciendo un trabajo que no le correspondía, y ese es un olor de diseño mucho antes que un problema de compatibilidad.
Durante toda tu vida como programador de interfaces has razonado sobre coste en términos del número de elementos: cien filas cuestan más que diez, un árbol profundo más que uno plano, una recomposición más que un repintado. Al escribir un shader esa métrica deja de aplicarse por completo y es sustituida por otra que resulta ajena hasta que se interioriza: el coste ya no depende de cuántas cosas dibujas sino de cuántos píxeles cubres y cuánta aritmética gasta cada uno. Un efecto que ocupa un icono de cuarenta y ocho dp y otro idéntico que ocupa la pantalla entera son el mismo código con un factor de mil entre sus facturas. Este cambio de unidad de medida es exactamente el que separa la programación de interfaces de la programación gráfica, y explica por qué los desarrolladores de videojuegos hablan de presupuestos de relleno y de ancho de banda mientras los de apps hablan de recomposiciones: son dos disciplinas mirando el mismo silicio desde extremos opuestos. Aprender AGSL, por eso, vale bastante más que el efecto concreto que consigas: te obliga a pensar en paralelo y sin estado, a expresar un problema como una función pura de la posición, a razonar sobre continuidad y suavizado en lugar de sobre bucles y condicionales, y a preguntarte por primera vez cuánta energía cuesta un adorno. Es un conocimiento que se transfiere íntegro a la web, a los motores de juego y a cualquier plataforma con GPU, porque lo que estás aprendiendo no es una API de Android sino el modelo de cómputo del hardware gráfico moderno. Y la madurez final consiste en saber que la respuesta correcta a la mayoría de los problemas visuales no es un shader: la potencia se reconoce mejor por la frecuencia con que se decide no usarla que por la frecuencia con que se despliega.
- Escribe un shader AGSL que produzca un halo radial animado y aplícalo como
ShaderBrushsobre unBoxde tamaño acotado. - Separa la asignación de uniformes entre el bloque de caché y el frame, y comprueba que el tamaño solo se escribe al redimensionar.
- Aplica el mismo shader como
RenderEffectsobre una imagen mediantegraphicsLayery compara el coste medido con la versión de pincel. - Implementa la ruta de respaldo con un gradiente para dispositivos anteriores a Android 13 y decide, con los números delante, si el shader se queda o se va.