wandres.dev
WGSL V · La librería estándar

select, mix, step y smoothstep: escribir sin ramas

El orden de argumentos de select que todo el mundo se equivoca, la definición exacta de mix y sus consecuencias numéricas, y cómo se construyen funciones por tramos sin condicionales.

⏱ 16 min

Estas cuatro funciones son las que convierten condiciones en aritmética, y son el vocabulario con el que se escriben los shaders que no ramifican. Tres son idénticas a las de GLSL. La cuarta, select, existe solo en WGSL, tiene los argumentos en un orden que nadie acierta a la primera, y produce un bug perfectamente silencioso cuando te equivocas: el shader compila, dibuja, y todo sale invertido.

🎯 Al terminar esta lección sabrás
  • Usar select con el orden correcto de argumentos, en versión escalar y por componentes.
  • Enunciar la definición exacta de mix y sus consecuencias en los extremos.
  • Construir funciones por tramos con step y smoothstep.
  • Decidir cuándo escribir sin ramas aporta algo y cuándo solo empeora la legibilidad.

select y su orden de argumentos

select(valorSiFalso, valorSiCierto, condicion)

El valor falso va primero. Es el orden inverso al del operador ternario de casi todos los lenguajes, donde la condición abre la expresión, y es la causa de una cantidad notable de shaders con el color de fondo y el de frente intercambiados.

// Ternario mental: cond ? a : b
// En WGSL:
let color = select(b, a, cond);

La forma de recordarlo que funciona es pensar en el orden numérico: el caso false es el 0 y va primero, el caso true es el 1 y va segundo, y la condición cierra.

select tiene además una forma por componentes que no tiene equivalente en un ternario: si la condición es un vector de booleanos, la selección se hace componente a componente.

let a = vec3f(1.0, 2.0, 3.0);
let b = vec3f(9.0, 9.0, 9.0);
let m = vec3<bool>(true, false, true);
let r = select(a, b, m);        // vec3f(9.0, 2.0, 9.0)

Fíjate en que aquí a es el valor falso: r.y toma a.y porque m.y es false.

Y una propiedad importante: select evalúa las dos expresiones. No es un condicional perezoso. Si uno de los dos lados es caro o tiene un efecto colateral, select no te ahorra nada; para eso hace falta un if.

mix, clamp y saturate

mix(e1, e2, e3)      // e1 * (1 - e3) + e2 * e3
clamp(e, bajo, alto) // min(max(e, bajo), alto)
saturate(e)          // clamp(e, 0.0, 1.0)

mix acepta el tercer argumento escalar o vectorial. Con escalar, interpola los dos vectores enteros con el mismo factor; con vector, cada componente con el suyo, lo cual sirve para combinar dos texturas con una máscara de tres canales en una sola operación.

El factor no está acotado: mix(a, b, 2.0) extrapola más allá de b, y mix(a, b, -1.0) extrapola en la dirección contraria. Es útil deliberadamente —así se hacen los ajustes de saturación y de contraste—, y es un bug cuando el factor viene de una división que se sale de rango sin que lo esperases. Si necesitas acotar, saturate el factor primero.

clamp con bajo mayor que alto no está definido de forma útil: no supongas que devuelve algo razonable. Es una situación que aparece cuando los dos límites vienen de datos, y merece una comprobación en el lado de la CPU.

step y smoothstep

step(borde, x)              // 0.0 si x < borde, 1.0 si no
smoothstep(bajo, alto, x)   // 0 por debajo, 1 por encima, curva suave en medio

step es un escalón duro y su uso principal es convertir una comparación en un número que multiplica. smoothstep es su versión suavizada, y su interior es el polinomio de Hermite t * t * (3 - 2t) sobre el valor normalizado entre los dos bordes, con derivada nula en los dos extremos.

Esa derivada nula es lo que la hace útil para transiciones: una interpolación con smoothstep no tiene esquinas visibles donde empieza y donde acaba, al contrario que una interpolación lineal.

// Atenuacion suave de una luz con radio.
let d = distance(p, luz.posicion);
let atenuacion = 1.0 - smoothstep(luz.radio * 0.7, luz.radio, d);

Si bajo y alto están al revés o son iguales, el resultado no es fiable; y si necesitas el escalón inverso, la forma idiomática es restar de uno, como arriba, y no intercambiar los bordes.

Escribir sin ramas

Con estas cuatro funciones se construye cualquier función por tramos. El patrón general es acumular contribuciones multiplicadas por sus máscaras:

// Una rampa de tres colores segun la altura, sin un solo if.
fn colorPorAltura(h : f32) -> vec3f {
  let arena  = vec3f(0.76, 0.70, 0.50);
  let hierba = vec3f(0.25, 0.45, 0.20);
  let roca   = vec3f(0.45, 0.45, 0.47);

  var c = arena;
  c = mix(c, hierba, smoothstep(0.15, 0.25, h));
  c = mix(c, roca,   smoothstep(0.55, 0.70, h));
  return c;
}

Cada mix sucesivo sobrescribe progresivamente el anterior, y las transiciones se solapan de forma continua. Escrito con if daría bordes duros y tres ramas.

💡
Sin ramas no significa más rápido

El código sin ramas se paga en instrucciones ejecutadas siempre. Merece la pena cuando las dos alternativas son aritmética barata, que es el caso de arriba, y cuando la condición varía entre píxeles vecinos, que es cuando una rama de verdad divergiría. No merece la pena cuando una de las dos alternativas incluye muestreos de textura o bucles: ahí un if que el grupo entero puede saltarse ahorra latencia real, y la versión sin ramas ejecuta lo caro siempre.

mix está definido como a por (1-t) más b por t, y esa forma no es la que usarías a mano

Hay dos maneras de escribir una interpolación lineal y son numéricamente distintas. WGSL especifica esta:

mix(a, b, t) = a * (1 - t) + b * t

Y la que casi todo el mundo escribe a mano es la otra:

a + t * (b - a)

Las dos son iguales en aritmética real y ninguna de las dos domina a la otra en coma flotante. La forma de WGSL acierta exactamente en los extremos: con t igual a 1 el resultado es exactamente b, y con t igual a 0 es exactamente a, porque los productos por cero y por uno son exactos. La forma manual no lo garantiza: a + 1.0 * (b - a) puede no dar exactamente b si hay cancelación en la resta.

A cambio, la forma manual es monótona —el resultado nunca retrocede al aumentar t— y la de WGSL puede no serlo por unos pocos ulps en el peor caso, cuando a y b tienen magnitudes muy distintas.

Dónde importa esto de verdad, que es la parte útil. En animación por keyframes, la exactitud en los extremos es lo que hace que una animación pare exactamente donde tiene que parar y que dos clips encadenados no den un salto de un microtón: ahí quieres mix. En mapeados que tienen que ser monótonos —una curva de tono, una función de transferencia, un remapeado de profundidad que después se compara—, un retroceso de un ulp puede producir un píxel invertido en un gradiente: ahí quieres la forma manual, o mejor, fma, que redondea una sola vez.

Y hay un tercer caso donde ninguna de las dos vale: cuando interpolas direcciones. Interpolar dos vectores unitarios linealmente y normalizar después no da velocidad angular constante, y para rotaciones hace falta interpolación esférica de verdad. mix sobre cuaterniones funciona para ángulos pequeños y visiblemente mal para ángulos grandes, que es el bug clásico del esqueleto que se colapsa al interpolar entre dos poses opuestas.

Nada de esto se ve leyendo la definición en la referencia. Se ve cuando alguien pregunta por qué un valor que debería ser exactamente 1.0 vale 0.99999994 y rompe una comparación.