GLSL에서 볼륨 렌더링 구현하기

0

이전 글에서는 레이 마칭(ray marching) 기법으로 불투명한 물체를 렌더링하는 방법을 설명했다. 그러나 ray가 물체의 경계에서 전진을 멈추기 때문에, 반투명하거나 투명한 물체는 제대로 렌더링할 수 없었다. 이 글에서는 기본적인 광학 물리를 도입하여 유리와 같은 물체를 렌더링하기 위한 레이 마칭 기법을 깊게 다룬다. 이 기법은 볼륨 렌더링(volume rendering) 또는 participating media rendering이라고도 한다.

반투명 물체

일반적인 물체의 색상을 표현하기 위해 반투명 색상을 계산하는 것에서부터 시작해보자. 이를 구하고 나면, 불투명도(opacity) 파라미터를 조정하기만 하면 투명하거나 불투명한 재질을 간단히 제어할 수 있다.

색상과 불투명도

먼저 물체의 색상과 불투명도(NeRF에서의 밀도 또는 Beer-Lambert 법칙에서의 광학 밀도)를 정의한다. get_color 함수는 물체 내부 한 점의 RGB 색상을 출력하고, get_density 함수는 양수 값을 반환한다. 밀도가 0이면 투명함을, 밀도가 높으면 불투명함을 의미한다. 이 정의는 밀도가 단위 거리당 흡수되는 빛의 양을 결정하는 Beer-Lambert 법칙을 따른다. 이는 안개, 연기, 유리 같은 매질을 통과하는 빛의 감쇠를 사실적으로 시뮬레이션할 수 있게 한다.

vec3 get_color(in vec3 p) {
  if (get_sphere_dist(p) < 0.0) return vec3(0.8, 0.9, 1.0);
  if (get_plane_dist(p) < 0.0) return texture(u_texture, fract(0.1*p.xz)).rgb;
  return vec3(1.2, 1.3, 1.5); // atmosphere color
}

float get_density(vec3 p) {
  if (get_sphere_dist(p) < 0.0) return 0.15;
  if (get_plane_dist(p) < 0.0) return 10.0;
  return 0.;
}

get_sphere_dist()get_plane_dist() 함수는 각각 구와 평면의 부호 있는 거리(SDF)를 계산한다.

float get_plane_dist(in vec3 p) {
  return p.y + 1.2;
}

float get_sphere_dist(in vec3 p) {
  vec4 s = vec4(0, 0, 0, 1);
  return length(p - s.xyz) - s.w;
}

Ray 샘플링

다음으로 ray를 따라 점들을 샘플링한다. 한 점이 표면 바깥에 있으면 SDF 값만큼 전진한다. 반대로 한 점이 표면에 가깝거나 내부에 있으면, 즉 SDF(p) <= SAMPLE_DIST이면, 점은 고정 스텝 SAMPLE_DIST만큼 전진한다. 이 값은 성능과 시각적 완성도 사이의 트레이드 오프를 조절하며, 휴리스틱하게 결정하면 된다. 작은 스텝 크기를 사용하면 매끄러운 렌더링과 정확한 셰이딩을 표현하지만 계산 시간이 늘어난다.

vec3 p = ro + rd * d;
float ds = SDF(p);

if (ds > SAMPLE_DIST)
  d += ds;
else
  d += SAMPLE_DIST;

그런 다음 Beer-Lambert 법칙에 따라, 밀도를 가중치 density * SAMPLE_DIST로 하여 샘플들의 색상을 더한다. ray는 물체 내부에서 흡수 또는 산란으로 인해 에너지를 잃는다. 따라서 ray가 물체를 통과할 때 transmittance 파라미터에 exp(-density * SAMPLE_DIST)를 곱하여 ray의 잔여 에너지를 업데이트한다. 즉, 미소 볼륨이 물체 표면에서 멀수록 색상을 표현하는 능력이 약해진다. 예를 들어 한 미소 볼륨이 표면 아래 2*SAMPLE_DIST에 있다면, 그 점에서 빛의 투과율(transmittance)은 표면에서의 투과율에 비해 exp(-density*SAMPLE_DIST)*exp(-density*SAMPLE_DIST)=exp(-density*2*SAMPLE_DIST)만큼 감소한다. 특정 ray와 N개의 샘플이 주어지면, 위 계산은 다음과 같이 표현할 수 있다.

\[\begin{align} C(\bf r) &= \sum_{i=1}^N {T_i (1-\exp(-\sigma_i \delta_i)) {\bf c}_i}, \\\nonumber T_i &= \exp(-\sum_{j=1}^{i-1}{\sigma_j \delta_i}), \end{align}\]

여기서 \({\bf c}_i\)는 \(i\)번째 샘플의 색상, \(\sigma_i\)는 그 밀도, \(\delta_i\)는 인접 샘플 사이의 거리, \(T_i\)는 \(i\)번째 샘플에 도달한 빛의 투과율을 나타낸다.

vec3 compute_color(in vec3 ro, in vec3 rd) {
  vec3 c = vec3(0.);
  float transmittance = 1.;
  float d = 0.;
  for (int i = 0; i < MAX_STEPS; ++i) {
    vec3 p = ro + rd * d;
    float ds = SDF(p);
    vec3 color = get_color(p);
    float density = get_density(p);
    
    if (ds > SAMPLE_DIST)
      d += ds;
    else
      d += SAMPLE_DIST;

    c += color * (1. - exp(-density * SAMPLE_DIST)) * transmittance;
    transmittance *= exp(-density * SAMPLE_DIST);
    
    if (transmittance < 0.01 || d > MAX_DIST) break;
  }
  return c;
}

빛의 감쇠

위 계산에서, 더 깊은 점의 색상은 최종 렌더링에 미치는 영향이 작다. 그 점에서 산란된 빛이 카메라로 도달하기위해 물체 내부를 통과해나오면서 에너지를 잃기 때문이다. 한편 그 점에 도달하는 빛의 세기도 고려해야 한다. 점이 표면 아래에 있으면, 그 점의 색상뿐 아니라 빛의 세기도 감소한다. 점 \(p\)에서 감쇠된 빛의 세기는 다음과 같이 계산된다.

float compute_intensity(in vec3 p) {
  vec3 l = get_light(); // light position
  vec3 ld = normalize(l - p); // light direction

  vec3 q = p;
  float intensity = 15.; // initial intensity
  for (int i = 0; i < MAX_STEPS; ++i) {
    float ds = SDF(q);
    float density = get_optical_density(q);

    float step_size;
    if (ds > 0.0)
      step_size = ds;
    else
      step_size = SAMPLE_DIST;

    q += step_size * ld;
    intensity *= exp(-density * step_size);

    if (dot(l - p, l - q) < 0.0 || intensity < 0.01)
      break;
  }
  return intensity;
}

초기 빛의 세기는 15.0으로 수동 설정한다. 미소볼륨에 도달하는 빛의 세기를 계산하기위해, ray를 역으로 점에서 광원까지 전진해본다. 그리고 광원을 지나치면 전진을 멈춘다. 위 함수를 적용한 후, compute_color()는 다음과 같이 수정된다.

vec3 compute_color(in vec3 ro, in vec3 rd) {
  vec3 c = vec3(0.);
  float d = 0.;
  float transmittance = 1.0; // RENAMED
  for (int i = 0; i < MAX_STEPS; ++i) {
    vec3 p = ro + rd * d;
    float ds = SDF(p);
    vec3 color = get_color(p);
    float density = get_density(p);
    float intensity = compute_intensity(p); // CHANGED
    
    if (ds > SAMPLE_DIST)
      d += ds;
    else
      d += SAMPLE_DIST;

    c += color * (1. - exp(-density * SAMPLE_DIST)) * intensity * transmittance;
    transmittance *= exp(-density * SAMPLE_DIST);
    
    if (transmittance < 0.01 || d > MAX_DIST) break;
  }
  return c;
}
감쇠없는 조명 감쇠된 조명

지터링

물체 내부에서 ray는 고정 스텝 크기 SAMPLE_DIST로 이동한다. 그러나 이 스텝 크기가 물체의 규모에 비해 충분히 작지 않으면 색상 밴딩(color banding) 아티팩트가 나타난다. 이 부작용을 완화하기 위해, ray의 스텝 크기를 고정하지 않고 무작위화한다.

if (ds > SAMPLE_DIST)
  step_size = ds;
else
  step_size = SAMPLE_DIST * (hash13(q) + 0.5); // Instead of SAMPLE_DIST

굴절

빛이 밀도가 다른 물질 사이의 경계를 만나면 그 방향이 꺾이는데, 이를 굴절(refraction)이라 한다. 이 꺾임은 입사각과 굴절각의 관계를 정하는 스넬의 법칙(Snell’s law)에 의해 계산된다.

\(n_1 \sin(\theta_1) = n_2 \sin(\theta_2),\) 여기서 \(n_1\)과 \(n_2\)는 각 물질의 굴절률로, 진공에서의 빛의 속도 대비 물질 속에서의 빛의 속도의 비율 계수다.

GLSL에서는 내장 함수 refract()를 활용할 수 있다. 또는 벡터 연산으로 함수를 구현할 수도 있다. IN은 각각 입사 단위 벡터와 법선 단위 벡터를 나타내고, eta는 두 물질의 굴절률 비 \((n_2/n_1)\)를 나타낸다.

vec3 refract(vec3 I, vec3 N, float eta) {
  k = 1.0 - eta * eta * (1.0 - dot(N, I) * dot(N, I));
  if (k < 0.0)
    T = NaN; // total internal reflection
  else
    T = eta * I - (eta * dot(N, I) + sqrt(k)) * N;
  return T;
}

위 공식은 다음과 같이 유도할 수 있다.

입사 단위 벡터 \(\hat{I}\), 법선 단위 벡터 \(\hat{N}\), 굴절률 비 \(\eta\)가 주어지면, 굴절 단위 벡터 \(\hat{T}\)는 두 벡터 \(\sin(\theta_t)\hat{M}\)과 \(-\cos(\theta_t)\hat{N}\)으로 분해될 수 있다. 여기서 \(\hat{M}\)은 \(\hat{N}\)과, \(\hat{I}\)와 \(\hat{N}\)의 외적 모두에 수직인 단위 벡터다. \(\hat{M} = (\hat{I} + \cos(\theta_i) \hat{N}) / \sin(\theta_i)\)이고, 마찬가지로

\[\begin{align} \hat{T} &= \sin(\theta_t)\hat{M} - \cos(\theta_t)\hat{N} \nonumber\\ &= \eta (\hat{I} + \cos(\theta_i) \hat{N}) - \cos(\theta_t) \hat{N}, \\ \end{align}\]

이며, 여기서 \(\eta = {\sin(\theta_t)}/{\sin(\theta_i)} = n_i/n_r\)이다. \(\cos(\theta_i)\)를 \((-\hat{I} \cdot \hat{N})\)로, \(\cos(\theta_t)\)를 \(\sqrt{1-\sin(\theta_t)^2}\)로 치환하면,

\[\begin{align} \hat{T} &= \eta (\hat{I} - (\hat{I} \cdot \hat{N}) \hat{N}) - \cos(\theta_t) \hat{N} \nonumber \\ &= \eta (\hat{I} - (\hat{I} \cdot \hat{N}) \hat{N}) - \sqrt{1-\sin(\theta_t)^2} \hat{N} \nonumber \\ &= \eta (\hat{I} - (\hat{I} \cdot \hat{N}) \hat{N}) - \sqrt{1-\eta^2 \sin(\theta_i)^2} \hat{N} \nonumber \\ &= \eta (\hat{I} - (\hat{I} \cdot \hat{N}) \hat{N}) - \sqrt{1-\eta^2 (1-(\hat{I}\cdot \hat{N})^2)} \hat{N} \\ &= \eta \hat{I} - \left( \eta (\hat{I} \cdot \hat{N}) + \sqrt{1-\eta^2 (1-(\hat{I}\cdot \hat{N})^2)} \right) \hat{N}. \\ \end{align}\]

이다.

내부 반사

굴절과 동시에, 일부 빛은 표면에서 반사된다. 이 현상은 입사각이 커질수록 특히 두드러지며, 특정 입사각 이상에서는 전반사(total internal reflection)가 일어난다. 이 임계각은 굴절률 비 \(\eta\)에 의해 결정된다.

\(\eta = 1.3\) (물) \(\eta = 1.5\) (유리)

반사

흑체가 아닌 물체에는 난반사(diffusion)와 정반사(specular reflection)가 있다. 이 반사들은 표면의 특성에 따라 빛의 색을 바꾸어 렌더링 결과에 큰 영향을 준다. 이 장에서는 정반사를 먼저 이론적으로 설명하고, 난반사는 다음 장에서 설명한다.

정반사

굴절의 원리와 달리, 입사 단위 벡터 \(\hat{I}\)와 법선 단위 벡터 \(\hat{N}\)으로부터 반사 단위 벡터 \(\hat{R}\)을 계산하는 것은 간단하다. 반사의 원리에 따라, 입사 벡터에서 법선 벡터에 평행한 성분의 방향만 뒤집으면 된다. 이는 공식 \(\hat{R} = \hat{I} - 2(\hat{I} \cdot \hat{N}) \hat{N}\)로 유도할 수 있다.

vec3 reflect(vec3 I, vec3 N) {
  R = I - 2.0 * dot(I, N) * N;
  return R;
}

프레넬 효과

물질 경계 표면의 반사율은 입사각에 따라 달라진다. 각이 90도에 가까워질수록 반사율이 증가하여 1에 이른다. 이를 프레넬 효과라고 하며, 이 글에서는 프레넬 반사 계수를 계산하기 위해 슐릭 근사(Schlick’s approximation)를 사용했다.

float compute_fresnel_reflectance(float eta, float u, float f0, float f90) {
  if (f0 == 0.0) return 0.0; // non-reflective

  float r0 = (eta - 1.) / (eta + 1.);
  r0 = r0 * r0;

  if (eta > 1.0) { // strike the surface of less dense medium
    float sin_refract = eta * sqrt(1. - u*u);
    if (sin_refract >= 1.0) return 1.0; // total internal reflection
    u = sqrt(1.0 - sin_refract*sin_refract);
  }

  float r = mix(r0, 1., pow(1. - u, 5.0));
  return mix(f0, f90, r);
}
프레넬 효과 없음 프레넬 효과 적용

난반사

램버시안 난반사 무작위화된 법선 벡터

현실 세계에서 대부분의 물질은 거친 표면을 가지고 있기 때문에, 반사되거나 굴절된 빛은 퍼지는 형태가 된다. 이는 국소 표면의 법선 벡터가 일관되지 않고 무작위적이기 때문이다. 이 무작위성을 시뮬레이션하기 위해, 표면의 거칠기에 의해 결정되는 분산을 갖는 가우시안 분포를 따르는 무작위 법선 벡터를 이용해 반사 및 굴절 벡터를 계산한다. 또한 반사와 굴절로 분할되는 여러 ray를 처리하기 위해, 각 ray의 위치, 방향, 색상, 투과율, 이동 거리 등의 정보를 저장하는 큐(queue) 구조를 사용하여, 개별적으로 ray들을 처리할 수 있도록 한다.

struct Ray {
  vec3 origin; // ray origin
  vec3 direction; // ray direction
  vec3 color; // ray color
  float transmittance; // transmittance
  float dist; // distance travelled before
};
Ray rays[NUM_RAYS];
int n_rays = 0; // number of rays

ray가 정반사나 난반사, 또는 정굴절이나 난굴절될 때마다, 분할 지점에서 출발하는 ray가 큐에 추가된다. 마지막으로 결과를 렌더링하는 것은 큐에 있는 ray들의 색상을 더하는 것이다.

for (int i = 0; i < n_rays; ++i) {
  Ray ray = rays[i];
  vec3 p = ray.origin;
  vec3 v = ray.direction;
  vec3 c = ray.color;
  float t = ray.transmittance;
  float d = ray.dist;

  if (d > MAX_DIST) continue; // skip if distance exceeds max distance
  if (t < MIN_TRNASMITTANCE) continue; // skip if transmittance is too low

  color += compute_color_along_ray(p, v, c, t, d);
}
return color;

난반사 조명을 흉내 내기 위해서는, 램버시안 반사 법칙(Lambertian reflection law)을 사용했다. 이 법칙은 반사된 빛의 세기가 표면 법선과 빛 방향 사이의 max(0., dot(normalize(l - q), n)), 그리고 표면 법선과 무작위적 반사 방향 사이의 dot(v_diffuse, n)에 비례한다고 말한다.

rand_vec = normalize(hash33(q) - 0.5); // random unit vector
vec3 v_diffuse = normalize(n + rand_vec); // random unit vector on the hemisphere
v_diffuse *= dot(v_diffuse, n); // cosine weighted
float diffuse_intensity = 0.2 * max(0., dot(normalize(l - q), n));
rays[n_rays++] = Ray(q + SURF_DIST * n, v_diffuse, l_color * color, diffuse_intensity * transmittance * (1. - get_specular(q)), dist_before + dist);

난굴절

위 방법과 유사하게, 무작위 벡터를 법선 벡터에 더하여 랜덤 법선 벡터를 생성한다. 그런 다음 굴절 벡터를 계산하고 표면 점에서 출발하는 새로운 ray를 큐에 추가한다.

// diffuse refraction
rand_vec = normalize(hash33(q) - 0.5); // random unit vector
vec3 v_refract = refract(v, normalize(n + get_roughness(p, q) * rand_vec), ri / ri_new);
rays[n_rays++] = Ray(q, v_refract, l_color, (1. - reflectance) * transmittance, dist_before + dist);

난반사

이 역시 마찬가지로, 무작위 법선 벡터를 생성하고, 반사된 ray를 큐에 추가한다.

vec3 rand_vec = normalize(hash33(q) - 0.5); // random unit vector
vec3 v_reflect = reflect(v, normalize(n + get_roughness(p, q) * rand_vec)); // random normal vector
rays[n_rays++] = Ray(q + SURF_DIST * v_reflect, v_reflect, l_color * color, reflectance * transmittance * get_specular(q), dist_before + dist);
난반사 없음 난반사 적용

위 결과는 Shadertoy에서 볼 수 있으며, 재질 속성도 조절할 수 있다. 다음은 몇 가지 예시다.

불투명 거울 같은

참고 자료