레이 트레이싱 (7)

반사와 굴절

2021년 02월 07일

우리 눈에 보이는 물체의 색은 대부분 물체 본연의 색이지만 종종 어떤 물체는 반사나 굴절을 이용해 다른 물체의 색을 보여주는 경우도 있습니다. 이번 포스트에서는 레이 트레이싱의 꽃이자, 보다 현실감 있는 렌더를 가능하게 하는 반사와 굴절을 알아보겠습니다.





반사

손거울을 요리조리 돌려보면 거울의 각도에 따라 비치는 물체들이 달라집니다. 아래 그림은 거울과 거울을 바라보는 카메라, 물체의 위치의 관계를 보여주고 있습니다.

그림처럼 거울 면은 거울 본연의 색 뿐만 아니라 반사된 물체의 색도 같이 비춥니다. 거울 본연의 색은 Phong reflection 을 통해 알 수 있지만 반사된 물체의 색은 다른 방법을 필요로 합니다. 그림을 자세히 보면 카메라에서 출발한 광선은 거울 면의 한 지점에 부딪힌 뒤 방향을 바꿉니다. 이 때 새로운 방향을 갖는 광선을 반사 광선이라 합니다. 반사 광선은 자신의 경로에 있는 다른 물체에 부딪힙니다. 부딪힌 물체의 색은 곧 거울 면의 색이 됩니다.

위 과정은 아래와 같이 정리할 수 있습니다.

  1. 거울 면에서 출발하는 반사 광선을 새로운 광선 객체로 정하고 적절한 방향을 설정한 뒤
  2. 다른 물체와 부딪힌다면
  3. 그 물체의 Phong reflection 에 의한 쉐이딩을 구해 거울 색에 반영합니다.

광선 객체는 그 요소로서 출발점 ee 와 방향 벡터 dd 를 가집니다. 반사 광선의 출발점은 거울의 부딪힌 지점입니다. 방향 벡터는 다음의 원리를 이용해 구할 수 있습니다.

위 그림은 초등학교 과학 시간에 배우는 입사각과 반사각의 원리를 보여주고 있습니다. 위 그림에 몇가지 벡터들을 그려보겠습니다.

물체 표면에서의 법선 벡터이자 단위 벡터인 nn 과 광선의 방향 벡터 dd 를 이용해 반사 광선의 방향 벡터를 구하면 다음과 같습니다.

dreflection=dorigin+2cosθnd_{reflection} = d_{origin} + 2 cos \theta \cdot n

cosθcos \thetadorigind_{origin}nn 의 내적으로 표현할 수 있습니다.

n(dorigin)=ndorigincosθ=cosθn \cdot (-d_{origin}) = - \parallel n \parallel \parallel d_{origin} \parallel cos \theta = cos \theta

다시 반사 광선의 방향 벡터를 정리하면 아래와 같습니다.

dreflection=dorigin2(ndorigin)nd_{reflection} = d_{origin} - 2 (n \cdot d_{origin}) \cdot n

한편, 반사 광선의 출발점은 부동소수점 오차로 인해 물체 표면보다 아래에 위치할 수도 있습니다. 아래의 왼쪽 확대 그림이 이를 잘 보여줍니다.

만약 출발지점이 물체 표면보다 아래에 있다면 광선은 출발지점의 물체와 부딪힙니다. 따라서 오른쪽 확대 그림과 같이 출발점을 법선 벡터 방향으로 조금 이동시켜 문제를 해결합니다. 이 때 이동거리는 0.0001 과 같이 아주 작은 값으로 설정합니다.

반사 광선을 구성하는 요소 eedd 를 찾았으니 이를 이용해 반사된 물체의 색을 구해 보겠습니다. 반사된 물체를 위한 쉐이딩은 다시 구현할 필요없이 기존의 Phong reflection 코드를 그대로 활용합니다. Phong reflection 이 반영된 레이 트레이싱 의사코드는 다음과 같았습니다.

def render_ray_tracing_image():
  for y in range(0, height):
    for x in range(0, width):
      ray = # x, y 를 이용한 광선 객체
      grid[y][x] = ray_trace(ray)

def ray_trace(ray):
  obj = # 부딪힌 물체들 중 가장 가까운 물체
  if obj is None:
    return background_color

  shade = # ambient 쉐이딩
  for light in lights:
    shade += # diffuse 쉐이딩
    shade += # specular 쉐이딩
  return shade

위 코드의 ray_trace 함수는 기존의 카메라에서 출발한 광선 객체를 인자로 받아 광선이 부딪힌 물체의 색을 얻는 역할을 했습니다. 이 함수에 반사 광선을 인자로서 전달하면 반사된 물체의 색을 얻습니다. 이것을 물체의 본래 색에 더하면 반사가 적용된 결과를 얻습니다. 아래는 반사를 적용한 물체의 색을 식으로 표현한 것입니다.

shade=(1reflectivity)shadeorigin+reflectivityshadereflectshade = (1 - reflectivity) \cdot shade_{origin} + reflectivity \cdot shade_{reflect}

물체의 반사 정도 reflectivityreflectivity 는 0 ~ 1 사이 값이고 반사 정도가 클수록 1에 가깝습니다. 예를 들어 거울은 본연의 색 없이 반사된 물체만을 비추므로 1을, 당구공과 같이 반사성을 띄지만 본연의 색도 지닌 경우는 그보다 작은 값으로 설정합니다.

아래는 반사를 적용한 의사코드입니다.

def render_ray_tracing_image():
  for y in range(0, height):
    for x in range(0, width):
      ray = # x, y 를 이용한 광선 객체
      grid[y][x] = ray_trace(ray)

"""
intersection  : 광선 객체 ray 와 물체가 부딪힌 지점
ray           : 광선 객체
n             : intersection 에서의 법선 벡터
"""
def get_reflect_ray(intersection, ray, n):
  reflect_ray = # 초기화한 광선 객체
  reflect_ray.e = intersection
  reflect_ray.d = ray.d - 2 (n * ray.d) * n
  return reflect_ray

def ray_trace(ray):
  obj = # 부딪힌 물체들 중 가장 가까운 물체
  if obj is None:
    return background_color

  shade = # ambient 쉐이딩
  for light in lights:
    shade += # diffuse 쉐이딩
    shade += # specular 쉐이딩

  n = # 물체 표면의 법선 벡터

  reflect_ray = get_reflect_ray(intersection, ray, n)
  reflect_shade = ray_trace(reflect_ray)

  return (1 - obj.reflectivity) * shade
    + obj.reflectivity * reflect_shade



반사는 연속적으로 일어날 수 있습니다. 엘리베이터처럼 양면에 거울이 있는 공간에서 거울을 바라보면 끝도 없는 반사를 볼 수 있습니다.

레이 트레이싱에서도 엘리베이터와 같은 무한한 반사를 만들어낼 수 있습니다. 단 반복의 제한을 두어 프로그램이 무한 루프에 빠지지 않도록 해줍니다. 아래 그림은 반사 횟수에 제한을 두었을 때 반사 광선이 물체에 부딪히는 모습을 보여줍니다.

반사 횟수 조절을 적용해 기존의 의사 코드가 무한 루프에 빠지지 않도록 수정해 보겠습니다.

def render_ray_tracing_image():
  for y in range(0, height):
    for x in range(0, width):
      ray = # x, y 를 이용한 광선 객체
      grid[y][x] = ray_trace(ray, 0)

"""
intersection  : 광선 객체 ray 와 물체가 부딪힌 지점
ray           : 광선 객체
n             : intersection 에서의 법선 벡터
"""
def get_reflect_ray(intersection, ray, n):
  reflect_ray = # 초기화한 광선 객체
  reflect_ray.e = intersection
  reflect_ray.d = ray.d - 2 (n * ray.d) * n
  return reflect_ray

def ray_trace(ray, depth):
  obj = # 부딪힌 물체들 중 가장 가까운 물체
  if obj is None:
    return background_color

  shade = # ambient 쉐이딩
  for light in lights:
    shade += # diffuse 쉐이딩
    shade += # specular 쉐이딩

  n = # 물체 표면의 법선 벡터

  if depth < 5:
    reflect_ray = get_reflect_ray(intersection, ray, n)
    reflect_shade = ray_trace(reflect_ray, depth + 1)

    shade = (1 - obj.reflectivity) * shade
      + obj.reflectivity * reflect_shade
  return shade

의사 코드를 반영한 코드를 바탕으로 반사 성질을 가진 물체를 공간에 넣고 이미지를 렌더해 보겠습니다.





굴절

굴절은 빛이 한 물질에서 다른 물질로 통과할 때 진행 방향이 달라지는 현상입니다. 초등학교 과학 시간에 물 속에 담긴 막대를 통해 굴절을 처음 배웠던 기억이 새록새록합니다.

굴절 역시 반사처럼 벡터를 이용해 수식으로 표현할 수 있습니다. 반사가 삼각함수와 내적만을 이용해 간단히 구할 수 있었다면 굴절은 두 물질에 대한 상관관계를 표현하는 특별한 법칙을 필요로 합니다. 굴절률(reflective index)과 굴절 각도에 대한 공식인 스넬의 법칙은 두 물질의 굴절률 η1\eta_1, η2\eta_2 와 법선에 대한 광선과 굴절된 광선의 각도 θ1\theta_1, θ2\theta_2 에 대해 아래와 같이 정리됩니다.

sinθ1sinθ2=η2η1\frac{sin\theta_1}{sin\theta_2} = \frac{\eta_2}{\eta_1}

굴절률이란 빛이 물질을 통과할 때 빛의 속도가 감소하는 비율입니다. 진공에서의 굴절률을 1로 하며, 공기 중에서 1.000293, 물에서는 1.333 와 같이 상수값을 갖습니다. 아래 그림은 η1\eta_1 의 굴절률을 가진 물질과 η2\eta_2 의 굴절률을 가진 물질에 대해 빛이 통과할 때 굴절각 θ1\theta_1, θ2\theta_2 를 보여주고 있습니다.

굴절되기 전의 방향 벡터 dorigind_{origin} 과 굴절된 뒤의 방향 벡터 drefractiond_{refraction}, 그리고 법선 벡터 nn 은 단위 벡터입니다. 굴절된 결과 벡터 drefractiond_{refraction} 을 구하기 위해 drefractiond_{refraction}를 두 벡터의 합으로 표현해 보겠습니다.

drefraction=A+Bd_{refraction} = A + B 라 할 때 벡터 AA, BB 는 각 θ2\theta_2 와 삼각함수를 이용해 다음과 같이 쓸 수 있습니다.

drefraction=A+BA=sinθ2MB=cosθ2(n)\begin{aligned} d_{refraction} &= A + B \\ A &= sin \theta_2 \cdot M \\ B &= cos \theta_2 \cdot (-n) \end{aligned}

MMAA 의 계산을 위한 중간 과정에 필요한 단위 벡터로 아래와 같이 어렵지 않게 정리할 수 있습니다.

C=dorigin+cosθ1nM=Csinθ1\begin{aligned} C &= d_{origin} + cos \theta_1 \cdot n \\ M &= \frac{C}{sin \theta_1} \end{aligned}

MM 을 이용해 AA 를 다시 정리해보겠습니다.

A=sinθ2M=sinθ2sinθ1C=sinθ2sinθ1(dorigin+cosθ1n)=η1η2(dorigin+cosθ1n) (스넬의법칙)\begin{aligned} A &= sin \theta_2 \cdot M \\ &= \frac{sin \theta_2}{sin \theta_1} \cdot C \\ &= \frac{sin \theta_2}{sin \theta_1} \cdot (d_{origin} + cos \theta_1 \cdot n) \\ &= \frac{\eta_1}{\eta_2} \cdot (d_{origin} + cos \theta_1 \cdot n)\text{ }(\because 스넬의법칙)\\ \end{aligned}

위 식의 마지막 미지수인 cosθ1cos \theta_1doriginn-d_{origin} \cdot n 이므로 아래와 같이 정리할 수 있습니다.

A=η1η2(dorigin(doriginn)n)A = \frac{\eta_1}{\eta_2} \cdot (d_{origin} - (d_{origin} \cdot n) \cdot n)

이제 drefractiond_{refraction} 을 이루는 두번째 벡터 BB 를 살펴보겠습니다.

B=cosθ2(n)B = cos \theta_2 \cdot (-n)

BB 의 미지수 cosθ2cos \theta_2 는 삼각함수 정리 sin2θ+cos2θ=1sin^2\theta + cos^2\theta = 1 를 이용해 구할 수 있습니다.

sin2θ2+cos2θ2=1cosθ2=1sin2θ2=1(η1η2)2sin2θ1sinθ1=1cos2θ1=1(doriginn)2\begin{aligned} & sin^2\theta_2 + cos^2\theta_2 = 1 \\ \\ cos\theta_2 &= \sqrt{1 - sin^2 \theta_2} \\ &= \sqrt{1 - (\frac{\eta_1}{\eta_2})^2 \cdot sin^2 \theta_1} \\ \\ sin \theta_1 &= \sqrt{1 - cos^2 \theta_1} \\ &= \sqrt{1 - (d_{origin} \cdot n)^2} \end{aligned}

위 식의 sinθ1sin \theta_1cosθ2cos \theta_2 식에 대입하면,

cosθ2=1(η1η2)2sin2θ1=1(η1η2)2(1(doriginn)2)B=cosθ2n\begin{aligned} cos\theta_2 &= \sqrt{1 - (\frac{\eta_1}{\eta_2})^2 \cdot sin^2 \theta_1} \\ &= \sqrt{1 - (\frac{\eta_1}{\eta_2})^2 \cdot (1 - (d_{origin} \cdot n)^2)} \\ B &= -cos \theta_2 \cdot n \end{aligned}

삼각함수 정리와 스넬의 법칙을 이용하면서 식의 전개 과정이 조금 지저분해졌지만 손으로 직접 정리하시다 보면 눈에 금방 익으실 것 같습니다.


굴절 광선의 출발점은 반사 광선과 마찬가지로 광선과 물체가 부딪힌 지점입니다. 한가지 반사의 경우와 다른 것은 부동소수점으로 인한 오차 문제를 해결하기 위해 법선 벡터의 반대 방향으로 출발점을 옮긴다는 것입니다. 아래 그림은 반사와 굴절에 대해 어떻게 위치 조정이 이루어지고 있는지를 보여주고 있습니다.

굴절을 물체의 색에 적용하기 위해서는 빛이 물체를 얼마나 통과하는지를 나타내는 투명도 (transparency) 를 이용합니다. 광선 객체가 물체에 부딪혔을 때 그 물체의 투명도를 반영한 물체의 색은 아래와 같습니다.

shade=(1transparency)shadeorigin+transparencyshaderefractshade = (1 - transparency) \cdot shade_{origin} + transparency \cdot shade_{refract}

반사와 굴절을 모두 적용한다면 아래와 같이 다시 쓸 수 있습니다.

shade=(1reflectivitytransparency)shadeorigin+reflectivityshadereflect+transparencyshaderefract\begin{aligned} shade &= (1 - reflectivity - transparency) \cdot shade_{origin}\\ & + reflectivity \cdot shade_{reflect} \\ & + transparency \cdot shade_{refract} \end{aligned}

마지막으로 굴절을 의사코드에 반영해 보겠습니다.

def render_ray_tracing_image():
  for y in range(0, height):
    for x in range(0, width):
      ray = # x, y 를 이용한 광선 객체

      # ray_trace 의 세번째 인자로서 공기 중의 굴절률의 근삿값인 1을 전달
      grid[y][x] = ray_trace(ray, 0, 1)

"""
intersection  : 광선 객체 ray 와 물체가 부딪힌 지점
ray           : 광선 객체
n             : intersection 에서의 법선 벡터
"""
def get_reflect_ray(intersection, ray, n):
  reflect_ray = # 초기화한 광선 객체
  reflect_ray.e = intersection
  reflect_ray.d = ray.d - 2 (n * ray.d) * n
  return reflect_ray

"""
intersection  : 광선 객체 ray 와 물체가 부딪힌 지점
ray           : 광선 객체
n             : intersection 에서의 법선 벡터
n1            : 현재 빛이 통과하는 물체의 굴절률
n2            : 광선 객체가 부딪힌 물체의 굴절률
"""
def get_refract_ray(intersection, ray, n, n1, n2):
  cos_theta_1 = -ray.d * n
  n1_over_n2 = n1 / n2
  cos_theta_2 = sqrt(
    1 - n1_over_n2 * n1_over_n2 * (1 - cos_theta_1 * cos_theta_1)
  )

  A = n2_over_n2 * (ray.d + cos_theta_1 * n)
  B = -cos_theta_2 * n

  return A + B

# current_refractive_index: 광선이 통과 중인 물체의 굴절률
def ray_trace(ray, depth, current_refractive_index):
  obj = # 부딪힌 물체들 중 가장 가까운 물체
  if obj is None:
    return background_color

  shade = # ambient 쉐이딩
  for light in lights:
    shade += # diffuse 쉐이딩
    shade += # specular 쉐이딩

  n = # 물체 표면의 법선 벡터

  if depth < 5:
    reflect_ray = get_reflect_ray(intersection, ray, n)
    reflect_shade = ray_trace(reflect_ray, depth + 1, current_refractive_index)

    refract_ray = get_refract_ray(
      intersection, ray, n,
      current_refractive_index, obj.refractive_index
    )
    refract_shade = ray_trace(refract_ray, depth + 1, obj.refractive_index)

    shade = (1 - obj.reflectivity - obj.transparency) * shade
      + obj.reflectivity * reflect_shade
      + obj.transparency * refract_shade

  return shade

아래는 반사와 굴절을 모두 적용한 렌더링 결과입니다.



반사와 굴절까지 레이 트레이싱에 필요한 기본 개념들을 하나씩 알아보았습니다. 물체와 광선의 부딪힘 판정, 법선 벡터 등 레이 트레이싱의 기본을 배우는데는 생각보다 크게 어려운 수학보다 기본적인 선형 대수, 기하학만을 필요로 했습니다. 혹시 레이 트레이싱의 진입장벽에 대해 부담을 가지고 계셨다면 이 블로그를 통해 조금은 덜어내셨길 바랍니다. 😃





출처 및 참고