본문 바로가기
Graphics/Graphics

Parallax Mapping

by scahp 2022. 2. 27.

Parallax Mapping

최초 작성 : 2022-02-27

마지막 수정 : 2022-02-27

최재호

 

[소스코드 수정] TBN Matrix 사용 오류 수정 - updated 2022-03-13

목차

1. 목표
 2. Parallax Mapping
  2.1. 다른 돌출된 부분을 고려한 텍셀의 계산
  2.2 Normal과 View Vector가 거의 수직인 경우 Offset이 너무 커지는 부분 완화
  2.3. height 값의 사용 범위 조정과 Pxy의 캐싱
  2.4. 높이 급격하게 변하는 경우 너무 멀리있는 Offset를 사용 완화를 위한 k Iteration
3. 실제 구현 코드
4. 실제 구현 결과
5. References

 

1. 목표

Normap Mapping의 단점을 보완한 텍스쳐 매핑 기법인 Parallax Mapping에 대해서 알아보고 실제 구현해봅시다.

Foundations of Game Engine Development Volume 2 Rendering 의 내용을 기반으로 알고리즘을 이해하고 구현합니다.

 

사전 지식

Normal Mapping

Tangent Space

 

2. Parallax Mapping

노멀매핑은 메시의 디테일을 노멀맵을 통해서 구현하는 기술입니다. 그래서 Triangle 수를 줄이면서도 메시의 디테일을 모두 표현할 수 있다는 점이 큰 장점입니다. 노멀맵을 통해서 얻어온 노멀로 표면의 라이팅을 구하는 방식으로 디테일을 구현하게 됩니다.

그림1. Quad에 벽돌벽 텍스쳐를 사용하여 렌더링한 이미지. 좌측 이미지는 노멀맵 사용하기 전, 우측은 노멀맵 사용 (출처 : 직접구현)

 

노멀매핑의 단점은 표면과 뷰벡터 사이의 각이 작아질 수록 돌출된 부분이 덜 표현된다는 점입니다. 이렇게 되는 이유는 다음과 같습니다. 노멀맵의 경우 특정 한 위치를 렌더링한다고 할 때, 어느 방향에서 보던간에 동일한 텍셀을 사용하여 렌더링하게 됩니다. 하지만 실제 돌출된 부분이 있다면 보는 방향에 따라서 다른 돌출된 부분들에 의해서 렌더링 될 컬러 텍스쳐의 픽셀이 가려질 것입니다.

 

그림2. 표면과 뷰벡터 사이의 각이 작어진 경우 돌출된 부분이 잘 표현되지 않는 것을 볼 수 있음. 위쪽이 노멀맵 사용전, 아래쪽이 노멀맵 사용 후 (출처 : 직접 구현)

 

2.1. 다른 돌출된 부분을 고려한 텍셀의 계산

 

그림3. Parallax Mapping의 핵심 설명, UV Offset인 Pxy를 구하는 과정을 설명함. 파랑색 선이 원래 돌출된 표면, o는 현재 위치의 UV로 원점 (0, 0) 로 둠, h는 현재 위치의 높이, v는 뷰벡터, n과 h를 사용하여 근사 평면을 만들고 이 평면과 뷰벡터의 교점 P를 구함, 교점 Pxy 위치를 UV의 Offset으로 사용함. (출처 : 레퍼런스1)

 

돌출된 부분을 고려하여 UV를 구하기 위해서는 아래와 같은 과정을 거칩니다.

1). 현재 텍셀의 Normal과 Height를 사용하여 근사된 평면을 구함

2). 뷰벡터(현재 지점에서 Eye 방향으로의 벡터)와 1)에서 구한 근사된 평면의 교점을 구함

3). 2)에서 구한 교점의 위치를 기존 Texture UV에 더해줘서 새로운 UV를 구함.

 

이 내용을 그림3을 보면서 다시한번 봅시다. (이해를 쉽게하기 위해서 위해서 2차원 이미지로 표현하고 있습니다.)

파란색 선이 실제 표면의 돌출된 부분입니다. 그리고 o는 원래 사용하려고 했던 Texture UV 위치입니다. Pxy는 Texture UV에 추가될 Offset UV 입니다. 계속해서 Pxy를 어떻게 구하는지 알아봅시다.

 

o, h 그리고 n을 사용하여 실제 돌출된 위치에 접하는 평면을 하나 구합니다. 여기서 구한 평면이 실제 표면의 돌출된 부분과 유사하다는 것을 그림3을 통해 알 수 있습니다. 그리고 (eye - o)를 통해 얻어낸 뷰벡터와 바로 전에 구한 평면과의 교점을 구합니다. 이 위치가 P 입니다. 이 위치 Pxy를 Texture UV의 Offset으로 사용합니다.

 

여기서 주의할 점은 그림3의 연산에 사용되는 모든 좌표나 공간을 탄젠트 공간으로 통일하여 계산합니다.

Normal과 Height 등의 정보를 텍스쳐맵에서 얻어올 때 Texture UV 를 기준으로 얻어오며, 연산의 결과인 Pxy 또한 Texture UV입니다. 그래서 뷰벡터 하나만 탄젠트 공간으로 옮기면 됩니다.

 

그림4는 Pxy를 유도하기 위한 공식을 설명합니다.

그림4. 근사평면과 뷰벡터의 교점을 구하는 공식 유도 (출처 : 직접 작성)

 

2.2 Normal과 View Vector가 거의 수직인 경우 Offset이 너무 커지는 부분 완화

그림4를 통해서 얻어낸 식은 n dot v 식으로 나누어 주는 부분 때문에 n dot v 가 0에 가까워질 수록 Offset이 상당히 커질 수 있는 문제가 있습니다. 이 책에서는 이 부분을 완화하기 위해서 단순히 n dot v를 곱해주어 이 부분을 제거합니다. 그래서 최종 Pxy 식은 그림5가 됩니다.

그림5. 현재 UV에서의 Offset Pxy를 구하는 식, n dot v를 나눠주는 부분을 제거하여 수직인 경우 너무 많은 offset을 이동하는 부분을 제거함. (출처 : 레퍼런스1)

 

2.3. height 값의 사용 범위 조정과 Pxy의 캐싱

실제 사용 시에는 height를 통해 돌출되는 부분이 더 명확하게 나타나도록 h의 범위를 0~1 사이 값에서 -1/2 ~ 1/2 값으로 변경합니다. 그래서 원본 height의 1/2 위치인 경우 Pxy의 Offset이 0이고, 원본 height의 1/2 보다 더 큰 경우 View 방향(eye방향)으로 Offset을 이동하고, 1/2 보다 작은 경우 View 에서 멀어지는 방향으로 Offset이 이동합니다.

 

Pxy = h * nz는 미리 계산하여 텍스쳐에 저장할 수 있습니다. 그래서 ParallaxMap이란 이름으로 Pxy를 저장 할 수 있습니다. 책에서는 Pxy를 저장하기 위해서 1개의 채널 Signed 8 bit 텍스쳐를 사용합니다. 그리고 h 값은 -1~1 값을 가질 수 있도록 2h - 1 한 후 nz와 곱하여 ParallaxMap에 저장합니다. height의 범위가 -1~1 이 되면, 기존 0~1 에 비해서 범위가 2배가 됩니다. 이 부분을 보정하기 위해서 추후에 ParallaxMap에서 데이터를 얻어온 후 1/2를 곱하여 사용합니다. (이렇게 한 이유는 텍스쳐에 저장할 때 정밀도를 최대한 잘 활용하기 위해서 -1~1 사이 값을 사용한 것 같습니다.)

 

2.4. 높이 급격하게 변하는 경우 너무 멀리있는 Offset를 사용 완화를 위한 k Iteration

범프맵의 경사가 너무 가파른 경우 너무 먼곳의 텍셀이 선택될 수 있습니다. 이런 부분을 해소하기 위해서 k 번 Parallax Mapping을 반복하는 방식을 사용할 수 있습니다. 이렇게 k번 이동할 때마다 새로운 근사 평면을 사용하기 때문에 더욱 더 정밀한 UV 위치를 얻어낼 수 있을 것입니다.

 

기존코드는 근사 평면이 있고, 근사 평면과 뷰벡터의 교차점을 구하기 때문에 확실히 Pxy를 구할 수 있었습니다. 하지만 k 반복 방식을 사용하는 경우 교차점이 아닌 Texture UV를 뷰벡터 방향(탄젠트 공간)으로 일정 거리만큼의 이동해야 합니다. 얼마만큼을 이동할지는 정해야하는데 책에서는 그림6와 같은 scale 값을 사용합니다.

 

그림6. k Iteration을 위한 이동 scale 값

 

그림6에서 s는 범프맵에서 노멀맵을 생성할 때 사용한 값입니다. 이 값은 범프맵의 height의 최대값이 텍셀 1개의 width 를 기준으로 몇배가 되는지가 담겨있는 값입니다. rx, ry는 텍스쳐의 크기입니다. 즉, (s/rx, s/ry)는 height의 최대값을 텍셀 크기 기준으로 변환한 것입니다. (제 생각에는 step 단위로 이동하게 되면 최종 목적지가 반드시 결정되어 있지 않기 때문에 적당한 이동 거리를 설정해주는 것 같고, 이 기준 거리를 height맵의 최대 높이로 잡은 것 같습니다.) k는 순회 횟수입니다. 앞에서 구한 최대 이동거리를 1/k 으로 나누어줍니다. 마지막으로 1/2는 Parallax Map에서 얻어온 Pxy = h * nz 에서 h 범위 -1~1 을 -1/2~1/2 로 변경하기 위한 부분이고 바로 여기서 곱해집니다.

 

3. 실제 구현 코드

실제 구현 코드는 여기에 있습니다.

https://github.com/scahp/Shadows/tree/ParallaxMapping

 

이제 실제 Parallax map의 작동 코드를 봅시다.

C++ 코드쪽에서는 단순히 Quad 를 렌더링합니다.

static auto Plane = jPrimitiveUtil::CreateQuad(Vector::ZeroVector, Vector::OneVector, Vector(100.0f, 100.0f, 100.0f), Vector4::ColorWhite);
{
  auto EnableClear = false;
  auto EnableDepthTest = false;
  auto DepthStencilFunc = EComparisonFunc::LESS;
  auto EnableBlend = false;
  auto BlendSrc = EBlendSrc::ONE;
  auto BlendDest = EBlendDest::ZERO;
  auto Shader = jShader::GetShader("ParallaxMapping");
  g_rhi->EnableDepthTest(true);
  g_rhi->EnableBlend(EnableBlend);
  g_rhi->SetBlendFunc(BlendSrc, BlendDest);
  g_rhi->SetShader(Shader);

  MainCamera->BindCamera(Shader);

  Plane->RenderObject->tex_object = DiffuseTexture;
  Plane->RenderObject->samplerState = jSamplerStatePool::GetSamplerState("LinearWrap").get();
  Plane->RenderObject->tex_object2 = BumpTexture;
  Plane->RenderObject->samplerState2 = jSamplerStatePool::GetSamplerState("LinearWrap").get();
  Plane->RenderObject->tex_object3 = DispTexture;
  Plane->RenderObject->samplerState3 = jSamplerStatePool::GetSamplerState("LinearWrap").get();

  SET_UNIFORM_BUFFER_STATIC(float, "NumOfSteps", jShadowAppSettingProperties::GetInstance().NumOfSteps, Shader);
  SET_UNIFORM_BUFFER_STATIC(Vector2, "TextureSize", TextureWH, Shader);
  SET_UNIFORM_BUFFER_STATIC(float, "HeightScale", jShadowAppSettingProperties::GetInstance().HeightScale, Shader);
  SET_UNIFORM_BUFFER_STATIC(Vector, "EyeWorldPos", MainCamera->Pos, Shader);
  SET_UNIFORM_BUFFER_STATIC(Vector, "LightDirection", DirectionalLight->Data.Direction, Shader);
  SET_UNIFORM_BUFFER_STATIC(int32, "TexturemappingType", (int32)jShadowAppSettingProperties::GetInstance().TextureMappingType, Shader);
  SET_UNIFORM_BUFFER_STATIC(int32, "FlipedYNormalMap", (int32)1, Shader);
  
  Plane->Update(deltaTime);
  Plane->Draw(MainCamera, Shader, {});
}

 

버택스 쉐이더에서는 TBN 매트릭스를 구성하고 뷰벡터를 탄젠트 공간으로 이동시켜 줍니다.

 

#version 330 core

precision mediump float;

layout(location = 0) in vec3 Pos;
layout(location = 1) in vec4 Color;
layout(location = 2) in vec3 Normal;
layout(location = 3) in vec3 Tangent;

uniform mat4 M;
uniform mat4 MVP;
uniform vec3 EyeWorldPos;

out vec2 TexCoord_;
out vec4 Color_;
out mat3 TBN;
out vec3 WorldSpaceViewDir;

void main()
{
  TexCoord_ = (Pos.xz + 0.5);
  Color_ = Color;
  gl_Position = MVP * vec4(Pos, 1.0);
  vec3 WorldPos = (M * vec4(Pos, 1.0)).xyz;
  WorldSpaceViewDir = normalize(EyeWorldPos - WorldPos);

  vec3 T = normalize(vec3(M * vec4(Tangent, 0.0)));
  vec3 B = normalize(vec3(M * vec4(cross(Normal, Tangent), 0.0)));
  vec3 N = normalize(vec3(M * vec4(Normal, 0.0)));

  TBN = mat3(T, B, N);
}

 

Parallax Mapping의 픽셀 쉐이더입니다.

여기서는 Parallax Map을 따로 구성하여 사용하지 않았습니다. 알고리즘을 이해하는데 편의를 뒀기 때문입니다. 그래서 Parallax Mapping을 사용하는 경우 매 연산마다 Height와 Normal을 얻어와서 UV의 Offset을 구합니다.

그리고 Iteration중 사용하는 scale 값을 구할 때, s = (max height / 1 texel width) 값을 임의로 조정할 수 있게 하여서 Parallax Mapping 시 돌출되는 양을 조정가능하게 하였습니다. 이렇게 한 이유는 돌출양을 조정가능한 점도 있지만 직접 범프맵에서 생성하지 않은 노멀맵의 경우 이 s값을 알 수 없기 때문에 적당한 값으로 조정가능하도록 한 것입니다.

#version 330 core

precision mediump float;

uniform sampler2D tex_object;    // diffuse
uniform sampler2D tex_object2;    // normalmap
uniform sampler2D tex_object3;    // height map
uniform int TextureSRGB[1];
uniform int UseTexture;
uniform int FlipedYNormalMap;

uniform int TexturemappingType;
uniform vec3 LightDirection;    // from light to location
uniform vec2 TextureSize;
uniform float HeightScale;      // s = (max height / 1 texel with), this was using when the normalmap was generated.
uniform float NumOfSteps;      // Num of ParallaxMap iteration steps

in vec2 TexCoord_;
in vec4 Color_;
in mat3 TBN;
in vec3 WorldSpaceViewDir;

out vec4 color;    // final color of fragment shader.

vec4 GetDiffuse(vec2 uv)
{
  return texture(tex_object, uv);
}

// Fetching normal from normal map
vec3 GetNormal(vec2 uv)
{
  vec3 normal = texture(tex_object2, uv).xyz;
  normal = normal * 2.0 - 1.0;
  return normal;
}

// Shifting UV by using Parallax Mapping
vec2 ApplyParallaxOffset(vec2 uv, vec3 vDir, vec2 scale)
{
  vec2 pdir = vDir.xy * scale;

  if (FlipedYNormalMap > 0)
    pdir.y = -pdir.y;  // because opengl texture y is inverted

  for (int i = 0; i < NumOfSteps; ++i)
  {
    // This code can be replaced with fetching parallax map for parallax variable(h * nz)
    float nz = GetNormal(uv).z;
    float h = (texture(tex_object3, uv).x * 2 - 1);
    float parallax = nz * h;
    /////////////////////////////////

    uv += pdir * parallax;
  }

  return uv;
}

void main()
{
  vec2 uv = TexCoord_;
  
  mat3 transposeTBN = transpose(TBN);
  vec3 TangentSpaceViewDir = normalize(transposeTBN * WorldSpaceViewDir);

  // Parallax Mapping을 사용하는 경우 UV를 조정함.
  if (TexturemappingType == 2)
  {
    vec2 scale = HeightScale / (2.0 * NumOfSteps * TextureSize);
    uv = ApplyParallaxOffset(uv, TangentSpaceViewDir, scale);
    uv = clamp(uv, vec2(0.0), vec2(1.0));
  }

  // NormalMap으로 부터 normal을 얻어오고, TBN 매트릭스로 변환시켜줌
  vec3 normal = GetNormal(uv);  
  normal = normalize(TBN * normal);

  // 라이팅 연산 수행
  float LightIntensity = 1.0f;
  if (TexturemappingType > 0)
  {
    LightIntensity = clamp(dot(normal, -LightDirection), 0.0, 1.0);
  }
  else
  {
    LightIntensity = clamp(dot(vec3(0.0, 1.0, 0.0), -LightDirection), 0.0, 1.0);
  }
  
  // Fetching Diffuse texture
  if (UseTexture > 0)
  {
    color = GetDiffuse(uv);
    if (TextureSRGB[0] > 0)
      color.xyz = pow(color.xyz, vec3(2.2));
  }
  else
  {
    color = Color_;
  }

  color.xyz *= vec3(LightIntensity);
}

 

4. 실제 구현 결과

그림7. Parallax Mapping 결과 (출처 : 직접구현)

 

그림8. DiffuseOnly vs NormalMapping vs Parallax Mapping 비교 (출처 : 직접구현)

 

5. References

1. https://foundationsofgameenginedev.com/

 

 

반응형

'Graphics > Graphics' 카테고리의 다른 글

Halfspace Fog 공식 유도 정리 - 흡수와 산란 응용  (2) 2022.03.22
Horizon Mapping  (0) 2022.03.15
Color Science  (2) 2021.12.25
[PBR] Substance/Roughness/Metalic  (0) 2021.05.03
Atmospheric Shadowing  (0) 2021.04.14