Junio 23, 2024

7 minutos de lectura

Autómatas celulares en GLSL

Índice:


Tercera y última continuación espiritual de shaders en corto. Con este post me voy de vacaciones de los shaders. Probablemente los siga usando, pero no escribiré más posts enfocados en GLSL.

Un adelanto del resultado final:

Viendo la complejidad que tiene Lenia para ser implementado, en este post se experimentará específicamente haciendo una versión del juego de la vida más "suave" (ya existen btw).

Las reglas del juego de la vida se pueden encontrar en Wikipedia 🗿:

  • Nace: Si una célula muerta tiene exactamente 3 células vecinas vivas "nace" (es decir, al turno siguiente estará viva).
  • Muere: una célula viva puede morir por uno de 2 casos:
    • Sobrepoblación: si tiene más de tres vecinos alrededor.
    • Aislamiento: si tiene solo un vecino alrededor o ninguno.
  • Vive: una célula se mantiene viva si tiene 2 o 3 vecinos a su alrededor.

Juego de la vida proporcional

El juego de la vida es algo determinante y el primer experimento es cambiar las reglas a algo más flexible. Las reglas quedarían así:

Siendo n la suma de la vida de los vecinos, la celda cambia respecto a n en los siguientes rangos:

  • [0, 1]: Vida disminuye en n.
  • [1, 3]: Vida aumenta en n/3.
  • 3 a más: Vida disminuye en n/8 (el máximo de n es 8).

Para ver la diferencia, se usará el siguiente estado inicial:

Y con las nuevas reglas:

Viene con trucos:

  • Se cambió el rango de aumentar vida de [1, 3] a [1, 1.5]. Simplemente porque se ve mejor 👍. Se puede probar otros valores moviendo el slider, representa el gap para aumentar la vida. El máximo es 8, lo cual hace que crezca sin parar.
  • Esquinas redondeadas: Suponiendo que la distancia horizontal y vertical es de 1, la vida de las celdas diagonales se multiplicó por 1 / sqrt(2) (solo se considera la vida proporcionalmente a la distancia horizontal y vertical).

Valores cercanos a 4 (como el del ejemplo) forman patrones rectos.

Juego de la vida proporcional y zonal

En este experimento se aumentará el área considerada para los vecinos. Con ello se tendrían 2 variables gap (que representa la formación de vida) y r (el radio considerado para los vecinos).

Las reglas ahora son, siendo n la suma de la vida de los vecinos, la celda cambia respecto a n en los siguientes rangos:

  • [0, 1]: Vida disminuye en n.
  • [1, gap]: Vida aumenta en n/gap.
  • gap a más: Vida disminuye en n/totalN.

No hay una explicación "lógica" para totalN, solo se sigue la corriente de lo que se propuso antes con 1 / sqrt(2) para las esquinas.

totalN: Representa la suma de las distancias proporcionales de los vecinos.

Por ejemplo:

float dist = length(vec2(i, j) - vec2(k, l)); // Distance de la celda evaluada (i, j) al vecino (k, l)
float factor = 1.0 / dist; // Variable propuesta
float curLive = getState(k, l) * factor;
n += curLive;
totalN += factor;

Dos puntos a tener en cuenta:

  • El primer slider es el gap de vida y el segundo el radio r para considerar vecinos.
  • Se hizo upgrade a la versión de GLSL para usar las variables de los loops como índices (no es importante, solo quería mencionarlo).

Tunenado el juego de la vida

En el experimente anterior se percibe un cambio drástico entre estados (por eso se redujo a 4 frames por segundo), a este punto solo se busca que se formen patrones más consistentes entre frames (ya ni se aplican las reglas propuestas previamente).

live += (uGap - n) * n

  • Primer slider: gap
  • Segundo slider: Radio para considerar un vecino (r).

Hay muchos sacrilegios tomados para hacer que luzca genial 🙂. No deberían ser problema, el código principal es el siguiente:

void main() {
  float i = vXY.x * uWidth - 0.5;
  float j = uHeight - vXY.y * uHeight - 0.5;

  float live = getState(i, j);

  float n = 0.0;
  for(float r = 1.0; r <= uR; r++) {
    float points = 8.0 * r;
    for(float p = 0.0; p < points; p++) {
      float k = i + r * cos(2.0 * PI * p / points);
      float l = j + r * sin(2.0 * PI * p / points);
      float dist = length(vec2(i, j) - vec2(k, l));
      float factor = 100.0 / dist; // 😉
      float klLive = getState(k, l) * factor;
      n += klLive;
    }
  }

  live += (uGap - n) * n;

  fragColor = vec4(vec3(live), 1.0);
}

El factor tomó un papel importante, reduce el impacto de los vecinos más lejanos. El cálculo del nuevo estado se redujo a live += (uGap - n) * n;, se omitió el caso de aislamientos.

Pero parece que no son suficientes cambios para ver una interacción más suave. Tiene el aspecto que hay mucha probabilidad de sobrevivir, pero el aspecto es más orgánico.

Con estos resultados se ven 2 oportunidades:

  • Dar más posibilidad de sobrevivir a casos medios.
  • Se puede reducir las reglas a una fórmula para obtener patrones.

Una fórmula que me interesó mucho es la siguiente, da patrones más consistentes.

live += -n * log(n) + n * uGap

Pero los cambios entre estados ahora son más fuerte fuertes (ósea se mueve más rápido). Por lo que hay que dar más chance a la muerte o reducir el impacto de los vecinos.

live += -pow(n - uGap, 2.0) + uGap

Después de rebuscar valores que generen patrones orgánicos, se encontró que con esta regla se pueden conseguir. Los valores son algo rebuscados pero se consiguió una función que representa bien un juego suave:

Se dejó el factor en float factor = 1.0 / dist; (tiene más sentido) y los parámetros son los siguientes:

  • gap: 12.9
  • r: 5

Sin embargo, después de unas iteraciones el patrón se vuelve caótico. Aunque, ese inicio es un logro.

En este ejemplo se renderizan varios puntos aleatorios como estado inicial. Dale play (tanto como gustes 🙂) para ver distintos patrones.

Juego de la vida tuneado + colores

Último experimento. Darle un poco de vida con colores. Podrían modificarse las reglas para generar distintos estados dependiendo del color y es justo lo que se muestra a continuación:

Ahora los patrones son más estables y quedan varias células autosuficientes. Los parámetros son:

  • gap: 13
  • Radio r: 5

Se tienen 3 dimensiones por celda. Se calculan vecinos por cada dimension, por lo que n ahora es un vec3. La interacción en este caso es pasar una dimensión distinta para el cálculo de la celda evaluada:

live.r += -pow(n.b - uGap, 2.0) + uGap;
live.g += -pow(n.r - uGap, 2.0) + uGap;
live.b += -pow(n.g - uGap, 2.0) + uGap;

Algo a notar es que hay patrones de expansión rectos, esto es por el cálculo de la vida en la vecindad. Se usan coordenadas polares, se da una vuelta y se va ampliando el radio. El problema está en la cantidad de puntos que se evalúan por radio, como se muestra:

float points = 8.0 * r;

Aumentando la cantidad de puntos por radio requiere buscar otros parámetros, pero esto mejora en la estabilidad de los patrones:

  • gap: 17.5
  • r: 4

Para estabilizar los colores se puede hacer más dependiente a cada dimensión de las demás, un buen patron encontrado es el siguiente:

live.r += -pow((n.r) / 2.0 - uGap, 2.0) + uGap;
live.g += -pow((n.r + n.g) / 3.0 - uGap, 2.0) + uGap;
live.b += -pow((n.r + n.g + n.b) / 4.0 - uGap, 2.0) + uGap;

Ejecútalo varias veces, se obtienen patrones bastante suaves pero hay momentos caóticos.


Nota final:

Muchos de los cálculos desde tunear son buscados al ojo. Son experimentos para pasar el rato (uno bueno). Si tienes alguna propuesta, feliz de escucharla o probarla!


No puedo terminar sin antes recrear el primer ejemplo con el nuevo autómata creado!

Por esta ocasión pondré todo el código aquí (siempre está disponible inspeccionando el iframe), puedes pegarlo y editarlo aquí:

p5.RendererGL.prototype._initContext = function () {
  try {
    this.drawingContext =
      this.canvas.getContext("webgl2", this._pInst._glAttributes) ||
      this.canvas.getContext("experimental-webgl", this._pInst._glAttributes);
    if (this.drawingContext === null) {
      throw new Error("Error creating webgl context");
    } else {
      const gl = this.drawingContext;
      gl.enable(gl.DEPTH_TEST);
      gl.depthFunc(gl.LEQUAL);
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
      this._viewport = this.drawingContext.getParameter(
        this.drawingContext.VIEWPORT
      );
    }
  } catch (er) {
    throw er;
  }
};

const vertShader = `#version 300 es
in vec3 aPosition;
out vec2 vXY;
void main() {
  vec4 pos = vec4(aPosition, 1.0);
  vXY = pos.xy;
  pos = pos * 2.0 - 1.0;
  gl_Position = pos;
}`;

const fragShader = `#version 300 es
precision mediump float;
in vec2 vXY;
uniform float uWidth;
uniform float uHeight;
uniform float uR;
uniform float uLGap;
uniform float uGap;
uniform sampler2D uState;
uniform vec2 uCircle;
out vec4 fragColor;
const float PI = 3.14159264;

vec3 getState(float i, float j) {
  vec4 data = texture(
    uState,
    vec2(
      (i + 0.5) / uWidth,
      (j + 0.5) / uHeight
    )
  );
  return data.rgb;
}

void main() {
  float i = vXY.x * uWidth - 0.5;
  float j = uHeight - vXY.y * uHeight - 0.5;

  vec3 live = getState(i, j);

  vec3 n = vec3(0.0);
  for(float r = 1.0; r <= uR; r++) {
    float points = 16.0 * r;
    for(float p = 0.0; p < points; p++) {
      float k = i + r * cos(2.0 * PI * p / points);
      float l = j + r * sin(2.0 * PI * p / points);
      float dist = length(vec2(i, j) - vec2(k, l));
      float factor = 1.0 / dist;
      vec3 klLive = getState(k, l) * factor;
      n.r += klLive.r;
      n.g += klLive.g;
      n.b += klLive.b;
    }
  }

  vec2 pos = (vXY * 2.0 - 1.0);
  pos.x *= (uWidth / uHeight);
  float circleDist = length(pos - uCircle);
  
  if(circleDist < 0.1) {
    live = vec3(1.0);
  } else {
    live.r += -pow((n.r) / 2.0 - uGap, 2.0) + uGap;
    live.g += -pow((n.r + n.g) / 3.0 - uGap, 2.0) + uGap;
    live.b += -pow((n.r + n.g + n.b) / 4.0 - uGap, 2.0) + uGap;
  }

  fragColor = vec4(live, 1.0);
}
`;

let myShader;
let g;

function setup() {
  createCanvas(350, 320, WEBGL);
  g = createGraphics(320, 320, WEBGL);
  myShader = createShader(vertShader, fragShader);

  g.shader(myShader);
  g.stroke(255);
  g.fill(255);

  function restart() {
    g.background(0);

    const points = 3000;
    for (let i = 0; i <= points; i++) {
      const x = width * (random(2) - 1);
      const y = height * (random(2) - 1);
      g.stroke(color(random(255), random(255), random(255)));
      g.point(x, y);
    }

    imageMode(CENTER);
    image(g, 0, 0, width, height);
  }

  restart();
}

function draw() {
  const angle = frameCount / 80;
  const uCircle = [cos(angle) * 0.6, sin(angle) * 0.6];
  myShader.setUniform("uState", g);
  myShader.setUniform("uCircle", uCircle);
  myShader.setUniform("uR", 5);
  myShader.setUniform("uGap", 13.7);
  myShader.setUniform("uWidth", width);
  myShader.setUniform("uHeight", height);
  g.rect(0, 0, 0, 0);
  imageMode(CENTER);
  image(g, 0, 0, width, height);
}