메타볼 만들기 1부

세부 목차

pixel-sampling-step

메타볼에 관심을 가지게 된 것은 수 년 전에, 시네마4디 소프트웨어에서 metaball object를 발견했을 때였다. 꿀렁꿀렁한 유기적인 느낌에 매력을 느꼈던 기억이 있다. 그리고 얼마 전, 수업에서 한 학생이 메타볼과 비슷한 형태를 질문하면서 코딩을 통해서 구현해보고 싶은 생각이 들었다. 마칭 스퀘어 marching squares 알고리듬에 대한 기술적인 설명은 위키피디아에 잘 나와있다. 간단하게 설명하면, 같은 값을 가지는 점들을 윤곽선으로 이어주는 것으로 메타볼이나 지도의 등고선 등을 만들 때 효과적으로 쓰인다. 정사각형으로 이루어진 셀(cell)을 하나씩 건너가며 샘플링하는 것이 행진하는 것과 흡사하다고 하여 marching이라는 이름이 붙었다고 한다. 마칭 스퀘어가 3차원으로 표현되면 마칭 큐브가 된다. 원으로 이루어진 파티클만으로는 표현하기 어려운 물방울, 점성이 있는 액체 등이 합쳐진 모양의 표현에 관심을 가지고 프로세싱에서 메타볼을 만들어보기로 하였다.

이 곳에 마칭스퀘어를 구현하기 위한 모든 설명이 자세하게 나와있다. 하지만 소스코드가 공개되어있는 것은 아니기 때문에, 주어진 수식들을 컴퓨터 알고리듬으로 적용해보는 좋은 연습이 되었다. 여기서는 이를 바탕으로 한 나의 공부과정을 기록하고 정리해본다.

모든 픽셀을 샘플링하기

먼저, 마칭 스퀘어를 사용하지 않고 모든 픽셀을 샘플링해서 메타볼을 만들어볼 수 있다. 모든 원들 Mover에 대해서 모든 픽셀을 샘플링해야 하기 때문에 값비싼 과정이라고 할 수 있다. 원의 개수가 늘어날수록, 화면 해상도가 커질수록 속도에 큰 영향을 주게 된다. 쉐이더토이에 이런 방식으로 만들어진 예들이 많이 있다. 이 방식이 마칭스퀘어보다 구현하기에 간단해 보여서 일단 시도해보았다.

먼저 Mover 클래스를 만든다. 이 클래스는 기본적인 바운싱 볼을 구현하는 것으로 코드는 생략한다.

class Mover {
    // simple bouncing ball class
}

핵심 함수 만들기

다음으로, 원의 중심에서 특정 픽셀까지의 거리를 계산해본다.

// pseudo code
dist(centerX, centerY, pixelX, pixelY);

이제 모든 원들에 대해서 특정 픽셀에 대한 거리를 계산한다.

// pseudo code
for (each ball) {
    dist(centerX, centerY, pixelX, pixelY);
}

이제 아래와 같이 함수로 표현하고, 모든 원과 각 픽셀의 거리를 누적해서 저장한다. 거리가 멀어질수록 원의 영향력이 줄어드므로 거리의 역을 구한다. 그리고 크기가 큰 원의 영향력을 고려하기 위해 반지름의 제곱을 곱해준다. 또, 중요한 것은 각 픽셀과 원들의 거리값을 누적해야 원과 원 사이의 픽셀들도 영향을 받게 되어 부드러운 값의 변화를 만들어낸다는 것이다. 또, 피타고라스의 정리 \(a^2 + b^2 = c^2\) 을 사용하여 거리를 구하는데, 제곱근을 구하는 것은 실행속도에 큰 영향을 미치게 되므로 (모든 픽셀에 대해서 매 프레임마다 계산을 해야한다.) 상대적인 값의 차이만을 사용하기 위해서 dist() 함수를 사용하지 않고, 제곱된 값을 그대로 사용한다.

float metaballize(float _x, float _y) {
    float sum = 0.0;
    for (int i = 0; i < balls.size(); i++) {
        Mover b = balls.get(i);
        sum += sq(b.radius)  / ( sq(b.pos.x - _x) + sq(b.pos.y - _y) );
    }
    return sum;
}

이제 매 프레임마다 모든 원들에 대해서 모든 픽셀들의 거리를 구하고, 이 값을 픽셀값에 넣어주면 된다. 전체 코드는 아래와 같다.

ArrayList<Mover> balls = new ArrayList<Mover>();

void setup() {
  size(600, 200);

  for (int i = 0; i < 10; i++) {
    balls.add( new Mover( new PVector(random(width), random(height)) ) );
  }
}

void draw() {  
  loadPixels();
  for (int i = 0; i < width * height; i++) {
    int x = i % width;
    int y = floor(i / width);
    pixels[i] = color(metaballize(x, y));
  }
  updatePixels();

  for (int i = balls.size() - 1; i >= 0; i--) {
    Mover b = balls.get(i);
    b.update();
    b.display();
    if (b.life <= 0) balls.remove(i);
  }
}

float metaballize(float _x, float _y) {
  float sum = 0.0;
  for (int i = 0; i < balls.size(); i++) {
    Mover b = balls.get(i);
    sum += sq(b.radius)  / ( sq(b.pos.x - _x) + sq(b.pos.y - _y) );
  }
  return sum;
}

void mousePressed() {
  balls.add( new Mover( new PVector(mouseX, mouseY) ) );
}

pixel-sampling-tiny

메타볼의 크기 조절하기

위의 예제에서는 원의 크기가 너무 작아서, 즉, metaballize() 함수에서 반환된 값의 범위가 미미한 까닭에 시각적으로 메타볼들의 상호작용하는 모습을 찾아보기 힘들다. 이를 조절해주기 위해서 blobbyness 변수를 하나 만든다.

void draw() {  
    float blobbyness = map(mouseX, 0, width, 0, 500);
  loadPixels();
  for (int i = 0; i < width * height; i++) {
    int x = i % width;
    int y = floor(i / width);
    pixels[i] = color(blobbyness * metaballize(x, y));
  }
  updatePixels();

  // 이하 생략
}

pixel-sampling-glow

분명한 윤곽선 만들기

기본적으로 위와 같이 메타볼은 빛이 나는 형태를 띈다. 모든 픽셀들의 값이 원에서 멀어지면서 부드럽게 변화하기 때문이다. 윤곽선이 확실한 메타볼을 얻기 위해서 step() 함수를 도입한다. 이 함수는 threshold 이하의 값은 0으로 잘라내고 그 이상은 1을 돌려주어 인풋에 상관없이 노멀라이즈하는 역할을 한다.

float step(float threshold, float val) {
  if (val >= threshold) return 1;
  else return 0;
}

픽셀값을 지정하는 부분을 아래와 같이 수정한다.

pixels[i] = color( 255 - 255 * step(blobbyness*metaballize(x, y), 255) );
Your browser does not support canvas.

위의 캔버스에 마우스를 클릭하면, 메타볼을 추가할 수 있다. 다음 기회에는 모든 픽셀을 샘플링하지 않고 16가지 조합으로 흡사한 결과를 만들어낼 수 있는 마칭 스퀘어 알고리듬에 대해서 알아보겠다.

탐구 가능성