moloxe.io / blog / mandelbulb-en-glsl
Esta es la continuación espiritual de shaders en corto. Aquí describiré los pasos para renderizar Mandelbulb en GLSL.
Qué es Mandelbulb? un fractal en 3-dimensiones. De nada.
En el post previo se muestra Mandelbrot (un fractal en 2-dimensiones) como se muestra a continuación:
Mandelbulb tiene la intención de mostrar como sería Mandelbrot en 3-dimensiones. Y si me equivoco, pues me equivoqué.
Trayectoria:
ray marching
prematuros y de álgebra lineal no muy remotos. (lado positivo: algunas cosas son caja negra)La intención es construir una versión propia, separada por partes (a mi manera (es una dictadura)).
El ray marching
es la técnica que se usará para renderizar Mandelbulb. Existen otras, es probable que también sirvan, pero parto de la asunción de que es posible ya que existen muchas implementaciones.
Esta técnica es iterativa, busca la intersección entre un supuesto rayo de luz y un objeto. En otras palabras, se lanza un rayo de luz desde un punto de la pantalla hasta encontrar un objeto.
Para aplicar la técnica en GLSL la parte importante es el fragment shader
.
El fragment shader es el siguiente:
precision highp float;
varying vec2 uv;
uniform float uTime;
mat3 rotationX(float angle) {
float c = cos(angle);
float s = sin(angle);
return mat3(
1.0, 0.0, 0.0,
0.0, c, -s,
0.0, s, c
);
}
mat3 rotationY(float angle) {
float c = cos(angle);
float s = sin(angle);
return mat3(
c, 0.0, s,
0.0, 1.0, 0.0,
-s, 0.0, c
);
}
float sdCube(vec3 p) {
vec3 boxSize = vec3(1.0, 1.0, 1.0);
vec3 d = abs(p) - boxSize;
return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}
float distanceField(vec3 p) {
float angle = uTime;
p = rotationY(angle) * p;
p = rotationX(angle) * p;
return sdCube(p);
}
vec3 rayMarch(vec3 ro, vec3 rd) {
// Menor valor oculta la escena de de atrás hacia adelante
const float maxDistance = 100.0;
// Menor valor oculta las esquinas de los objetos
const float minDistance = 0.001;
// Mayor valor aumenta la fidelidad de la imágen a mayor coste computacional
const int maxIter = 100;
float totalDistance = 0.0;
vec3 p;
for (int i = 0; i < maxIter; i++) {
p = ro + totalDistance * rd;
float d = distanceField(p);
if (d < minDistance) {
return vec3(p); // Superficie
}
totalDistance += d;
if (totalDistance > maxDistance) {
break;
}
}
return vec3(0.0, 0.0, 0.0); // Fondo
}
void main() {
vec3 ro = vec3(0.0, 0.0, 4.0); // Posición del observador
vec3 rd = normalize(vec3(uv, -1.0)); // Dirección del rayo
vec3 color = rayMarch(ro, rd);
gl_FragColor = vec4(color, 1.0);
}
Son muchas cosas pero lo importante es rayMarch
:
vec3 rayMarch(vec3 ro, vec3 rd) {
// Menor valor oculta la escena de de atrás hacia adelante
const float maxDistance = 100.0;
// Menor valor oculta las esquinas de los objetos
const float minDistance = 0.001;
// Mayor valor aumenta la fidelidad de la imágen a mayor coste computacional
const int maxIter = 100;
float totalDistance = 0.0;
vec3 p;
for (int i = 0; i < 100; i++) {
p = ro + totalDistance * rd;
float d = distanceField(p);
if (d < minDistance) {
return vec3(p); // Superficie
}
totalDistance += d;
if (totalDistance > maxDistance) {
break;
}
}
return vec3(0.0, 0.0, 0.0); // Fondo
}
A diferencia de otros métodos de renderizado, este es estimado. La parte más relevante es la que nos permite obtener la coordenada p
. Se obtiene de forma iterativa, cuando la distancia cumple con la condición de ser menor a minDistance
el punto p
es considerado parte de la escena (de alguna superficie). Con ello se puede asignar un valor al color.
El resto del código son utilidades (álgebra lineal: posiciones, rayos de luz, rotaciones, etc...). Para los objetos existen muchas funciones con las que se puede calcular la distancia a uno de ellos, puedes revisar:
En distanceField
es donde se calculan las distancias a cada objeto, en este caso hace rotaciones y calcula la distancia a un cubo.
La referencia principal se toma del siguiente recurso:
También sirve ver la referencia en wikipedia:
Pasos:
Suena simple pero el criterio es extenso. Primero es necesario pasar la coordenada p
a coordenadas esféricas:
vec3 toSpherical(vec3 p) {
float r = length(p);
float theta = acos(p.z / r);
float phi = atan(p.y, p.x);
return vec3(r, theta, phi);
}
No puedo decir mucho, la fórmula de White y Nylander hacen posible el cálculo del conjunto de Mandelbulb. ✨
Para no entrar en mucho detalle, empecemos del main
:
void main() {
vec3 ro = vec3(0.0, 0.0, 1.6);
vec3 rd = normalize(vec3(uv, -1.0));
vec4 color = rayMarch(ro, rd);
gl_FragColor = color;
}
Es lo mismo que en ejemplos anteriores, se modificaron los valores para mostrar una visualización más apreciable.
rayMarch
:
vec4 rayMarch(vec3 ro, vec3 rd) {
const float maxDistance = 100.0;
const float minDistance = 0.001;
const int maxIter = 100;
float totalDistance = 0.0;
vec3 p;
for (int i = 0; i < maxIter; i++) {
p = ro + totalDistance * rd;
vec2 dfVec = distanceField(p);
float d = dfVec.x;
if (d < minDistance) {
float effort = dfVec.y;
float g = effort;
float b = 1.0 - effort;
return vec4(0.0, g, b, 1.0);
}
totalDistance += d;
if (totalDistance > maxDistance) {
break;
}
}
return vec4(0.0);
}
Se añadió un parámetro extra (effort
) que representa cuantas iteraciones se realizaron para saber si una coordenada pertenece al conjunto (verde: muchas iteraciones, azul: pocas iteraciones).
distanceField
:
vec2 distanceField(vec3 p) {
float angle = uTime / 10.0;
p = rotationY(angle) * p;
p = rotationX(PI / 2.0) * p;
return sdMandelbulb(p, 8.0);
}
Para apreciar el mejor el fractal, se hacen ligeras rotaciones.
sdMandelbulb
: 💀
vec2 sdMandelbulb(vec3 p, float power) {
const int maxIter = 12;
vec3 zeta = p;
float dr = 1.0;
float r = 0.0;
int iterations = 0;
for (int _ = 0; _ < maxIter; _++) {
iterations++;
vec3 spherical = toSpherical(zeta);
r = spherical.x;
if (r > 2.0) {
break;
}
dr = pow(r, power - 1.0) * power * dr + 1.0;
float theta = spherical.y * power;
float phi = spherical.z * power;
float sinTheta = sin(theta);
float powx = pow(r, power) * sinTheta * cos(phi);
float powy = pow(r, power) * sinTheta * sin(phi);
float powz = pow(r, power) * cos(theta);
zeta.x = powx + p.x;
zeta.y = powy + p.y;
zeta.z = powz + p.z;
}
float d = 0.5 * log(r) * r / dr;
float effort = float(iterations) / float(maxIter);
return vec2(d, effort);
}
Esta función estima la distancia a una coordenada que pertenezca al conjunto de Mandelbulb. No comprendo muy bien el cálculo de dr
, pero entiendo que es su propósito.
Adicionalmente, se añadió effort
como una utilidad para distinguir entre los puntos del conjunto.
En el ejemplo anterior se usa una potencia de 8, sin embargo, lo genial viene de probar distintos valores.
Una referencia (para entrar a fondo) la pueden encontrar en el comentario mandelbulbSDF.glsl
de lygia
:
https://github.com/patriciogonzalezvivo/lygia/blob/main/sdf/mandelbulbSDF.glsl#L40
// distance estimation through the Hubbard-Douady potential from Inigo Quilez
return vec2(0.25*log(m) * sqrt(m) / dz, iterations);
No entiendo con certeza la similitud con lo que conseguí, pero obivo existe...
Este es muy similar al del post anterior.
Eso, el código con el que se creó la portada está en ese iframe
.
Nota: A nivel personal me queda pendiente experimentar con estados previos para calcular el siguiente, así como en el juego de la vida del post anterior. Igual lo dejo again (copiar es gratis):