Metal y shaders en SwiftUI: efectos a medida
Funciones stitchable en Metal Shading Language conectadas a SwiftUI mediante ShaderLibrary: colorEffect por píxel, layerEffect con muestreo de la capa, distortionEffect geométrico, y el análisis de coste que decide cuándo el descenso a la GPU vale la pena.
Hay una frontera donde la composición declarativa se agota: efectos que dependen del valor de cada píxel individual, de su distancia a un punto, del ruido evaluado en su coordenada o de una deformación continua del plano. Nada de eso se expresa combinando máscaras y modos de mezcla, porque exige computación arbitraria por fragmento. SwiftUI abre esa puerta con una elegancia notable: escribes una función en Metal Shading Language, la marcas como enlazable, y el framework la inyecta en su propio pipeline de renderizado como un modificador más. Es el descenso más profundo disponible sin abandonar el framework, y como todo descenso, se justifica por lo que hay abajo y se paga en lo que dejas arriba.
- Escribir funciones
stitchableen Metal y conectarlas desdeShaderLibrary. - Aplicar
colorEffectpara transformaciones por píxel y pasar argumentos tipados. - Usar
layerEffectydistortionEffectpara muestrear y deformar el contenido. - Evaluar el coste en GPU y decidir cuándo un shader es la herramienta correcta.
Anatomía de una función enlazable
El shader vive en un archivo con extensión metal dentro del objetivo, se compila junto a la app y se localiza en tiempo de ejecución por su nombre. El atributo stitchable es lo que permite a SwiftUI coserlo dentro de su propio pipeline en vez de exigir una cadena de renderizado propia:
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
[[ stitchable ]] half4 tinteRadial(float2 posicion,
half4 color,
float2 centro,
float radio,
half4 acento) {
float d = distance(posicion, centro) / max(radio, 1.0);
half mezcla = half(saturate(1.0 - d));
return mix(color, acento * color.a, mezcla) ;
}
La firma no es libre: el primer parámetro es siempre la posición del píxel en el espacio de la vista, el segundo el color que ese píxel tendría sin el efecto, y a partir de ahí vienen tus argumentos. El tipo de retorno half4 es color premultiplicado por alfa, y ese detalle explica la mayoría de los halos y bordes sucios de los primeros intentos: si multiplicas el color sin multiplicar también el alfa, rompes la invariante que espera el compositor.
Del lado de Swift, la biblioteca de shaders se consulta por nombre gracias a la búsqueda dinámica de miembros, y los argumentos se envían con constructores tipados:
tarjeta.colorEffect(
ShaderLibrary.tinteRadial(
.float2(centro),
.float(180),
.color(.orange)
)
)
Argumentos
float, float2, float3, color, image, data y boundingRect cubren el paso de parámetros desde Swift a Metal.
visualEffect
Combínalo con visualEffect para alimentar el shader con la geometría real de la vista sin provocar ciclos de layout.
Tiempo
Un TimelineView que inyecta el instante como float es la forma canónica de animar un shader sin estado en la GPU.
Los tres puntos de enganche
SwiftUI ofrece tres modificadores, y la diferencia entre ellos no es de estilo sino de qué información recibe el shader y cuánto trabajo obliga al sistema a preparar.
colorEffect es el más barato: recibe posición y color del píxel, y no puede mirar a sus vecinos. Sirve para tintes, umbralizados, curvas de color, patrones procedurales y ruido.
layerEffect recibe una capa muestreable en lugar de un color, de modo que puedes leer el contenido en cualquier coordenada. Eso habilita desenfoques propios, aberraciones cromáticas, píxeles agrandados o efectos de dispersión, y a cambio obliga a materializar el contenido como textura antes de ejecutar el shader:
[[ stitchable ]] half4 pixelado(float2 posicion,
SwiftUI::Layer capa,
float tamano) {
float2 celda = floor(posicion / tamano) * tamano + tamano * 0.5;
return capa.sample(celda);
}
distortionEffect opera en el espacio geométrico: no devuelve color sino la posición desde la que se debe muestrear, lo que permite ondas, lentes y pliegues. Exige declarar un desplazamiento máximo de muestreo, porque el sistema necesita saber cuánto margen reservar alrededor de la vista para que el efecto no se recorte.
imagen.distortionEffect(
ShaderLibrary.onda(.float(fase), .float(amplitud)),
maxSampleOffset: CGSize(width: 24, height: 24)
)
flowchart TD A[Vista SwiftUI] --> B[Se materializa como textura] B --> C[Shader stitchable en la GPU] C --> D[colorEffect solo color por pixel] C --> E[layerEffect muestreo del contenido] C --> F[distortionEffect nueva coordenada] D --> G[Composicion final] E --> G F --> G style C fill:#cba6f7,color:#11111b style B fill:#fab387,color:#11111b
Animar sin estado
Un shader no recuerda nada entre fotogramas: es una función pura evaluada de nuevo cada vez. La animación, por tanto, no se logra mutando el shader sino inyectando el tiempo como argumento y dejando que el resultado dependa de él. La pareja idiomática es un TimelineView que provee el instante y un visualEffect que aporta la geometría real medida, evitando que el cálculo del tamaño realimente el layout:
TimelineView(.animation) { contexto in
let t = contexto.date.timeIntervalSinceReferenceDate
contenido.visualEffect { vista, proxy in
vista.colorEffect(
ShaderLibrary.brilloBarrido(
.float(t),
.float2(proxy.size),
.float(reducirMovimiento ? 0 : 1)
)
)
}
}
Ese último argumento es el que separa una demo de un producto: la preferencia de movimiento reducido, leída del entorno, viaja al shader como un interruptor y permite que el efecto se congele en un estado estático razonable en lugar de desaparecer. La misma técnica sirve para el esquema de color, el contraste aumentado o cualquier condición del sistema que deba modular el resultado.
Coste y criterio
Un shader se ejecuta una vez por píxel cubierto y por fotograma. La aritmética es implacable: una superficie de cuatrocientos por ochocientos puntos en una pantalla de tres veces de densidad son casi tres millones de fragmentos, y a ciento veinte fotogramas por segundo eso son cientos de millones de invocaciones por segundo. El coste unitario del código importa poco hasta que lo multiplicas por ese número.
De ahí salen reglas operativas concretas. Usa half en lugar de float siempre que la precisión lo permita, porque las GPU de Apple procesan el doble de operaciones en media precisión. Evita ramificaciones dependientes de la posición, que provocan divergencia entre hilos del mismo grupo. Prefiere colorEffect a layerEffect cuando no necesites vecindad, ya que el segundo obliga a una pasada de materialización. Y desconfía de aplicar shaders a áreas grandes que se desplazan, donde el efecto se recalcula en cada fotograma de la animación de scroll.
Hay además un coste que no aparece en ningún perfil de GPU. Una vista con shader se rasteriza, lo que significa que el sistema pierde parte de la información semántica sobre lo que hay dentro: los efectos se aplican al resultado visual, no al árbol, y cualquier suposición sobre nitidez de texto o escalado vectorial deja de ser válida. La accesibilidad y el tacto siguen funcionando sobre la vista original, pero el usuario ve algo que el sistema ya no puede describir ni adaptar.
Los errores de Metal aparecen en tiempo de compilación del objetivo, no del archivo Swift, así que revisa el registro de compilación completo cuando un efecto no aparece. Un shader mal escrito suele fallar en silencio dejando la vista sin modificar, y ese silencio es el peor síntoma posible al depurar.
Cada capa que atraviesas hacia abajo te da poder y te quita colaboración del sistema. Un shader es el ejemplo extremo: obtienes control absoluto sobre cada píxel y renuncias a que el framework entienda lo que estás pintando. Ese intercambio se justifica cuando el efecto es imposible de expresar arriba y es central en la identidad del producto, y no se justifica cuando existe una composición razonable de gradientes, máscaras y materiales que el sistema ya sabe adaptar a modo oscuro, contraste aumentado, movimiento reducido y densidades de pantalla que aún no existen. El criterio profesional es doble. Primero, prueba a resolverlo con la capa más alta que funcione y solo desciende con una medición que demuestre la necesidad. Segundo, si desciendes, asume el mantenimiento completo: comprobar el resultado en modo claro y oscuro, respetar la preferencia de movimiento reducido desactivando la animación del shader, y dejar un camino de degradación cuando el efecto no pueda ejecutarse. El código que solo funciona en la demo del autor no es una técnica avanzada; es deuda con estética.
- Añade un archivo Metal al objetivo y escribe una función
stitchableque devuelva el color de entrada sin cambios; comprueba que se aplica y compila. - Conviértelo en un tinte radial con centro y radio pasados desde Swift mediante
colorEffect. - Escribe un efecto de pixelado con
layerEffecty compara el coste de fotograma con el del tinte anterior. - Anima una onda con
distortionEffectalimentada por el tiempo de unTimelineView, y recuerda declarar el desplazamiento máximo de muestreo. - Desactiva la animación del shader cuando el usuario tenga activada la reducción de movimiento y documenta la alternativa visual.