셀룰러 노이즈의 다양한 변형 이해하기

0

셀룰러 노이즈(cellular noise)는 근방의 시드 점까지의 거리를 기준으로 공간을 분할하여 유기적인 세포 형태의 패턴을 만들어낸다. 컴퓨터 그래픽스, 자연 시뮬레이션, 생성 예술에 활용된다. 이 글에서는 GLSL로 구현한 셀룰러 노이즈의 여러 변형을 살펴본다: Voronoise, 경계 하이라이팅, 가중 Voronoi, 계층적 Voronoi. 각 변형은 거리 척도나 셀의 시각적 표현 중 하나를 수정하여 서로 다른 효과를 만든다. 셰이더 프로그래밍으로 이 패턴들을 구현하는 방법 및 원리를 설명하고, 예제 코드를 함께 제공한다.

셀룰러 노이즈는 Worley 노이즈 또는 Voronoi 노이즈라고도 불리며, 주어진 점들까지의 거리를 기준으로 공간을 여러 영역으로 나눈다. 시드 점이라 불리는 각 점은 다른 시드 점보다 그 시드 점에 더 가까운 점들의 집합인 셀을 형성한다. 이론적으로는 어떤 점이 어느 시드 점에 속하는지 찾기 위해 모든 시드 점까지의 거리를 계산해야 한다. 그러나 이는 계산 비용이 크다. 실제로는 캔버스를 정규 격자로 나누고 각 칸에 하나의 시드 점을 둔다. 이렇게 하면 한 픽셀 주변의 9개 인접 시드 점으로 거리 검사를 제한할 수 있어, 핵심 구조를 보존하면서 계산을 크게 줄인다. 이 글에서는 Voronoi 기하학을 이용해 만들 수 있는 다양한 패턴을 보인다.

Voronoi 거리

이는 GLSL에서 가장 간단한 셀룰러 노이즈 버전이다. 9개 인접 칸의 시드 점까지의 거리를 계산하여 가장 작은 거리 값을 출력한다.

#ifdef GL_ES
precision mediump float;
#endif

#define N 5.

uniform vec2 u_resolution;

vec3 random3 (vec2 p)
{
  vec3 q = vec3( dot(p,vec2(127.1,311.7)), 
          dot(p,vec2(269.5,183.3)), 
          dot(p,vec2(419.2,371.9)) );
  return fract(sin(q)*43758.5453);
}

float voronoi (vec2 st) {
  // Tile the space
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);

  float m_dist = N;

  for (int y= -1; y <= 1; y++)
  for (int x= -1; x <= 1; x++) {
    // Neighbor place in the grid
    vec2 neighbor = vec2(float(x),float(y));

    // Random position from current + neighbor place in the grid
    vec2 point = random3(i_st + neighbor).xy;

    // Distance to the point
    float dist = length(neighbor + point - f_st);

    // Keep the closer distance
    m_dist = min(m_dist, dist);
  }
  return m_dist;
}

void main() {
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  st.x *= u_resolution.x/u_resolution.y;

  float c = voronoi( N*(st) );
    
  gl_FragColor = vec4(vec3(c),1.0);
}

Voronoi 다이어그램

가장 가까운 거리뿐 아니라 픽셀이 어느 시드 점에 속하는지도 결정하기 위해, 추가 데이터인 가장 가까운 시드 점의 좌표를 저장한다. 이는 min 로직을, if 조건으로 대체하면 된다. 가장 가까운 시드 점의 격자 위치 i_st + neighbor가 반환값의 일부가 되어, 각 영역을 그 시드 점의 위치를 기준으로 색칠할 수 있게 하여 더 명확한 셀 기반 다이어그램을 만들 수 있다.

vec3 voronoi (vec2 st) {
  // Tile the space
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);

  vec3 m = vec3(N, 0., 0.);

  for (int y= -1; y <= 1; y++)
  for (int x= -1; x <= 1; x++) {
    // Neighbor place in the grid
    vec2 neighbor = vec2(float(x),float(y));

    // Random position from current + neighbor place in the grid
    vec2 point = random3(i_st + neighbor).xy;

    // Distance to the point
    float dist = length(neighbor + point - f_st);

    // Keep the closer distance
    if (dist < m.x) {
      m.x = min(m.x, dist);
      m.yz = i_st + neighbor;
    }
  }
  return m;
}

void main() {
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  st.x *= u_resolution.x/u_resolution.y;

  vec3 m = voronoi( N*st );
  vec3 color = random3( m.yz );
    
  gl_FragColor = vec4(color, 1.);
}

Voronoi 경계 찾기

셀의 경계를 시각화하기 위해 거리장(distance field)을 사용하고 smoothstep을 적용해 경계 영역을 강조할 수 있다. gl_FragColor = vec4(vec3(c),1.0)gl_FragColor = vec4(vec3(smoothstep(0., 1.4142, c)),1.0)으로 대체했다. 그러나 이 방법은 시드 점 사이의 거리에 따라 경계 두께가 균일하지 않다. 더 정밀한 접근은, 두 시드 점 사이의 수직 이등분선으로 정의되는 실제 Voronoi 모서리까지의 거리를 계산하는 것이다.

이를 계산하려면 가장 가까운 시드 점의 위치와 그 인접 시드 점들이 필요하다. 두 시드 점(가장 가까운 시드 점 \(p_a\)와 인접 시드 점 중 하나 \(p_b\))이 주어지면, 그 사이의 경계는 둘의 중간에 나타나며, 그 방향 벡터는 둘 사이의 선분 \(p_a - p_b\)에 수직이다. 따라서 경계로부터 점 \(x\)까지의 거리는, 선분 벡터 \(p_a - p_b\)와 \(x - p_c\)의 내적으로 계산할 수 있다. 여기서 \(p_c\)는 경계 위의 임의의 점이며, 아래 코드에서는 \(p_c = 0.5 * (p_a + p_b)\)를 사용하였다. 인접 시드 점을 모두 확인하기 위해 두 개의 for 문을 사용한다. 첫 번째는 가장 가까운 시드 점을 찾고, 두 번째는 모든 인접 시드 점으로부터의 거리를 계산한다. 다음 인터랙티브 이미지는 Voronoi 거리와 Voronoi 경계의 차이를 보여준다.

Distance
Boundary
// Created by inigo quilez - iq/2013
// License Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
// http://www.iquilezles.org/www/articles/voronoilines/voronoilines.htm
// Edited by Sangil Lee

#ifdef GL_ES
precision mediump float;
#endif

#define N 5.

uniform vec2 u_resolution;

vec3 random3 (vec2 p)
{
  vec3 q = vec3( dot(p,vec2(127.1,311.7)), 
          dot(p,vec2(269.5,183.3)), 
          dot(p,vec2(419.2,371.9)) );
  return fract(sin(q)*43758.5453);
}

float voronoi( in vec2 x ) {
  vec2 i_st = floor(x);
  vec2 f_st = fract(x);

  // first pass: regular voronoi
  vec2 closest_neighbor, closest_point;
  float min_dist = N;
  for (int j= -1; j <= 1; j++)
  for (int i= -1; i <= 1; i++) {
    vec2 neighbor = vec2(float(i),float(j));
    vec2 point = random3(i_st + neighbor).xy;

    float dist = length(neighbor + point - f_st);

    if ( dist < min_dist ) {
      min_dist = dist;
      closest_point = neighbor + point;
      closest_neighbor = neighbor;
    }
  }

  // second pass: distance to borders
  min_dist = N;
  for (int j= -2; j <= 2; j++)
  for (int i= -2; i <= 2; i++) {
    if (i == 0 && j == 0) continue;
    vec2 neighbor = closest_neighbor + vec2(float(i),float(j));
    vec2 point = random3(i_st + neighbor).xy;

    vec2 second_closest_point = neighbor + point;

    min_dist = min(min_dist, dot( 0.5*(closest_point+second_closest_point) - f_st, normalize(second_closest_point-closest_point) ));
  }
  return min_dist;
}

void main() {
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  st.x *= u_resolution.x/u_resolution.y;

  // Scale
  float c = voronoi(st*N);

  // borders
  vec3 color = vec3(0.);
  color = mix( vec3(1.0), color, smoothstep( 0.0, 0.05, c ) );
  gl_FragColor = vec4(color,1.0);
}

가중 Voronoi 노이즈

이전 버전들에서는 모든 시드 점이 동일한 거리 함수를 사용하여 균일한 크기의 셀을 만들었다. 다양성을 주기 위해, 거리에 가중치를 적용하여 일부 셀이 더 크거나 작게 보이도록 한다. 간단히 length(x - y)1.0 / w * length(x - y)로 대체하면 된다. 가중치 w는 시드 점마다 무작위로 할당된다. 이는 역동적이고 불규칙한 셀룰러 텍스처를 만든다.

// The MIT License
// Copyright © 2025 Sangil Lee

#ifdef GL_ES
precision mediump float;
#endif

#define N 5.

uniform vec2 u_resolution;
uniform float u_time;

vec3 random3 (vec2 p)
{
  vec3 q = vec3( dot(p,vec2(127.1,311.7)), 
          dot(p,vec2(269.5,183.3)), 
          dot(p,vec2(419.2,371.9)) );
  return fract(sin(q)*43758.5453);
}

float my_dist (vec2 x, vec2 y, float w) {
  return 1./w * length(x-y);
}

float voronoi (vec2 st) {
  // Tile the space
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);

  float m_dist = N;

  for (int y= -2; y <= 2; y++)
  for (int x= -2; x <= 2; x++) {
    // Neighbor place in the grid
    vec2 neighbor = vec2(float(x),float(y));

    // Random position from current + neighbor place in the grid
    vec2 point = random3(i_st + neighbor).xy;

    // Distance to the point
    float weight = 0.2 + 0.8*random3(i_st + neighbor).z;
    float dist = my_dist(neighbor + point, f_st, weight);

    // Keep the closer distance
    m_dist = min(m_dist, dist);
  }
  return m_dist;
}

void main() {
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  st.x *= u_resolution.x/u_resolution.y;

  float c = voronoi( N*(st) );
    
  gl_FragColor = vec4(vec3(c*0.5),1.0);
}

실용적 Voronoi 경계

위의 가중 Voronoi의 경우, 정확한 경계를 계산하는 것은 간단하지 않다. 대신 유한 차분(finite difference)을 이용해 경계를 근사한다. 각 시드 점에 고유한 ID를 부여한 뒤, Voronoi 함수를 세 번 평가한다: 한 번은 Voronoi 값을 위해, 또 한 번은 u축 미분을 계산하기 위해, 나머지 한 번은 v축 미분을 계산하기 위해서다.

속한 셀을 인접 셀과 확실히 구분하기 위해, 셀의 ID를 x * N + y로 할당한다. 여기서 xy는 해당 칸의 격자 위치다. 다음으로 작은 변위 벡터 e = vec2(2.,0.)를 정의한다. 그러면 c = voronoi(st)는 Voronoi 노이즈의 값, ca = voronoi(st + e.xy / u_resolution)cb = voronoi(st + e.yx / u_resolution)는 각각 u축 미분 맵과 v축 미분 맵이다. 마지막으로, abs(c.y-ca.y) + abs(c.y-cb.y) > 0을 만족하는 프래그먼트가 경계에 속한다. 이 기법은 해석적으로 경계를 유도할 수 없을 때도 경계를 하이라이팅할 수 있게 한다. 다음 인터랙티브 이미지는 위의 가중 Voronoi 노이즈와 그 경계의 차이를 보여준다.

Image Left
Distance
Image Right
Boundary
// The MIT License
// Copyright © 2015 Inigo Quilez
// Edited by Sangil Lee

#ifdef GL_ES
precision mediump float;
#endif

#define N 5.

uniform vec2 u_resolution;

vec3 random3 (vec2 p)
{
  vec3 q = vec3( dot(p,vec2(127.1,311.7)), 
          dot(p,vec2(269.5,183.3)), 
          dot(p,vec2(419.2,371.9)) );
  return fract(sin(q)*43758.5453);
}

float my_dist (vec2 x, vec2 y, float w) {
  return 1./w * pow(length(x-y), 2.0);
}

vec2 voronoi (vec2 st) {
  // Tile the space
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);

  float m_dist = N;
  float m_id;

  for (int y= -2; y <= 2; y++)
  for (int x= -2; x <= 2; x++) {
    // Neighbor place in the grid
    vec2 neighbor = vec2(float(x),float(y));

    // Random position from current + neighbor place in the grid
    vec2 point = random3(i_st + neighbor).xy;

    // Distance to the point
    float weight = 0.2 + 0.8*random3(i_st + neighbor).z;
    float dist = my_dist(neighbor + point, f_st, weight);

    // Keep the closer distance
    if (dist < m_dist) {
      m_dist = dist;
      m_id = (i_st + neighbor).x * N + (i_st + neighbor).y;
    }
  }
  return vec2(m_dist, m_id);
}

void main() {
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  st.x *= u_resolution.x/u_resolution.y;

  vec2 e = vec2(2.,0.);
  vec2 c = voronoi( N*(st) );
  vec2 ca = voronoi( N*(st + e.xy/u_resolution) );
  vec2 cb = voronoi( N*(st + e.yx/u_resolution) );
  
  vec3 col = random3(vec2(c.y));
  col *= 1.-smoothstep(0., 0.001, abs(c.y-ca.y) + abs(c.y-cb.y));
  
  gl_FragColor = vec4( col, 1.0 );
}

Voronoise

random과 noise의 관계와 비슷하게, voronoise(Voronoi + noise)는 인접 시드 점들 사이의 보간된 값을 얻기 위해 smoothing 기법을 사용한다. 가장 가까운 시드 점에만 의존하는 대신, 거리에 반비례하는 가중치로 모든 근처 시드 점의 기여를 고려한다.

아래 예제에서 프로그램은 가장 가까운 시드 점과 그로부터의 거리만 찾는 것이 아니라, 모든 인접 시드 점의 속성과 그로부터의 거리도 고려한다. 거리는 커스텀 가중 함수에 의해 가중치로 변환된다. 작은 거리는 큰 가중치를 만든다: w = 1.0 - smoothstep(0.0,1.4142,dist).

smoothing 효과를 줄이려면 pow(w, k)에서 k를 큰 값으로 선택할 수 있다. 그러면 가장 가까운 시드 점의 가중치는 증가하고 나머지는 감소한다. 다음 인터랙티브 이미지는 Voronoi 다이어그램과 Voronoise의 차이를 보여준다.

Image Left
Voronoi
Image Right
Voronoise
// The MIT License
// Copyright © 2014 Inigo Quilez
// Edited by Sangil Lee

#ifdef GL_ES
precision mediump float;
#endif

#define N 5.

uniform vec2 u_resolution;
uniform vec2 u_mouse;
uniform float u_time;

vec3 random3( vec2 p )
{
  vec3 q = vec3( dot(p,vec2(127.1,311.7)), 
          dot(p,vec2(269.5,183.3)), 
          dot(p,vec2(419.2,371.9)) );
  return fract(sin(q)*43758.5453);
}

float voronoise( in vec2 p, float k )
{
  vec2 i = floor(p);
  vec2 f = fract(p);
  
  vec2 a = vec2(0.0);
  for( int y = -2; y <= 2; y++ )
  for( int x = -2; x <= 2; x++ )
  {
    vec2 neighbor = vec2(x, y);
    vec2 point = random3(i + neighbor).xy;
    float dist = length(neighbor + point - f);
    
    float w = 1.0 - smoothstep(0.0,1.4142,dist);
    w = pow(w, k);
    float color = random3(i + neighbor).z;
    a += vec2(color*w, w);
  }

  return a.x/a.y;
}

void main()
{
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  st.x *= u_resolution.x/u_resolution.y;
  
  float c = voronoise( N*st, 10. );
  gl_FragColor = vec4( c, c, c, 1. );
}

계층적 Voronoi

계층적 Voronoi 다이어그램은 프랙탈과 비슷하게 여러 단계의 Voronoi 패턴을 층층이 쌓아 만든다. 계층 구조를 구현하려면 계층 수준을 결정하는 추가 파라미터가 각 격자에 필요하다. 다음 예제는 계층적 Voronoi의 기본 원리를 이해하기 쉽게 설명한다.

아래 예제에는 경계가 빨간 선으로 그려진 3x3 격자가 있다. 그리고 새로운 함수 level()를 도입한다. level은 계층 레벨을 의미하며, 아래 그림에서는 중앙 격자의 레벨이 1, 나머지는 0이다. 레벨값이 0이면 단순 Voronoi 노이즈처럼 md = min(md, d)가 실행된다. 반면 더 높은 수준의 레벨에 대해서는 격자를 4등분하고 가장 가까운 거리를 찾는다.

// The MIT License
// Copyright © 2015 Inigo Quilez
// Edited by Sangil Lee

#ifdef GL_ES
precision mediump float;
#endif

uniform vec2 u_resolution;

#define N 3.

float level(vec2 p)
{
  if (p.x == 1. && p.y == 1.)
    return 1.;
  return 0.;
}

float voronoi(vec2 st)
{
  vec2 n = floor(st);
  
  float md = 1e10;
  for( int i=-1; i<=1; i++ )
  for( int j=-1; j<=1; j++ ) {
    vec2 g1 = n + vec2(float(i),float(j));
    vec3 rr = vec3(0.5, 0.5, level(g1));
    vec2 o = g1 + rr.xy;
    float d = length(o - st);
    float z = rr.z;
    
    if( z == 0. ) {
      md = min(md, d);
    } else {
      for( int k=0; k<=1; k++ )
      for( int l=0; l<=1; l++ ) {
        vec2 g2 = g1 + vec2(float(k),float(l))/2.0;
        rr = vec3(0.5, 0.5, level(g2));
        o = g2 + rr.xy/2.0;
        d = length(o - st);
        z = rr.z;
          
        md = min(md, d);
      }
    }       
  }
  return md;
}

void main()
{
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  st.x *= u_resolution.x/u_resolution.y;
  
  st *= N;
  
  float c = voronoi(st);
  vec3 color = vec3(c);
  
  // Draw cell center
  color += 1.-smoothstep(0., .05, c);

  // Draw grid
  color.r += step(.98, fract(st.x)) + step(.98, fract(st.y));
  
  gl_FragColor = vec4(color, 1.0);
}

무작위 레벨을 가진 계층적 Voronoi는 아래와 같다. 이는 일부 영역은 듬성듬성하게 또 다른 영역은 조밀하게 만든다.

// The MIT License
// Copyright © 2015 Inigo Quilez
// Edited by Sangil Lee

#ifdef GL_ES
precision mediump float;
#endif

uniform vec2 u_resolution;

#define LEVEL 2
#define N 5.

vec3 rand3(vec2 p)
{
  vec3 q = vec3( dot(p,vec2(127.1,311.7)), 
          dot(p,vec2(269.5,183.3)), 
          dot(p,vec2(419.2,371.9)) );
  return fract(sin(q)*43758.5453);
}

float voronoi(vec2 st)
{
  vec2 n = floor(st);
  
  float md = 1e10;
  for( int i=-1; i<=1; i++ )
  for( int j=-1; j<=1; j++ ) {
    vec2 g1 = n + vec2(float(i),float(j));
    vec3 rr = rand3( g1 );
    vec2 o = g1 + rr.xy;
    float d = length(o - st);
    float z = rr.z;
    
    #if LEVEL > 0
    if( z < 0.75 )
    #endif            
    {
      md = min(md, d);
    }
    #if LEVEL > 0
    else {
      for( int k=0; k<=1; k++ )
      for( int l=0; l<=1; l++ ) {
        vec2 g2 = g1 + vec2(float(k),float(l))/2.0;
        rr = rand3( g2 );
        o = g2 + rr.xy/2.0;
        d = length(o - st);
        z = rr.z;
        
        #if LEVEL > 1
        if( z < 0.75 )
        #endif                    
        {
          md = min(md, d);
        }
        #if LEVEL > 1
        else {
          for( int n=0; n<=1; n++ )
          for( int m=0; m<=1; m++ ) {
            vec2 g3 = g2 + vec2(float(m),float(n))/4.0;
            rr = rand3( g3 );
            o = g3 + rr.xy/4.0;
            d = length(o - st);
            z = rr.z;

            md = min(md, d);
          }
        }
        #endif
      }
    }
    #endif        
  }
  return md;
}

void main()
{
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  st.x *= u_resolution.x/u_resolution.y;
  
  float c = voronoi(N*st);
  gl_FragColor = vec4(c, c, c, 1.0);
}

계층 수준이 증가할수록 동일한 코드가 for 문 안에서 반복되는 것을 알 수 있다. 따라서 재귀 함수를 이용해 계층적 Voronoi를 짧은 줄로 구현할 수도 있지만, 안타깝게도 GLSL은 재귀 함수를 지원하지 않는다.

정리하며

위의 다양한 Voronoi 노이즈 버전을 이용해, 이 글과 같이 다양한 프랙탈 노이즈를 만들 수 있다. 다음은 몇 가지 예시다.

Voronoi distance Voronoise
Boundary Distance from boundary