Derivadas y funciones de textura
Qué calculan dpdx, dpdy y fwidth y por qué solo existen en fragmento, el truco del borde antialiaseado, y el catálogo completo de funciones de textura con la etapa en la que vale cada una.
Las derivadas son la única función de la librería estándar que consulta datos de otra invocación, y esa rareza explica todas sus restricciones. Con ellas se resuelven tres problemas que de otro modo necesitarían información que el shader no tiene: a qué resolución se está viendo una textura, cuánto ocupa un texel en pantalla, y dónde poner el degradado de un borde para que no se vea escalonado.
- Explicar qué calculan
dpdx,dpdyyfwidthy en qué se diferencian sus variantes. - Antialiasar un borde procedural con
fwidthysmoothstep. - Elegir la función de muestreo de textura correcta para cada situación.
- Saber qué funciones de textura se pueden usar fuera de la etapa de fragmentos.
Las derivadas
dpdx(e) devuelve la variación de e entre invocaciones vecinas en el eje horizontal de la pantalla; dpdy(e), en el vertical. No es una derivada analítica: es una diferencia finita entre valores de invocaciones distintas del mismo cuadrado de 2x2.
fwidth(e) es abs(dpdx(e)) + abs(dpdy(e)), una estimación barata de cuánto cambia e en el entorno de un píxel.
Las tres tienen variantes con sufijo Coarse y Fine:
| Variante | Qué hace |
|---|---|
dpdxCoarse |
una sola diferencia para las cuatro invocaciones del cuadrado |
dpdxFine |
una diferencia por pareja, más precisa y potencialmente más lenta |
dpdx |
la implementación elige; equivale a una de las dos |
En la práctica se usa la versión sin sufijo casi siempre, y las otras dos aparecen cuando hace falta control explícito, típicamente en el cálculo de niveles de mip a mano.
Las tres funciones solo existen en la etapa de fragmentos y exigen control de flujo uniforme, que es un tema por sí solo y está desarrollado en la lección sobre uniformidad.
El borde antialiaseado
Este es el uso que hay que tener automatizado, porque resuelve el problema de los bordes duros en cualquier forma procedural: texto vectorial, rejillas, círculos, curvas de nivel.
La idea es que el ancho de la transición no puede ser una constante, porque en pantalla ocupa más o menos según la distancia y el ángulo. fwidth da exactamente esa medida:
// Transicion de un pixel de ancho, sea cual sea la escala en pantalla.
fn borde(valor : f32, umbral : f32) -> f32 {
let w = fwidth(valor);
return smoothstep(umbral - w, umbral + w, valor);
}
Aplicado a una rejilla procedural, el resultado es una rejilla cuyas líneas se ven igual de nítidas de cerca y de lejos, y que se desvanece en la distancia en vez de convertirse en ruido:
@fragment
fn fs(entrada : Entrada) -> @location(0) vec4f {
let celda = fract(entrada.mundoXZ); // coordenadas dentro de la celda
let d = min(min(celda.x, celda.y), min(1.0 - celda.x, 1.0 - celda.y));
let w = fwidth(d);
let linea = 1.0 - smoothstep(0.0, w * 1.5, d);
return vec4f(vec3f(linea), 1.0);
}
El otro uso frecuente es reconstruir una normal plana a partir de la posición del mundo interpolada, sin necesidad de que la malla traiga normales:
let n = normalize(cross(dpdx(entrada.mundo), dpdy(entrada.mundo)));
Cuidado con el signo: el eje vertical de la pantalla crece hacia abajo, así que según la convención de tu escena puede hacer falta invertir el resultado. Y ten presente que esta normal es plana por triángulo: sirve para depurar, para geometría facetada y para terreno generado, no para sustituir a las normales suaves de una malla.
El catálogo de funciones de textura
| Función | Qué hace | Dónde vale |
|---|---|---|
textureSample |
muestrea con nivel de mip automático | solo fragmento |
textureSampleBias |
igual, con un sesgo sumado al nivel | solo fragmento |
textureSampleLevel |
muestrea en un nivel explícito | cualquier etapa |
textureSampleGrad |
muestrea con gradientes explícitos | sin requisito de uniformidad |
textureSampleCompare |
comparación de profundidad con filtrado | solo fragmento |
textureSampleCompareLevel |
comparación de profundidad en el nivel base | cualquier etapa |
textureSampleBaseClampToEdge |
nivel base con acotado al borde | para vídeo importado |
textureLoad |
lee un texel por coordenada entera, sin sampler | cualquier etapa |
textureStore |
escribe en una textura de almacenamiento | |
textureGather |
devuelve los cuatro texels vecinos de un componente | |
textureGatherCompare |
los cuatro resultados de comparación | |
textureDimensions |
tamaño en texels, opcionalmente de un nivel | |
textureNumLevels, textureNumLayers, textureNumSamples |
metadatos de la textura |
Varias de ellas admiten además un argumento final offset con un desplazamiento entero en texels, que tiene que ser una expresión de tiempo de compilación. Sirve para muestrear vecinos sin recalcular coordenadas y suele compilarse a un modo de direccionamiento gratuito:
let centro = textureSample(t, s, uv);
let derecha = textureSample(t, s, uv, vec2i(1, 0));
textureGather merece atención porque hace en una operación lo que en cuatro textureSample costaría cuatro. Devuelve los cuatro texels que un muestreo bilineal habría mezclado, sin mezclarlos, de un solo componente que se indica como primer argumento:
let cuatro = textureGather(0, alturas, muestreo, uv); // el componente x de los 4 vecinos
Es la base de los filtros de sombra suaves eficientes y de cualquier reconstrucción que necesite los vecinos exactos.
Fuera de la etapa de fragmentos
En un vertex shader o en un compute shader no hay cuadrados de 2x2, así que no hay derivadas y no hay nivel de mip automático. Las funciones disponibles son las que no lo necesitan: textureSampleLevel, textureSampleCompareLevel, textureLoad, textureStore y las de metadatos.
// Desplazamiento de vertices leyendo un mapa de alturas: nivel explicito.
@vertex
fn vs(@location(0) p : vec3f, @location(1) uv : vec2f) -> @builtin(position) vec4f {
let h = textureSampleLevel(alturas, muestreo, uv, 0.0).r;
return camara.viewProj * vec4f(p.x, h * escala, p.z, 1.0);
}
La diferencia entre textureLoad y textureSampleLevel es que la primera no usa sampler: lee el texel de la coordenada entera exacta, sin filtrar y sin aplicar el modo de repetición. Es la que quieres en un compute shader que procesa una imagen píxel a píxel, y la que evita depender de la configuración de un sampler que quizá no controlas.
Este artefacto es reconocible y su causa es de las que no se le ocurren a nadie mirando el código.
Tienes una textura que se repite y envuelves la coordenada con fract antes de muestrear, o usas una función de patrón que produce coordenadas discontinuas. Sobre la superficie aparece una línea fina y borrosa justo donde la coordenada da la vuelta. La textura no está mal, el sampler está en modo repetición, y aumentar la resolución no arregla nada.
Lo que pasa es esto. En el píxel donde la coordenada salta de 0.99 a 0.01, la diferencia entre invocaciones vecinas del cuadrado no es 0.02: es 0.98. textureSample interpreta esa diferencia enorme como «esta textura se está viendo comprimidísima» y selecciona el último nivel de mip, que es un solo texel del color medio. De ahí la línea borrosa: es una fila de píxeles muestreando el mip más pequeño.
Tiene tres soluciones y conviene conocer las tres. La correcta: no envolver a mano y dejar que el sampler lo haga con addressModeU: 'repeat', porque el hardware de direccionamiento envuelve después de calcular las derivadas. La que sirve cuando no puedes: calcular las derivadas de la coordenada sin envolver y muestrear con textureSampleGrad, pasándolas explícitamente.
let ddx = dpdx(uvSinEnvolver);
let ddy = dpdy(uvSinEnvolver);
let c = textureSampleGrad(t, s, fract(uvSinEnvolver), ddx, ddy);Y la que se usa cuando la discontinuidad es intrínseca, por ejemplo en un atlas o en un mapeo triplanar con mezcla: fijar el nivel con textureSampleLevel calculado a mano a partir de una coordenada continua.
El mismo mecanismo explica otros dos artefactos que parecen no tener nada que ver. En un atlas de texturas, el salto entre dos regiones adyacentes produce la misma línea. Y en un mapeo cúbico o triplanar, la mezcla entre proyecciones tiene derivadas discontinuas y produce el mismo efecto en las aristas. Cuando veas una línea borrosa justo donde una coordenada cambia de golpe, la respuesta es siempre la misma: alguien está derivando una función discontinua.