Post Lists

2018년 12월 31일 월요일

Height Maps

https://lwjglgamedev.gitbooks.io/3d-game-development-with-lwjgl/content/chapter14/chapter14.html

Height Maps
이 챕터에서, 우리는 height maps를 사용하여 복잡한 테레인을 어떻게 만드는지를 배울 것이다. 우리가 시작하기 전에, 너는 어떤 리팩토링이 된 것을 알아야 할 것이다. 우리는 어떤 새로운 패키지들을 만들었고, 그것들을 더 잘 정리하기 위해 어떤 클래스들을 옮겼다. 너는 소스 코드에서 그 변화들을 체크할 수 있다.

그래서 height map이 무엇이냐? height map은 surface elevation data를 얻기 위해 픽셀 컬러를 사용하는 3D terrain을 생성하기 위해 사용되는 한 이미지이다. Height maps image는 보통 gray scale을 사용하고, Terragen같은 프로그램에 의해 생성될 수 있다. height map 이미지는 이것 처럼 보인다.

위의 이미지는 마치 너가 위에서 땅을 보고 있는 것처럼 보인다. 그 이미지로, 우리는 정점으로 구성된 삼각형으로 만들어진 한 mesh를 만들 것이다. 각 정점의 altitude는 이미지 픽셀들의 각각 컬러에 따라서 계산될 것이다. 블랙 컬러는 가장 낮은 값을 나타내고, 하얀색은 가장 높은 것을 나타낸다.

우리는 정점들의 한 grid를 만들 것인데, 그 이미지의 각 픽셀에 대해 하나씩이다. 그러한 정점들은 다음 그림에서 보여지듯이 mesh를 구성할 삼각형들을 형성하기 위해 사용될 것이다.

그 mesh는 x와 z축을 가로질러서 y축에서 elevation을 바꾸기 위해 픽셀 컬러를 사용하여 렌더링될 큰 quad를 형성할 것이다.

height map으로부터 3D terrain을 만드는 프로세스는 다음으로 요약될 수 있다:

  • height map을 포함하는 image를 불러와라 (우리는 각 픽셀에 접근하기 위해 BufferedImage instance를 사용할 것이다).
  • 각 이미지 픽셀에 대해, 픽셀 컬러를 기반으로 그것의 높이를 가진 정점을 만들어라.
  • vertex에 정확하 texture coordinate를 반환해라.
  • 정점과 과련된 삼각형을 그리기 위해 인덱스들을 설정해라.
우리는 위에서 설명된 단계들을 수행하여, height map image를 기반으로 Mesh를 만드는 HeightMapMesh라 이름이 있는 한 클래스를 만들 것이다. 그 클래스를 위해 정의된 상수들을 처음에 리뷰해보자.

private static final int MAX_COLOUR = 255 * 255 * 255;

우리가 위에서 설명했듯이, 우리는 height map으로 사용된 이미지의 각 픽셀 컬러를 기반으로 각 정점의 높이를 계산할 것이다. 이미지들은 보통 greyscale이고, PNG image에 대해서이다. 그리고 이것은 각 픽셀에 대한 RGB 컴포넌트가 0 ~ 255이다. 그래서 우리는 다른 높이를 정의하는 256개의 discrete values를 가진다. 이것은 너에게 충분한 정밀도일지도 모르지만, 만약 그것이 아니라면, 우리는 좀 더 intermediate values를 갖기 위해 세 개의 RGB components들을 사용할 수 있다. 이 경우에, 그 높이는 0 ~ 255^3의 범위를 형성하도록 계산되어질 수 있다. 우리는 두 번째 접근법을 선택할 것이다. 그래서 우리는 greyscale images로 제한되지 않는다.

다음 상수는:

private static final float STARTX = -0.5f;
private static final float STARTZ = -0.5f;

그 메쉬는 정점들의 한 집합으로 형성될 것인데 (픽셀당 하나씩), 그것의 x와 z 좌표들은 다음의 범위에 있을 것이다:
  • [-0.5, 0.5], 즉, [STARTX, -STARTX] for the x axis.
  • [-0.5, 0.5] 즉, [STARTZ, -STARTZ] for the z axis.
그 값들에 대해서 너무 걱정하지 말아라. 나중에 최종 메쉬는 그것의 사이즈가 world에 맞게 하기 위해 스케일될 수 있다. y축과 관련해서, 우리는 두 개의 파라미터 minY and maaxY를 설정할 것인데, y좌표가 가질 수 있는 가장 낮고 높은 값을 설정하기 위한 것이다. 이러한 파라미터들은 상수는 아니다. 왜냐하면 우리가 그것들을 run time에 바꾸기를 원할지도 모르기 때문이다. 적용된 스케일링과 독립적으로. 마지막으로, 테레인은 [STARTX, -STARTX], [STARTZ, -STARTZ], [minY, maxY] 범위의 cube안에 포함될 것이다.

그 메쉬는 HeightMapMesh 클래스의 생성자에서 만들어질 것인데, 이것처럼 정의된다.

public HeightMapMesh(float minY, float maxY, String heightMapFile, String TextureFile, int textInc) throws Exception{ }

그것은 y축에 대한 최소와 최대값을 받는다. height map으로서 사용될 이미지를 포함하는 파일의 이름과, 사용될 텍스쳐 파일도. 그것은 또한 우리가 나중에 이야기할 textInc라는 정수를 받는다.

우리가 그 constructor에서 할 첫 번째 것은 height map image를 Buffered Image instance로 불러오는 것이다.

그러고나서 우리는 texture file을 ByteBuffer로 불러오고, Mesh를 구성할 변수들을 준비한다. incx와 incz 변수들은 x와 z 좌표에서 각 정점에 적용될 increment를 가질 것이다. 그래서 그 Mesh는 위에서 명시된 범위를 다룬다.

그 이후에, 우리는 이미지에 대해 반복할 준비가 된다. 그리고 각 픽셀 당 한 정점을 만들고, 그것의 텍스쳐좌표를 준비하고, Mesh를 구성할 삼각형을 정확히 정의할 인덱스들을 준비한다.

정점 좌표를 만드는 프로세스는 self-explanatory하다. 이번에 왜 우리가 텍스쳐 좌표를 한 숫자로 곱하고 그 height가 어떻게 계산되는지를 무시하자. 각 정점에 대해 우리가 두 개의 삼각형들의 정점을 정의하고 있다는 것을 볼 수 있다 (우리가 마지막 row or column있다는 것을 제외하고). 3x3 image로 그것을 시각화해보자, 그것들이 어떻게 구성하는지를 시각화 하기 위해서. 3x3 image는 9개의 정점들을 구성하고. 그리고 따라서 2x4 삼각형에 의해 형성된 4개의 quads들이 있다. 다음의 그림은 그 grid를 보여준다. 다음의 사진은 그 그리드를 보여주고, Vrc (r: row, c: column)의 형태로 각 정점을 네이밍한다.

우리가 첫 번째 정점(v00)을 처리할 때, 우리는 빨갛게 칠해진 두 삼각형의 인덱스들을 정의한다.

우리가 두 번째 정점 (V01)을 처리할 때, 우리는 빨갛게 칠해진 두 삼각형들의 인덱스들을 정의한다. 그러나, 우리가 세 번째 정점 (V02)를 처리할 떄, 우리는 정점들을 더 정의할 필요가 없다. 그 행에 대한 삼각형들이 이미 정의가 되어있다.

너는 쉽게 그 프로세스가 나머지 정점들에 대해 처리되는지를 볼 수 있따. 이제, 우리가 모든 정점 위치, 텍스쳐 좌표, 인덱스들 만들기만 한다면, 우리는 Mesh를 만들필요가 있다. 그리고 모든 그 데이터가 있는 관련된 Material들도.

너는 우리가 입력으로 vertex positions을 취하는 normals를 계산하는 것을 볼 수 있다. normals이 어떻게 계산되는지를 보기전에, heights가 어떻게 얻어지는지를 보자. 우리는 getHeight라고 불려지는 한 method를 만드는데, 그것을 vertex에 대한 height를 계산한다.

그 메소드는 한 픽셀에 대한 x와 z 좌표들을 받고, image의 width와 Byte Buffer를 받는데, RGB 컬러를 반환하는 buffer이다 (개별 RGB 컴포넌트들의 합) 그리고 minY and maxY 사이에 포함된 값을 반환한다. (black 컬러에 대해 minY와 white color에 대해 maxY).

너는 BufferedImage를 사용해서 좀 더 간단한 버전을 개발할지도 모른다, 그것이 RGB 값을 얻는 편리한 방법을 포함하고. 그러나 우리는 AWT를 사용할 것이다. AWT는 OSX와 섞이지 않는다는 것을 기억해라. 그래서 그것들의 클래스를 사용하는것을 피해라.

이제 텍스쳐 좌푣르이 어떻게 계산되는지를 봐보자. 첫 번째 옵션은 전체 메쉬에 따라 텍스쳐를 wrap하는 것인데, top left vertex는(0,0) 텍스쳐 좌표를 가질 것이고, bottom right vertex는 (1,1) 텍스쳐 좌표를 가질 것이다. 이 접근법의 문제는 그 텍스쳐가 좋은 결과를 제공하기 위해서 커야할 것이다는 것이다. 만약 그렇지 않다면 그것은 너무 stretched될 것이다.

그러나, 우리는 매우 효율적인 기법을 사용하여 좋은 결과를 가진 작은 텍스쳐를 사용할 수 있다. 만약 우리가 텍스쳐 좌표들이 [1,1] 범위를 넘어가도록 설정한다면, 우리는 origin에 돌아와서 처음부터 다시 계산할 수 있다. 다음의 사진은 이 behavior를 보여주는데, 몇 개 쿼드들에서 같은 텍스쳐를 tiling하는 것을 보여준다. [1.1] 범위를 넘어가는.

이것이 텍스쳐 좌표를 설정할 떄 우리가 할 것이다. 우리는 텍스쳐 좌표를 factor로 곱할 것이다, textInc parameter인데, 텍스쳐의 픽셀의 개수를 증가시키기 위해서이다 adjacent vertices 사이에 사용될

{
저렇게 하면 쉐이더 코드에서 이렇게 하면 된다.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// shadertype=glsl
#version 330 core

out vec4 FragColor;

in vec3 fragPos;
in vec3 normal;
in vec2 TexCoords;
in float ypos;

uniform vec3 cameraPos;

uniform sampler2D terrTex;

void main()
{
 float newTexX = TexCoords.x > 1.0 ? TexCoords.x - floor(TexCoords.x) : TexCoords.x;
 float newTexY = TexCoords.y > 1.0 ? TexCoords.y - floor(TexCoords.y) : TexCoords.y ;
 

 // test Texture
 vec3 texColor = texture(terrTex, vec2(newTexX, newTexY)).xyz;
 FragColor = vec4(texColor, 1.0);
}

- 사용한 텍스쳐


- 결과물 1 (멀리서)

- 결과물 2 (확대)
- 결과물 3 (전체 Terrain 텍스쳐 좌표를 0 ~ 1로 설정했을 때)

뭐든 코드로 조종한다면 다 할 수 있을듯 하다.
텍스쳐 좌표를 0 ~ grid vertex 범위까지 해놓는다면, 각 한 개의 grid quad에 접근하여 뭔가 하고 싶을 때는 grid width와 height를 나누어서 접근할 수도 있겠다. 내가 원하는대로 하면 된다.

}

이제 남아있는 유일한 것은 normal 계산이다. 우리가 normals이 필요하다는 것을 기억해라. 그래서 light는 terrain에 정확하게 적용되어질 수 있다. normals 없이, 우리의 terrain은 같은 컬러로 렌더링 될 것이다, light가 각 지점에 어떻게 닿든. 우리가 여기에서 사용할 방법은 height maps에 대해 가장 효율적이지만, 그것은 normals이 어떻게 자동 계산되는지를 이해하느넫 도와줄 것이다. 만약 너가 다른 솔루션들을 검색해본다면, 외적 연산을 하지 않고 인접 점들의 높이를 사용하는 좀 더 효율적인 접근법을 찾을지도 ㅁ ㅗ른다. 그럼에도 불구하고, 이것은 startup에서 되기 때문에, 여기에서 보여지는 메소드는 성능을 많이 잡아먹지 않을 것이다.

그래픽적으로 normal이 어떻게 계산되는지 설명해보자. 우리가 P0이라는 정점을 가지고 있다고 상상해보자. 우리는 처음에 주변을 둘러싸는 정점들 가각에 대해 계산한다 (P1, P02, P3, P4), 이러한 점들을 연결하는 표면에 접선이 벡터들이다. 이러한 벡터들 (V1, V2, V3, V4)들은 P0으로부터 인접 점들 각각을 빼서 계싼된다 (V1 = P1- P0, etc).

그러고나서 우리는 인접 점들을 연결하는 평면들의 각가에 대한 normal를 계산한다. 이것은 이전에 계산된 벡터들에 대한 외적을 연산해서 된다. 예를들어, P1과 P2를 연결하는 표면의 normal (blue로 칠해진) V1과 V2 사이의 외적으로 연산된다.

만약 우리가 표면들의 나머지에 대해 normals를 계산한다면, P0에 대한 normal은 그 주변 surfaces의 모든 normal의 합계가 (normalized)가 될 것이다.

{
normal calculate에 대해서 위에서 처럼 해도 된다. 근데 이게 왜 이렇게 계산되는지에 대해서 알고 싶었는데,

https://www.leadwerks.com/community/topic/16244-calculate-normals-from-heightmap/?do=findComment&comment=106666

이 답변과

https://stackoverflow.com/questions/49640250/calculate-normals-from-heightmap

이 답변과

James Stewart Calculus 8판 PDF기준으로, Chapter 14의 Section 6인 Directional Derivatives and the Gradient Vector에 설명이 자세히 나와있다. 특히 Subsection인 Tangent Planes to Level Surfaces가 normal를 구하는데 쓰이는 수학이다.

그 수학 내용이 스택오버플로우에 적혀져있어서, Calculus 텍스트북을 공부해야 그 답변을 이해가 가능할 것이다.

여튼 적용해봤는데, 확실히 Perlin Noise를 evalute하면서 구한 normal과는 차이가 난다. 그래도 어쨋든 이정도면 타협할만하다. 저기 답변에서 주의해야할 것이 있는데

나는 여러가지 답변들을 다 시도해보았는데,
https://www.scratchapixel.com/lessons/procedural-generation-virtual-worlds/perlin-noise-part-2/perlin-noise-computing-derivatives

여기에서 설명한 것이 가장 깔끔하고, 코드도 깔끔하다. 어떤 답변을 실행하든 normal은 거기에서 거기다. analytical partial derivative가 아닌 이상 크게 차이가 안난다. 그래서 코드는 다음 처럼 깔끔하게 되었다.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Normals
for (unsigned j = 0; j <= m_terrainSubDepth; ++j)
{
 for (unsigned i = 0; i <= m_terrainSubWidth; ++i)
 {
  int centerIndex = i + j * (m_terrainSubWidth + 1);
  int rightIndex = centerIndex + 1;
  int downIndex = centerIndex + (m_terrainSubWidth + 1);

  // Handle Edge Case.
  if (i == m_terrainSubWidth) rightIndex = centerIndex;
  if (j == m_terrainSubDepth) downIndex = centerIndex;

  float centerV = vertices[centerIndex].y;
  float rightV = vertices[rightIndex].y;
  float downV = vertices[downIndex].y;

  // calculate the normal using the gradient vector of partial derivative math
  normals[centerIndex] = glm::normalize(glm::vec3((rightV - centerV) *  -1.f, 1.f, (downV - centerV) * -1.f));
 }
}

이게 height map으로 부터 normal를 만드는 최종 코드가 될 것이다.

간단한 directional light를 했다.

}



마지막으로, 더 큰 terrains을 구성하기 위해, 우리는 두 가지 옵션들을 가진다:

  • 더 큰 height map을 만들어라
  • height map을 재사용하고, 그것을 3D space를 통해 타일링해라. 그 height map은 타일같이 world를 가로질러서 이동할 수 있는 terrain block처럼 보일 것이다. 그렇게 하기 위해서, height map의 edge의 픽셀들은 같아야 한다 (left edge는 right side와 동일하고 top edge는 bottom과 동일하고), 그 타일들간의 gaps를 피하기 위해서.
우리는 두 번째 접근법을 사용할 것이다 (그리고 적절한 height map을 선택할 것이다). 이것을 지원하기 위해서, 우리는 height map tiles의 square를 만들 Terrain이라고 이름지엊인 class를 만들 것이다.


전체 프로세스를 설명해보자. 우리는 다음의 좌표들을 가진 블럭들을 가지고 있다 (x와 ㅋz에 대해, 위에서 정의된 상수들과 함께).

3x3 blocks grid에 의해 형성된 한 테레인을 만든다고 가정하자. 또한 우리가 그 terrain blocks을 스케일링 하지 않는다고 가정하자 (즉, 그 변수 blocksPerRow는 3이 될 ㅓㄳ이고, scale 변수는 1이다). 우리는 grid가 (0,0) 좌표에 중심을 잡길 원한다.

우리는 그 블럭들을 평행이동 시키길 원한다. 그래서 그 정점들은 다음의 좌표들을 가질 것이다.

그 평행이동은 setPosition method에 의해서 얻어지지만, 우리가 설정한 것이 position이 아닌 displacement라는 것을 기억해라. 만약 너가 위의 표를 다시 본다면, 너는 central block이 어떤 displacement를 요구하지 않는다는 것을 알 것이다.  그것은 이미 적절한 좌표에 위치되어있다. 초로색에 그려진 정점은 displacement가 필요하다. x좌표에 -1정도. 파란색으로 그려진 정점은 +1의 displacement가 필요하다. x displacement를 계산하는 공식은, 그 스케일과 block width를 고려해서 이것이다:

xDisplacement = (col - (blocksPerRow - 1) / 2) * scale * width

만약 우리가 DummyGame class같은 Terrain instance를 만든다면, 우리는 이것과 같은 것을 얻는다.

너는 테레인 주위로 카메라를 움직일 수 있고, 그것이 어떻게 렌더링 되는지 볼 수 있따. 우리는 여전히 충돌 탐지를 구현하지 않았기 때문에, 너는 그것을 통과해서 가고 그것으로 부터 바라볼 수 있따. 우리는 face culling을 활성화 했기 때문에, 테레인의 어떤 부분들은 아래에서 볼 때 렌더링 되지 않는다.

댓글 없음:

댓글 쓰기