Usando filtros de Metal con vistas y vídeos

Cuando trabajamos con gráficos y efectos visuales en iOS y macOS, dos de las tecnologías más potentes disponibles son Metal y Core Image. Ambas permiten realizar transformaciones y efectos sobre imágenes, pero tienen diferencias clave en cuanto a su rendimiento, flexibilidad y la manera en que pueden aplicarse en una interfaz de usuario.

Podréis encontrar un proyecto con el código completo en nuestro Patreon. El código incluye 10 shaders de Metal y sus modificadores para ser aplicados a cualquier vista. Así como un ejemplo de utilización del VideoRenderController a un vídeo.

Efectos Metal incluídos (con sus modificadores)

  • Caleido: Efecto caleidoscopio, permite escoger el cuadrante de la imagen a replicar.
  • OldTimes: Efecto de ruido animado, simulación de película antigua.
  • Pixelate: Efecto de pixelado, puede escoger el tamaño del píxel.
  • Posterize: Efecto de posterizado, podemos escoger la intensidad.
  • Sepia: Efecto color sepia, podemos escoger la intensidad.
  • Solarize: cambio de color a partir de una imagen LUT.
  • Threshold Black and White: Efecto pasado a tinta blanco y negro, podemos escoger intensidad.
  • Tiled: Multiplicar por n² una imagen (4, 9, 16, …)
  • Warhol: Transforma una imagen a 4 con colorizado.
  • ZoomBlur: Efecto de movimiento borroso des del centro de la imagen.

Código incluído

  • VideoRenderController
  • VideoRenderView
  • ContentView

Procesando de gráficos en SwiftUI

¿Qué es Metal?

Metal es una API de bajo nivel creada por Apple para acceder directamente a la GPU, ofreciendo un rendimiento óptimo para gráficos 2D, 3D y cálculos paralelos. Se utiliza comúnmente en aplicaciones que requieren renderizados complejos, como videojuegos y aplicaciones de edición de imágenes y video.

Al ser una API de bajo nivel, Metal proporciona un control total sobre la GPU, lo que permite la creación de shaders personalizados para efectos visuales avanzados. Sin embargo, esta flexibilidad también implica una mayor complejidad a la hora de implementarlo.

¿Qué es Core Image?

Core Image es una API de más alto nivel diseñada específicamente para el procesamiento de imágenes. Proporciona una serie de filtros optimizados que pueden aplicarse de manera sencilla sin necesidad de escribir shaders manualmente. Core Image está basado en un flujo de procesamiento eficiente y funciona bien con imágenes estáticas y flujos de video. Si bien Core Image es fácil de usar y ofrece un buen rendimiento, no permite el mismo nivel de personalización que Metal.

Aplicación en SwiftUI

Si bien aplicar filtros de Core Image a imágenes en SwiftUI es bastante directo, el proceso se vuelve más complejo cuando queremos aplicar estos filtros a vistas completas. Para ello, generalmente es necesario capturar una imagen de la vista y aplicar el filtro sobre ella, lo que puede no ser ideal para efectos en tiempo real.

Por otro lado, Metal permite aplicar efectos visuales en tiempo real mediante shaders personalizados que pueden usarse como efectos de cambio de color, efectos de capa o efectos de distorsión. Sin embargo, si la vista contiene un video, Metal no puede acceder directamente a los frames y nos da un error. Para solucionar esto, se debe construir una vista que convierta cada frame del video en una imagen, permitiendo así el procesamiento con Metal.

Como crear una vista de imagen a partir de vídeo

ViewRenderController

Crearemos un Observable Object que llamaremos VideoRenderController. Este recibe un vídeo, un objeto AVPlayer, que puede venir de un recurso local o cargado des de internet. Nuestro Render Controller tendrá dos variables @Published: currentFrame que será la imagen del frame que esté mostrándose en este momento en forma de UIImage, y tambén isLoading para tener la información del estado de carga de los frames del vídeo.

Al inicializar este ViewModel, extraemos el AVPlayerItem del AVPlayer y lo configuramos. Esto implica configurar un AVPlayerItemVideoOutput especificando el tipo de búfer de píxeles. Además, en la inicialización también establecemos un Timer para que se dispare una función de extracción de frame con una frecuencia de 30 veces por segundo (no necesitaremos más para cualquier vídeo normal, si tuviera un frame rate más alto, podríamos aumentarlo).

Este Timer lo que hará pues, es ejecutar la función renderFrame(). En esta función, una vez comprobado que tengamos configurado un output y un vídeo reproduciéndose (currentItem), captura el actual buffer de píxeles i lo transforma, primero a una imagen de Core Image (CIImage), luego a CoreGraphics (CGImage) y finalmente a UIImage.

Una vez hecho esto deberemos actualizar la variable currentFrame en el hilo principal ya que es la manera que obliga Apple a actualizar elementos de la interfaz gráfica. Esto no haría falta realmente en este ViewModel pero sabemos que usaremos esta imagen en una vista así que mejor hacerlo aquí ya y ahorarnos problemas.

Para no consumir recursos del sistema, añadiremos una función extra que detendrá el timer con la función invalidate() si necesitamos detener la reproducción. Los otros controles del vídeo (play, velocity, etc.) pueden hacerse sobre el elemento AVPlayer que le pasamos al VideoRenderController.

Esta solución también admite, desde fuera crear una notificación (ya sea con Notification Center o con Combine) para, por ejemplo observar la llegada al final del vídeo y volverlo a reproducir para hacer un loop. Aunque este renderer admite como AVPlayer un AVPlayerLooper tal cual, no es muy eficiente a la hora de reproducirlo y se producen algunos saltos de frame.

import SwiftUI
import AVFoundation
import CoreImage
import CoreImage.CIFilterBuiltins
class VideoRenderController: ObservableObject {
    @Published var currentFrame: UIImage? = nil
    @Published var isLoading = true
    private var player: AVPlayer
    private var playerItem: AVPlayerItem?
    private var videoOutput: AVPlayerItemVideoOutput?
    private var frameTimer: Timer?
    private let ciContext = CIContext()
    // New initializer that accepts an existing AVPlayer
    init(player: AVPlayer) {
        self.player = player
        // Use the currentItem from the global player.
        // (Make sure the player already has an AVPlayerItem.)
        if let currentItem = player.currentItem {
            self.playerItem = currentItem
            setupVideoOutput(for: currentItem)
        }
        // Start frame extraction
        startFrameTimer()
    }
    private func setupVideoOutput(for item: AVPlayerItem) {
        let output = AVPlayerItemVideoOutput(pixelBufferAttributes: [
            kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
        ])
        self.videoOutput = output
        item.add(output)
    }
    private func startFrameTimer() {
        isLoading = false
        frameTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
            self?.renderFrame()
        }
        print("[DEBUG] Started 30 FPS timer.")
    }
    private func renderFrame() {
        guard let output = videoOutput,
              let currentItem = player.currentItem else { return }
        let currentTime = currentItem.currentTime()
        guard let pixelBuffer = output.copyPixelBuffer(forItemTime: currentTime, itemTimeForDisplay: nil) else {
            return
        }
        DispatchQueue.global(qos: .userInitiated).async {
            let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
            guard let cgImage = self.ciContext.createCGImage(ciImage, from: ciImage.extent) else { return }
            let uiImage = UIImage(cgImage: cgImage)
            DispatchQueue.main.async {
                self.currentFrame = uiImage
            }
        }
    }
    func stop() {
        frameTimer?.invalidate()
        player.pause()
    }
}

VideoRenderView

Esta vista nos servirá para tener efectivamente una imagen actualizada 30 veces por segundo a partir de un player de vídeo AVPlayer.

Usamos como ViewModel el VideoRenderController que hemos creado antes. La vista se inicializa a partir del player que recibe y es en este punto del init que inicializamos el ViewModel, accediento al wrapped value con el comando _renderController.

Hecho esto ya podremos acceder a renderController.currentFrame que contendrá la imagen del frame actualizada. Crearemos pues una vista con esta imagen una vez hechas las comprobaciones de su existencia.

A partir de aquí, si no hay imágen podemos mostrar un texto, no mostrar nada o lo que queramos.

import SwiftUI
import AVFoundation
struct VideoRenderView: View {
    @StateObject private var renderController: VideoRenderController
    // New initializer using a global or passed-in AVPlayer
    init(player: AVPlayer) {
        _renderController = StateObject(wrappedValue: VideoRenderController(player: player))
    }
    var body: some View {
        VStack {
            if let frame = renderController.currentFrame {
                if !renderController.isLoading {
                    Image(uiImage: frame)
                        .resizable()
                        .scaledToFit()
                }
            } else {
                Text("No frames available.")
            }
        }
    }
}

 

Aplicar Metal a Vistas de SwiftUI

SwiftUI puede aplicar 3 tipos diferentes de filtros de Metal a una vista usando estos modificadores:

  • colorEffect: permite pasar la información de los píxeles y cambiar sus propiedades.
  • layerEffect: permite además pasar información de la capa en sí para samplearla.
  • distortionEffect: permite mover los píxeles a lugares diferentes, necesitaremos pasar la información de las dimensiones de la capa.

Para cada tipo, la sintaxis es ligeramente diferente, tanto del modificador como del archivo de Metal que debemos crear.

Color Effect

Para usar el moficador colorEffect deberemos tener un archivo de metal que admita como mínimo 2 variables, la primera podemos llamarla como queramos pero será la información de la posición del pixel, la segunda serán sus datos de color y alpha del mismo. A partir de aquí, si queremos, podemos añadir más variables de entrada si queremos usarlas en nuestro shader.

Hemos creado un ejemplo de cambio de color a sepia: SepiaTone.metal.

[[stitchable]]
half4 sepiaTone(float2 pos, half4 color, float amount) {
    
    // Calculate the new red, green, and blue values using the sepia matrix.
    float sepiaRed   = color.r * 0.393 + color.g * 0.769 + color.b * 0.189;
    float sepiaGreen = color.r * 0.349 + color.g * 0.686 + color.b * 0.168;
    float sepiaBlue  = color.r * 0.272 + color.g * 0.534 + color.b * 0.131;
    
    
    half4 sepiaColor = half4(clamp(sepiaRed, 0.0, 1.0),
                             clamp(sepiaGreen, 0.0, 1.0),
                             clamp(sepiaBlue, 0.0, 1.0),
                             color.a);
    
    
    return (1.0 - amount) * color + amount * sepiaColor;;
}

Como vemos, hemos creado una función que retorna la información del pixel en formato half4, que hemos llamado sepiaTone. Esta recibe la posición, el color y nos hemos creado una extra que controla la cantidad de efecto de sepia que queramos.

¿Cómo pasamos esta información?

Para ello usaremos el modificador .colorEffect sobre cualquier vista que queramos

{Cualquier vista}
.colorEffect(
        ShaderLibrary.sepiaTone(
            .float(0.8)
        )
)

Por defecto, internamente SwiftUI ya está pasando la posición y el color. Nosotros además le pasamos una variable float que es la cantidad de efecto que estamos usando dentro del shader, si no pasáramos nada más, simplemente pondríamos .sepiaTone().

Layer Effect

Este modificador nos permitirá además de lo anterior tener la información de la propia capa. Dentro de metal la usaremos de esta manera. Vemos el ejemplo Pixelate.metal

#include <metal_stdlib>
#include <SwiftUI/SwiftUI.h>
using namespace metal;


[[ stitchable ]]
half4 pixelate(
    float2 position,
    SwiftUI::Layer layer,
    float pixelSize
) {
    // Compute the pixelated position by snapping to a grid.
    float2 pixelatedPosition = floor(position / pixelSize) * pixelSize;

    // Sample the texture at the pixelated position.
    half4 color = layer.sample(pixelatedPosition);

    return color;
}

Vemos como esta función recibe la posición del píxel concreto, pero también la capa entera de SwiftUI (le indicamos el tipo con SwiftUI::Layer y debemos incluir el header <SwiftUI/SwiftUI.h>

Estas dos primeras variables las pasa el modificador de forma automática, en este caso yo he añadido una variable más para controlar el tamaño del píxel que queremos.

Para usarlo aplicamos a la vista que queramos el modificador .layerEffect.

{Cualquier vista}
    .layerEffect(
        ShaderLibrary.pixelate(
            .float(10)
        ), 
        maxSampleOffset: .zero
    )

En este caso .layerEffect recibe dos cosas, por un lado el shader en sí que vamos a aplicar y también en este caso maxSampleOffset, que le dice la distancia máxima de sampleo (para controlar temas de eficiencia). Le podemos pasar un CGRect de dimensiones o dejarlo en .zero.

Igual que antes, a la función pixelate le pasamos el valor deseado, en este caso 10 que será el tamaño del píxel, las otras dos variables de entrada, posición y capa ya las pasa automáticamente el modificador de SwiftUI.

Visual effect y las dimensiones de la capa

Si queremos usar las dimensiones reales de la capa tendremos que combinar nuestros efectos combinan con otro modificador que trae esa información: .visualEffect. Es el caso de un nuevo efecto que queremos aplicar como es “tile” que lo que hará es sacar una imagen multiplicada por 4 (2×2). Aplicaríamos primero visual effect y sobre el contenido del closure el modificador que queramos:

{Cualquier vista}
        .visualEffect { content, proxy in
            content.layerEffect(
                ShaderLibrary.tiled(
                    .float2(proxy.size)
                ),
                 maxSampleOffset: CGSize(width: 40, height: 40),
                isEnabled: isEnabled
            )
        }

Visual effect trae consigo el content (nuestra vista), y un proxy (que es como un GeometryProxy). Deberemos pasarle esta información al shader.

En este caso tendremos una función de metal que se llamará Tiled.metal a la que le pasaremos el vector float2 (con la información de width y height) que viene en proxy.size.

La función de Metal sería la siguiente:

#include <metal_stdlib>
#include <SwiftUI/SwiftUI.h>

using namespace metal;

[[stitchable]]
half4 tiled(
    float2 position,
    SwiftUI::Layer layer,
    float2 size
) {
    // Convert position to a normalized coordinate relative to the tile size,
    // then scale by # times to repeat the layer in a timesxtimes grid (2^2 total tiles)
    float2 tiledCoord = (position / size) * 2;
    
    // Wrap the coordinates so they stay within [0,1] for the original texture
    float2 wrappedCoord = fract(tiledCoord);
    
    // Convert the wrapped coordinate back to the original texture space
    float2 sampleCoord = wrappedCoord * size;
    
    // Sample the layer at the computed coordinate
    return layer.sample(sampleCoord);
}

Pasar imágenes (texturas) a Metal

Un paso no trivial es cuando queremos que metal reciba una imágen (textura en jerga de Metal) para usarla en nuestro shader. Es el caso de que por ejemplo queramos crear un cambio de color a partir de un archivo LUT.

En este caso lo que debemos pasar es una imagen con nuestro modificador en formato Image. También deberíamos pasarle las dimensiones de dicha imagen para poderla utilizar.

ShaderLibrary.solarize(
    .float2(proxy.size),
    .image(Image(uiImage: keyImage)),
    .float2(keyImage.size)
),

Nuestro función de Metal recibiría las siguientes variables de entrada:

#include "metal_stdlib"
#include "SwiftUI/SwiftUI.h"
using namespace metal;


[[ stitchable ]]
half4 solarize(float2 position,
                SwiftUI::Layer mainLayer,
                float2 layerSize,   // Use if you need to convert non-normalized coordinates.
                texture2d<half> keyTexture [[ texture(1) ]],
                float2 keySize)     // LUT dimensions (if needed).
{

...

}

A partir de aquí ya estaríamos en disposición de usar la capa principal así como la textura que hemos llamado keyTexture.

Conclusiones

Si logramos crear una vista con imágenes renderizadas de un vídeo, ya seremos capaces de aplicar un efecto de Metal, o varios de forma encadenada a cualquier vista de SwiftUI, ya sea que contenga objetos como imágenes, vídeos, iconos, forma o texto…

En el proyecto de patreon encontraréis como aplicarlo a una vista de prueba pudiendo seleccionar los diferentes tipos de efectos así como el código completo de los diferentes efectos incluyendo un efecto solarize del ejemplo anterior.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top