Saltar al contenido principal

Escribir y usar fragment shaders

Cómo crear y usar fragment shaders para crear efectos visuales personalizados en tu aplicación Flutter.

Los shaders personalizados se pueden usar para proporcionar efectos gráficos enriquecidos más allá de los proporcionados por el SDK de Flutter. Un shader es un programa creado en un lenguaje pequeño, similar a Dart, conocido como GLSL, y ejecutado en la GPU del usuario.

Los shaders personalizados se añaden a un proyecto de Flutter listándolos en el archivo pubspec.yaml, y se obtienen usando la API FragmentProgram.

Añadir shaders a una aplicación

#

Los shaders, en forma de archivos GLSL con la extensión .frag, deben declararse en la sección shaders del archivo pubspec.yaml de tu proyecto. La herramienta de línea de comandos de Flutter compila el shader a su formato de backend adecuado, y genera los metadatos necesarios en tiempo de ejecución. El shader compilado se incluye luego en la aplicación al igual que un asset.

yaml
flutter:
  shaders:
    - shaders/myshader.frag

Al ejecutarse en modo debug, los cambios en un programa de shader activan la recompilación y actualizan el shader durante el Hot Reload o Hot Restart.

Los shaders de paquetes se añaden a un proyecto con el prefijo packages/$pkgname en el nombre del programa de shader (donde $pkgname es el nombre del paquete).

Cargar shaders en tiempo de ejecución

#

Para cargar un shader en un objeto FragmentProgram en tiempo de ejecución, usa el constructor FragmentProgram.fromAsset. El nombre del asset es el mismo que la ruta al shader proporcionada en el archivo pubspec.yaml.

dart
void loadMyShader() async {
  var program = await FragmentProgram.fromAsset('shaders/myshader.frag');
}

El objeto FragmentProgram se puede usar para crear una o más instancias de FragmentShader. Un objeto FragmentShader representa un programa fragment junto con un conjunto particular de uniforms (parámetros de configuración). Los uniforms disponibles dependen de cómo se haya definido el shader.

dart
void updateShader(Canvas canvas, Rect rect, FragmentProgram program) {
  var shader = program.fragmentShader();
  shader.setFloat(0, 42.0);
  canvas.drawRect(rect, Paint()..shader = shader);
}

API Canvas

#

Los fragment shaders se pueden usar con la mayoría de las APIs de Canvas estableciendo Paint.shader. Por ejemplo, al usar Canvas.drawRect el shader se evalúa para todos los fragmentos dentro del rectángulo. Para una API como Canvas.drawPath con un trazo de línea, el shader se evalúa para todos los fragmentos dentro de la línea del trazo. Algunas APIs, como Canvas.drawImage, ignoran el valor del shader.

dart
void paint(Canvas canvas, Size size, FragmentShader shader) {
  // Draws a rectangle with the shader used as a color source.
  canvas.drawRect(
    Rect.fromLTWH(0, 0, size.width, size.height),
    Paint()..shader = shader,
  );

  // Draws a stroked rectangle with the shader only applied to the fragments
  // that lie within the stroke.
  canvas.drawRect(
    Rect.fromLTWH(0, 0, size.width, size.height),
    Paint()
      ..style = PaintingStyle.stroke
      ..shader = shader,
  )
}

API ImageFilter

#

Los fragment shaders también se pueden usar con la API ImageFilter. Esto permite usar fragment shaders personalizados con la clase ImageFiltered o la clase BackdropFilter para aplicar shaders al contenido ya renderizado. ImageFilter proporciona un constructor, ImageFilter.shader, para crear un ImageFilter con un fragment shader personalizado.

dart
Widget build(BuildContext context, FragmentShader shader) {
  return ClipRect(
    child: SizedBox(
      width: 300,
      height: 300,
      child: BackdropFilter(
        filter: ImageFilter.shader(shader),
        child: Container(
          color: Colors.transparent,
        ),
      ),
    ),
  );
}

Al usar ImageFilter con BackdropFilter, se puede usar un ClipRect para limitar el área afectada por el ImageFilter. Sin un ClipRect el BackdropFilter se aplicará a toda la pantalla.

Los fragment shaders de ImageFilter reciben algunos uniforms automáticamente desde el motor. El valor de sampler2D en el índice 0 se establece en la imagen de entrada del filtro, y los valores de float en los índices 0 y 1 se establecen en el ancho y alto de la imagen. Tu shader debe especificar este constructor para aceptar estos valores (por ejemplo, un sampler2D y un vec2), pero no debes establecerlos desde tu código Dart.

Al dirigirse a OpenGLES, las coordenadas y de la textura se invertirá, por lo que el fragment shader debe des-invertir las UV al muestrear desde texturas proporcionadas por el motor.

glsl
#version 460 core
#include <flutter/runtime_effect.glsl>

out vec4 fragColor;

// These uniforms are automatically set by the engine.
uniform vec2 u_size;
uniform sampler2D u_texture;

void main() {
  vec2 uv = FlutterFragCoord().xy / u_size;
#ifdef IMPELLER_TARGET_OPENGLES
  // When sampling from u_texture on OpenGLES the y-coordinates will be flipped.
  uv.y = 1.0 - uv.y;
#endif
  vec4 color = texture(u_texture, uv);
  float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
  fragColor = vec4(vec3(gray), color.a);
}

Creación de shaders

#

Los fragment shaders se escriben como archivos fuente GLSL. Por convención, estos archivos tienen la extensión .frag. (Flutter no admite vertex shaders, que tendrían la extensión .vert).

Cualquier versión de GLSL desde la 460 hasta la 100 es compatible, aunque algunas características disponibles están restringidas. El resto de los ejemplos de este documento utilizan la versión 460 core.

Los shaders están sujetos a las siguientes limitaciones cuando se usan con Flutter:

  • UBOs y SSBOs no son compatibles
  • sampler2D es el único tipo de sampler compatible
  • Solo la versión de dos argumentos de texture (sampler y uv) es compatible
  • No se pueden declarar entradas varying adicionales
  • Se ignoran todas las sugerencias de precisión al dirigirse a Skia
  • Los enteros sin signo y los booleanos no son compatibles

Uniforms

#

Un programa fragment se puede configurar definiendo valores de uniform en la fuente del shader GLSL y luego estableciendo estos valores en Dart para cada instancia de fragment shader.

Los uniforms de punto flotante con los tipos GLSL float, vec2, vec3 y vec4 se establecen usando el método FragmentShader.setFloat o FragmentShader.getUniformFloat. Los valores de sampler GLSL, que utilizan el tipo sampler2D, se establecen usando el método FragmentShader.setImageSampler o FragmentShader.getImageSampler.

El índice correcto para cada valor de uniform se determina por el orden en que se definen los valores de uniform en el programa fragment. Para tipos de datos compuestos por múltiples floats, como un vec4, debes llamar a FragmentShader.setFloat o UniformFloatSlot.set una vez para cada valor.

Por ejemplo, dadas las siguientes declaraciones de uniforms en un programa fragment GLSL:

glsl
uniform float uScale;
uniform sampler2D uTexture;
uniform vec2 uMagnitude;
uniform vec4 uColor;

El código Dart correspondiente para inicializar estos valores de uniform es el siguiente:

dart
class Foobar {
  late final UniformFloatSlot _scale;
  late final List<UniformFloatSlot> _magnitude;
  late final List<UniformFloatSlot> _color;
  late final ImageSamplerSlot _texture;

  void setUp(FragmentShader shader) {
    _scale = shader.getUniformFloat('uScale');
    _magnitude = List<UniformFloatSlot>.generate(2, (int index) {
      return shader.getUniformFloat('uMagnitude', index);
    });
    _color = List<UniformFloatSlot>.generate(4, (int index) {
      return shader.getUniformFloat('uColor', index);
    });
    _texture = shader.getImageSampler('uTexture');
  }

  void update(Color color, Image image) {
    _scale.set(23);
    _magnitude[0].set(114);
    _magnitude[1].set(83);
    _color[0].set(color.r * color.a);
    _color[1].set(color.g * color.a);
    _color[2].set(color.b * color.a);
    _color[3].set(color.a);
    _texture.set(image);
  }
}

Al usar FragmentShader.setFloat ten en cuenta que los índices no cuentan el uniform sampler2D. Este uniform se establece por separado con FragmentShader.setImageSampler, con el índice comenzando de nuevo en 0.

Cualquier uniform flotante que se deje sin inicializar tendrá un valor por defecto de 0.0.

Los datos de reflexión generados por el compilador de shaders de Flutter se pueden auditar con los siguientes comandos para ver aspectos como los desplazamientos (offsets) de los uniforms.

shell
cd $FLUTTER
# Generate the .sl file.
`find bin/ -name impellerc` \
  --runtime-stage-metal \
  --iplr \
  --input=path/to/myshader.frag \
  --sl=foo.sl \
  --spirv=foo.spirv \
  --include=engine/src/flutter/impeller/compiler/shader_lib/ \
  --input-type=frag
# Convert the .sl file to .json
flatc \
  --json \
  ./engine/src/flutter/impeller/runtime_stage/runtime_stage.fbs \
  -- ./foo.sl
# View results
cat foo.json

Posición actual

#

El shader tiene acceso a un valor varying que contiene las coordenadas locales para el fragmento particular que se está evaluando. Usa esta función para calcular efectos que dependan de la posición actual, a la cual se puede acceder importando la librería flutter/runtime_effect.glsl y llamando a la función FlutterFragCoord. Por ejemplo:

glsl
#include <flutter/runtime_effect.glsl>

void main() {
  vec2 currentPos = FlutterFragCoord().xy;
}

El valor devuelto por FlutterFragCoord es distinto de gl_FragCoord. gl_FragCoord proporciona las coordenadas del espacio de la pantalla y generalmente debe evitarse para garantizar que los shaders sean consistentes entre backends. Al dirigirse a un backend de Skia, las llamadas a gl_FragCoord se reescriben para acceder a las coordenadas locales, pero esta reescritura no es posible con Impeller.

Colores

#

No existe un tipo de datos integrado para los colores. En su lugar, comúnmente se representan como un vec4 donde cada componente corresponde a uno de los canales de color RGBA.

La salida única fragColor espera que el valor de color esté normalizado para estar en el rango de 0.0 a 1.0 y que tenga alpha premultiplicado. Esto es diferente de los colores típicos de Flutter que usan una codificación de valores de 0-255 y tienen alpha sin premultiplicar.

Samplers

#

Un sampler proporciona acceso a un objeto Image de dart:ui. Esta imagen se puede obtener ya sea de una imagen decodificada o de parte de la aplicación usando Scene.toImageSync o Picture.toImageSync.

Ejemplo de uso de samplers en GLSL
#
glsl
#include <flutter/runtime_effect.glsl>

uniform vec2 uSize;
uniform sampler2D uTexture;

out vec4 fragColor;

void main() {
  vec2 uv = FlutterFragCoord().xy / uSize;
  fragColor = texture(uTexture, uv);
}

Por defecto, la imagen usa TileMode.clamp para determinar cómo se comportan los valores fuera del rango de [0, 1]. La personalización del modo mosaico (tile mode) no es compatible y debe ser emulada en el shader.

Ejemplo de toImageSync
#
dart
class SDFPainter {
  SDFPainter(this.sdfShader, this.renderShader);

  FragmentShader sdfShader;
  FragmentShader renderShader;
  Image? _sdf;
  bool isDirty = false;
  double radius = 0.5;

  void paint(Canvas canvas, Size size) {
    if (_sdf == null || isDirty) {
      final recorder = PictureRecorder();
      final subCanvas = Canvas(recorder);
      final paint = Paint()..shader = sdfShader;
      sdfShader.setFloat(0, size.width);
      sdfShader.setFloat(1, size.height);
      sdfShader.setFloat(2, radius);
      subCanvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
      final picture = recorder.endRecording();
      _sdf = picture.toImageSync(size.width.toInt(), size.height.toInt());
      isDirty = false;
    }

    renderShader.setFloat(0, size.width);
    renderShader.setFloat(1, size.height);
    renderShader.setImageSampler(0, _sdf!);

    canvas.drawRect(
      Rect.fromLTWH(0, 0, size.width, size.height),
      Paint()..shader = renderShader,
    );
  }
}

Consideraciones de rendimiento

#

Al dirigirse al backend de Skia, cargar el shader podría ser costoso ya que debe compilarse al shader específico de la plataforma adecuado en tiempo de ejecución. Si tienes la intención de usar uno o más shaders durante una animación, considera precargar en caché (precaching) los objetos del programa fragment antes de iniciar la animación.

Puedes reutilizar un objeto FragmentShader a través de los fotogramas; esto es más eficiente que crear un nuevo FragmentShader para cada fotograma.

Para obtener una guía más detallada sobre cómo escribir shaders eficientes, consulta Writing efficient shaders en GitHub.

Otros recursos

#

Para más información, aquí tienes algunos recursos.