Texturing

쉽게 말해서, 우리가 일일히 도형의 Pixel 에다가 색깔을 넣어서 그림을 넣어주기는 너무 번거롭다. 그렇다면 2D 이미지를 가지고 와서 그 도형에다가 덮어버리는 기술을 Texture Mapping 이라고 한다. 관련된건 Resource 를 참고 하자. 일단 브레인 스토밍을 해보자, 어떤 Texture 이미지를 Screen 안에 있는 또는 가상 공간안에 있는 도형에 맞춰서 그릴려고 한다. 다시 말해서, Texture 위치에 어느 색깔값을 가지고 와서, 도형에 그릴까? 라는것이 되어야한다. 그럴려면 좌표계 변환이 필요하다. 아래의 그림을 한번 참고 해보자.

위의 그림과 같이 Texture Coordinates 를 uv 좌표계라고 한다. 왼손좌표계를 DirectX 에서 사용하므로(OpenGL 은 반대), 맨윗 오른쪽이 (0, 0) 을가지고 width 가 1 이고, height 가 1 좌표계를 만들수 있다. 그래서 본론으로 돌아와서, 이 Texture 을 어떻게 도형에다가 매칭 시켜줄지, 즉 임의의 UV 좌표에 있는 색깔값을 도형에 넣는 방법을 Sampling 이라고 한다. 여기서 Sampling 방법은 두가지가 있다. 하나는 Mapping 을 할때, 임의의 UV 좌표를 이미지 좌표에 매핑이 되었을때 가장 가까운 Pixel 값만 가져오는게, Point Sampling 이라고 하고, Interpolation 을 해서 Linear 하게 부드럽게 표현하기위해서 Sampling 하는 기법을 Linear Sampling 이라고 한다.

일단 uv 좌표계에서, 이미지의 좌표계로 변환하는과정을 살펴보자. 텍스춰링의 좌표 범위는 앞서 말한것 처럼 [0.0, 1.0] x [0.0, 1.0] 여기에서 이미지 좌표를 변경하려면, [-0.5, width - 1 + 0.5] x [-0.5, height - 1 + 0.5] 의 좌표에 Mapping 을 해야한다. 여기에서 0.5 가 Padding 처럼 붙여있는이유는, 일단 UV 좌표계에서 한 pixel 이 pixel block 정중앙에 위치해있다고 생각했을때, 실제 UV 좌표의 array 또는 메모리가 저장되는 측면에서는 [0, width-1] x [0, height - 1] 인덱싱을 할수 있지만, 어떤 임의점을 표현을 하려면 0.5 씩 벌어져야 그 중앙에 가까운 값들을 가지고 올수 있어서 표현되어있다.

vec2 xy = uv * vec2(float(width), float(height)) - vec2(0.5f);

Sampling (Point & Linear)

여기에서 Sampling 을 하는 것을 설명을 하려고한다.

Point Sampling Code 는 아래와 같다. GetClamp 함수는 이미지가 좌표가 넘어가는걸 Clamping 하기위해서, 그리고 Point 같은 경우는 Pixel 이 Pixel block 정중앙에 있기 때문에 제일 가까운걸 가져다 쓰고 싶어서 glm::round 를 사용했다.

class Texture
{
public:
    int width, height, channels;
    std::vector<uint8_t> image;
    Texture(const std::string &fileName);
    Texture(const int& width, const int &height, const std::vector<vec3> &pixels);

    vec3 GetClamped(int i, int j)
	{
		i = glm::clamp(i, 0, width - 1);
		j = glm::clamp(j, 0, height - 1);
		const float r = image[(i + width * j) * channels + 0] / 255.0f;
		const float g = image[(i + width * j) * channels + 1] / 255.0f;
		const float b = image[(i + width * j) * channels + 2] / 255.0f;
		return vec3(r, g, b);
	}

    vec3 SamplePoint(const vec2 &uv) // Nearest sampling이라고 부르기도 함
	{
		vec2 xy = uv * vec2(float(width), float(height)) - vec2(0.5f);
		int i = glm::round(xy.x);
		int j = glm::round(xy.y);
		// return GetClamped(i, j);
		return GetClamped(i, j);
	}
}

결과는 아래와같다.

또 다른건 바로 Linear Sampling 이다. Linear Sampling 같은 경우 Pixel 을 가지고 오지만, 그 Point 와 Point 사이의 Pixel 값을 자연스럽게 추가해줘야되기 때문에 Linear Interpolation 을 사용해야 한다. 또한 가로축 Sampling 을 할뿐만아니라, 세로축도 Sampling 을 해야 한다. 즉 Linear Interpolation 을 두번하는걸 Bilinear Interpolation 이라고 한다.

구현은 비슷하지만, GetWrapped 와 GetClamp 에 따라서 결과값이 달라진다. 그리고 Bilinear Interpolation 가로축에 해당되는 부분을 a 라고 지정했으며, 세로축에 해당되는 부분을 b 로 저장해서 사용되었다.


vec3 InterpolateBilinear(const float &dx, const float &dy,
    const vec3 &c00, const vec3 &c10,
	const vec3 &c01, const vec3 &c11)
{
	vec3 a = c00 * (1.0f - dx) + c10 * dx;
	vec3 b = c01 * (1.0f - dx) + c11 * dx
	return a * (1.0f - dy) + b * dy;
}

vec3 SampleLinear(const vec2 &uv)
{
	const vec2 xy = uv * vec2(float(width), float(height)) - vec2(0.5f);
	const int i = int(glm::floor(xy.x));
	const int j = int(glm::floor(xy.y));
	const float dx = xy.x - float(i);
	const float dy = xy.y - float(j)
	return InterpolateBilinear(dx, dy, GetClamped(i, j), GetClamped(i + 1, j), GetClamped(i, j + 1), GetClamped(i + 1, j + 1));
}

결과는 아래와같다.

Texturing

위와같이 사용을했을때 GetWrapped 와 GetClamped 를 사용해서 원하는 Domain 에 따라 사용되는게 다를수도있고, 그리고 현재 Texture 에 빛의 효과를 따로 줄수도 있다.

SuperSampling

Super Smapling 기법 같은 경우 Alisaing 을 지우기 위해서 사용이 된다. Alias 는 이미지나 물체가 Sample 될때, distortion 이 되는 효과를 말을 한다. 아래의 이미지를 참고 하기 바란다.

이런 상황일때 사용할수 있는게 Supersampling 기법이다. 요즘엔 Deep Learning 을 사용한 Super Sampling 이 있다. 일단 Super Sampling Sampling 을 하는 방법은 여러가지가 있다. 구현하고자 하고 싶은건 Grid algorithm in uniform distribution 으로 Sampling 을 할거다.

일단 Recursive 방식으로 할거고, 4 개의 점을 이용해서 Sampling 을 하려고 하니까 2x2 filter 처럼 구현된걸 볼수 있다. Recursive Level 이 높을수록 Sampling 하는게 다르다.

vec3 traceRay2x2(vec3 eyePos, vec3 pixelPos, const float dx, const int recursiveLevel)
{
	if (recursiveLevel == 0)
	{
		Ray myRay{pixelPos, glm::normalize(pixelPos - eyePos)};
		return traceRay(myRay);
	
	const float subdx = 0.5f * dx
	vec3 pixelColor(0.0f);
	pixelPos = vec3(pixelPos.x - subdx * 0.5f, pixelPos.y - subdx * 0.5f, pixelPos.z)
	for (int j = 0; j < 2; j++)
	{
		for (int i = 0; i < 2; i++)
		{
			vec3 subPos(pixelPos.x + float(i) * subdx, pixelPos.y + float(j) * subdx, pixelPos.z);
			Ray subRay{ subPos, glm::normalize(subPos - eyePos) };
			pixelColor += traceRay2x2(eyePos, subPos, subdx, recursiveLevel - 1);
		}
	}
	return pixelColor * 0.25f;
}

결과는 아래와 같다.

Sample = 2

Sample = 4

Resource

Shadowing

빛이 있는곳에 우리가 걷거나 어떤 행동을 하게 된다면, 우리의 모습과 똑같게 그림자가 있는걸 확인 할수 있다. 그림자는 결국 물체에 빛이 부딛혔을때, 그 뒷부분에다가 광원과 비슷한 방향으로 그림자를 그려주면 된다. 즉 조명의 위치나 물체의 위치에따라서 그림자가 결정된다.

그리고 참고로 Real-time graphics 에서는 광원에서 물체가 있다고 하면은 뒤에는 그림자를 붙여주는 형식으로 한다.

조금 원리를 짚고 넘어가자.

  1. 눈에서 나오는 Ray 가 Screen 을 지나서 어떤 바닥에 부딫힌다.
  2. 그 바닥에 부딫힌 Ray 에서, 다시 Shadow Ray 를 쏴준다.
  3. 그때 물체가 있다면 충돌할거고, 없으면 바로 광원쪽으로 갈거다.

여기서 두번째 스텝 같은경우 Shadow Ray 를 쏜다는 의미가 바닥에서 쏜다는 의미보다는 Shadow Ray 가 광원까지 도달할수 있는지 없는지를 체크하는것이다. 그러면 Diffuse 와 Specular 은 없애버리고, ambient color 만 검은색으로 칠해주면된다. 참고로 Shadow Ray 에서 쏠때, 작은 수치의값을 움직인다. 위의 내용은 아래의 그림과 같다.

코드는 아래와 같다.

glm::vec3 color(hit.obj->amb);

const vec3 dirToLight = glm::normalize(light.pos - hit.poin)
Ray ShadowRay = { hit.point + dirToLight * 1e-4f, dirToLight }
if (FindClosestCollision(ShadowRay).d < 0.0f)
{
	const float diff = glm::max(dot(hit.normal, dirToLight), 0.0)
	const vec3 reflectDir = 2.0f * dot(hit.normal, dirToLight) * hit.normal - dirToLight;
	const float specular = glm::pow(glm::max(glm::dot(-ray.dir, reflectDir), 0.0f), hit.obj->alph)
	color += hit.obj->dif * diff + hit.obj->spec * specular;
}
return color;

결과는 아래와 같다.

Resource

Introduction to Barycentric Coordinates

이제는 도형을 그려봤으니, 도형에 색깔을 입혀보는것도 중요하다. 도형에 어떻게 색상을 정해줄지는 유저의 자유지만, 내부 알고리즘이 어떻게 돌아가는지는 알아야한다.

근데 만약에 각 Vertex 당 색이 정해졌다고 하면, 편하게 알아서 그라데이션을 해줄수 있는 방법이 있을까하면, 바로 Barycentric Coordinates 이 있다. 즉 가중치를 줘서 Pixel 값에 영향을 끼치는거다.

여기서 Barycentric Coordinates 바로 넘어가기전에 Interpolation 을 한번 생각해보자.

Interpolation or Linear Interpolation

아래의 그림을 살펴보자.

일단 v1 과 v0 이 존재한다고 했을때, 임의의 V 의 점을 찾으려고 할때 Linear Interpolation 을 사용해서 그 Pointer 를 구할수 있다. 예를 들어서 V 의 값이 v0 에 가까워진다고 했을때 b 에 가중치가 더들어가서, b / a + b 는 커질것이다. 마찬가지로 반대 일 경우이는 a / a + b 의 가중치가 거질것이다. 또 이 가중치의 합은 1 이므로, 이미지에 표현된것과 같이 표현이 가능하다.

Barycentric Coordinates

위의 내용과 같이 생각을 해보자면 삼각형에도 적용이 가능할것이다. 어떠한 점 P 를 잡고, Vertex 를 표현했을때 P = v0 + v1 + v2 / 3 이라고 표현될것이다. 더 쉽게 표현한다고 하면 W 를 사용해서 p = w0v0 + w1v1 + w2v2 이런식으로 표현이 될것이다. 그리고 각 W 는 Point (P) 를 사용해서 작은 삼각형의 생각한다면 넓이의 비율로 구할수 있을것이다. 넓이 뿐만아니라 각 Vertex 의 색상이 주어졌다면, 똑같이 구할수 있다.

Point (P) 의 색깔 Color(c) 가 있다고 가정을 한다면, 위에 표시한것처럼 Color 도 c = w0c0 + w1c1 + w2c2 로 표현이 가능할거다.

설명은 이쯤하고 구현하는 코드를 공유하려고 한다.

const vec3 cross0 = glm::cross(point - v2, v1 - v2);
const vec3 cross1 = glm::cross(point - v0, v2 - v0);
const vec3 cross2 = glm::cross(v1 - v0, point - v0
if (dot(cross0, faceNormal) < 0.0f)
    return false;
if (dot(cross1, faceNormal) < 0.0f)
    return false;
if (dot(cross2, faceNormal) < 0.0f)
    return fals

const float area0 = glm::length(cross0) * 0.5;
const float area1 = glm::length(cross1) * 0.5;
const float area2 = glm::length(cross2) * 0.
const float areaSum = area0 + area1 + area

float w0 = area0 / areaSum;
float w1 = area1 / areaSum;
float w2 = 1 - w0 - w1;

이 코드를 사용한다고 하면, 아래와 같이 삼각형에 부드럽게 Pixel 이 적용되어있는걸 확인할수 있다.

Model, View, Projection

생각보다, Virtual Environment, 즉 사람이 모니터를 봤을때, 물체의 움직임을 어떻게 정의하고, CPU 에서는 충분히 가능한 이야기일수 있다. 하지만 Game Engine 은 어떠한 물체를 그리기 위해서는, GPU 를 통해서 그려야만한다. 그때 그릴때, Vertex 의 정보 (Position, Index, Primitive:triangle…) 등을 정의할수 있다. 그리고 Pixel 정보에서 그림을 그릴때, Vertex 의 정보를 가지고 그리기를 시작한다.

How to draw a Triangle

앞에 Post 에서는 원의 방정식을 이용해서 물체를 그렸었다. 하지만, 더 Computer Graphics 를 접한다면 삼각형을 먼저 그려보아야한다. 왜냐하면 바로 모든 물체는 삼각형으로 그려지고 기본이기 때문이다. 그래서 도형에서는 Mesh 가 많으면 많을수록 그 도형이나 물체가 더 정밀하게 보인다.

일단 왜 삼각형이 기본의 도형이냐면 첫째는 점을 이용해서 접혀지지 않는다. 사각형같은 경우 Vertex 가 4 개 이기때문에 양쪽 대각선을 이용해서 삼각형을 만들수 있다.

삼각형을 구조를 한번 짜보자. 일단 아래와 같이 필요한 생성자들이 있고, Ray 의 충돌 여부를 확인 하는 함수가 있다. 물론 모든 물체를 Object 부모 클래스를 만들어서, Hit CheckRayCollision 을 순수 가상함수로 만들어서 관리를 해도된다.

class Traiangle
{

public:
    vec3 v0, v1, v2;

    Triangle()
        : v0(vec3(0.0f)), v1(vec3(0.0f)), vec3(vec3(0.0f))
    {}
    
    Triangle(vec3 v0, vec3 v1, vec3 v2)
        : v0(v0), v1(v1), v2(v2)
    {}

    Hit CheckRayCollision(Ray &ray)
    {}
};

원같은 경우 원의 반지름안에 임의의 Point 가 들어오느냐 안들어오느냐는 생각보다 easy, but Triangle 은 살짝 다르다. 일단 나중에 공부할 Barycentric Coordinate 을 생각하면 편할텐데. 일단 삼각형 안에 들어 왔는지 안들어왔는지는 수식으로 보면 편하다.

예를 들어서 ray 의 식을 P = O + tD 라는 식이있다. 그리고 어떤 Point 가 삼각형의 임의의 Point 에 맞는다고 생각하고, 그 평면 안에 있다고 생각을 하고 세운것이다. 그렇다고 하면 그 Point 같은경우 어떠한 Vertex (v0) 이 존재한다고 했을때, Point 에서 v0 까지 가는 Vector 는 삼각형 평면의 Normal 값과 90 이어야한다. 즉 glm::dot((P - V0), Normal) = 0 이 나와야한다, 그 P 는 Ray 가 쏘는 방향이므로 Ray 의 식을 대입을 해보면 t 의 값을 찾을수 있다. 여기에서 t 는 거리인 scalar 값이다. 그렇다고 한다면, 우리는 식을 고쳐서 t 를 기준으로 세울수 있다.

t = (glm::dot(v0, Normal) - glm::dot(orig, Normal)) / glm::dot(dir, Normal) 여기에서 dir 은 Ray 의 방향 벡터이고, Orig 는 Ray 의 시작 점이다. 이러한 방법으로 t 에 따라서 충돌점 Point 를 찾을수 있다.

바로 코드로 한번 봐보자.

bool IntersectRayTriangle(const vec3 &orig, const vec3 &dir,
const vec3 &v0, const vec3 &v1, const vec3 &v2, vec3 &point, vec3 &faceNormal, float &t, float &u, float &v)
{
    faceNormal = glm::normalize(glm::cross(v1 - v0, v2-v0));
	
    // Backface culling
	if (glm::dot(-dir, faceNormal) < 0.0f) return false;
	
    // 광선과 평면의 Normal 과 수평일경우
    if (glm::abs(glm::dot(dir, faceNormal)) < 1e-2f) return false;

	t = (glm::dot(v0, faceNormal) - glm::dot(orig, faceNormal)) / glm::dot(dir, faceNormal);
	
    // 광선이 뒤에서 충돌했을때? retrun false
    if (t < 0.0f) return false;
	
    point = orig + t * dir; // 충돌점

	// 작은 삼각형들 3개의 normal 계산
	const vec3 normal0 = glm::normalize(glm::cross(point - v2, v1 - v2));
	const vec3 normal1 = glm::normalize(glm::cross(point - v0, v2 - v0));
	const vec3 normal2 = glm::normalize(glm::cross(v1 - v0, point - v0));

	// 아래에서 cross product의 절대값으로 작은 삼각형들의 넓이 계산
	 if (dot(normal0, faceNormal) < 0.0f) return false;
	 if (dot(normal1, faceNormal) < 0.0f) return false;
	 if (dot(normal2, faceNormal) < 0.0f) return false;

	return true;
}

Cross Product 의 성질로 인해서 Normal 값을 계산 할때에 주의할점은 주로 왼손좌표계로 시계방향으로 웬만하면 맞춰줘야하므로, Cross Product 할때 주의하기 바란다. 하지만 어떻게 계산을 하든 시계방향으로 맞춰줘야한다는것만 조심하자.

Resource

Orthographic Projection

Orthographics Projection 은 정투영, 즉 물체를 원근감없이 그리는거다. 어렸을때 집을 그릴때 원근감을 표현하기 위해서 노력했지만 그럴싸하게 안나오는 이유는 바로 원근감을 생각하지 않고 그리기때문이다. 이럴 경우 집의 표면만 그대로 그리는 건데 이게 바로 정투영이다.

그래서 Computer Grpahics 에서 RayTracing 을 구현할때도 마찬가지이다. 어떤 세도형이 있고 물체가 앞에 있는데도 불구하고 그리는 순서 대로 그렸을때가 바로 정투영 현상이다.

예를 들어서, 일단 코드에서는 빨간색을 먼저 그리고, 초록색 그리고, 파란색을 그렸고, 각 구의 Vertex 들의 Z 는 빨간색, 초록색, 파란색순으로 앞순서로 만들었다. [그림을 참고]

Perspective Projection

어찌됬든 정투영 보다는 더 realistic 한 거는 Perspective Projection (원근 투영) 이다. 앞에 정투영 같은 경우, 각 Screen 에 있는 pixel 들이 똑같은 위치에 시작해서 Ray 를 쏘는 반면, Perspective Projection 같은 경우 각 Pixel 들의 Ray 들이 방향이 다다르게 설정된다, 바로 눈의 시점의 Pointer 를 이용해서. 이럴때 주의점은 그 Ray 들을 전부다 Normalize 를 해줘야한다. 그렇다면 쉽게 코드를 작성해보자. 테스트는 위의 사진을 가지고 첫번째로 그릴거다. 여기서 보면, 일단 Render 만 하기때문에 아직은 어떤 물체가 Ray 에 부딫히는 걸 무시하고, 결과를 써봤다.

일단 제일 중요한거 eye position 을 우리 스크린 뒷방향 즉 1.5 f 에서 바라본다고 하고 작성을 했다. Screen 의 각 Ray 들은 screen 에서 우리 눈의 방향 Vector 를 뺀값들이다.

void Render(std::vector<glm::vec4>& pixels)
{
	std::fill(pixels.begin(), pixels.end(), vec4{ 0.0f, 0.0f, 0.0f, 1.0f })
	const vec3 eyePos(0.0f, 0.0f, -1.5f);

#pragma omp parallel for
	for (int j = 0; j < height; j++)
	    for (int i = 0; i < width; i++)
		{
			const vec3 pixelPosWorld = TransformScreenToWorld(vec2(i, j));

			Ray pixelRay{ pixelPosWorld, glm::normalize(pixelPosWorld - eyePos)};

			pixels[i + width * j] = vec4(glm::clamp(traceRay(pixelRay), 0.0f, 1.0f), 1.0f);
		}
}

근데 여기에서 문제가 제대로 그려지는건 맞지만, Red Sphere 가 green sphere 를 감추게 해야한다. 그러면 제대로 동작하지 않았다는걸 볼수 있다. 그리고 모든 물체의 Radius 는 동일하다.

그래서 고쳐야 할부분은 바로 Closest Hit 이 누구인지 확인만 하면 된다.

Hit FindClosestCollision(Ray& ray)
{
	float closestD = 10000.0;
	Hit closestHit = Hit{ -1.0, dvec3(0.0), dvec3(0.0) }
	for (int l = 0; l < objects.size(); l++)
	{
		auto hit = objects[l]->CheckRayCollision(ray)
		if (hit.d >= 0.0f)
		{
			if (hit.d < closestD)
			{
				closestD = hit.d;
				closestHit = hit;
				closestHit.obj = objects[l];
			}
		}
	}	
	return closestHit;
}

Resource

Lighting Effect

일반적으로 물체에 빛을 받아서 반사되어서 나온 색깔을 우리가 보고 “아 저건 빨간색이다, 오 저건 초록색이다” 라고 말을 할수 있을거다. 그래서 Computer Graphics 에서는 물체에 조명에따라서 어떻게 Shaindg 을 하는지 알아보자.

일단 단순한 백색광 Light 를 c++ 로 단순하게 만들어보자면 아래와 같다.

class Light
{
public:
    glm::vec3 pos;
}

이후에 초기화를 할때는

Light light;

light = Light{ {0.0f, 0.0f, -1.0f}} // 화면 뒷쪽

일단 여기에 앞서서 어떤 물체가 어떤 색상을 띄고 있는지, 물체의 표면에 따라 달라질수 있다. 이게 바로 물체의 Material 에 따라 달라진다.

Phong Models & Phong Shading

물체가 조명을 받았을때, Ambient + Diffuse + Specular 이렇게 나누어진다. 왜? 라고 물어본다면, 사실 나도 잘모르겠지만, 어찌됬든 이런 식으로 나눠서 aggregate 하면 뭔가 그럴듯하게 Reflection 이 나온다는 Model 이 Phong Model 이라고 Wiki 에는 나와있다.

그러면 Ambient, Diffuse, Specular 의 어떻게 구하는지, 속성이 뭔지를 알아보자.

Ambient 같은 경우는, 어떤 물체가 색깔을 빛내고 있다고 생각을 하면된다. 즉 이말은 빛이 도달했을때 물체 그대로의 색깔을 return 한다고 생각을 하면된다.

Diffuse 의 원리는 아래의 그림을 참고하면 된다. 만약에 어떤 Light 가 중앙에 물체를 90 degree 로 내려짼다고 했을때, 빛의 세기는 강하다. 하지만 Light Source 가 가 기울어지게 쎄면, 확실히 빛의 세기는 각각 다를것이다. 이럴때 생각을 해보면, 어떤 물체의 Normal 값과 Light Source 의 Opposite direction 의 Vector 의 각도에 따라 빛의 영향이 달라질것이다. 그래서 이 각도를 사용을 하려면 cos(theta) 를 사용하면 된다. [기본적으로 Diffuse 같은 경우는 표면이 거친 상태에서 가정한다. 그래서 난반사 느낌으로 재질을 설정한다.]

Cos Graph 같은 경우 pi / 2 일때 0 이고, 0 일때 1 이다 그걸 생각을 해보면 max(cos(theta), 0.0f) 로 clipping 처리가 가능하다. 물론 여기서 cos(theta) 를 구지 구할필요가 없다. 어떤 경우에 normal vector 와 Light source 의 반대방향의 Vector 가 Unit Vector 일때 glm::dot(n, l) = cos(theta) 를 사용한다면 max(glm::dot(n,l), 0.0f) 의 식으로 변경이 가능하다.

Diffuse 의 구현방식은 생각보다 쉽다.

const vec3 dirToLight = glm::normalize(light.pos - hit.d) // 이미 물체에서 Light 를 바라보는 Vector
const float diff = glm::max(glm::dot(hit.normal, dirToLight), 0.0f);

그래서 diffuse 값의 제곱을 하는게 보이는데, 이거는 Light Power 를 보여주기 위해서 return 을 하게 되면 아래의 사진처럼 나온다.

그 다음 마지막으로 Specular 를 한번 봐보자. 위에서 그림과 같이 Specular 같은 경우, 재질자체가 매끈할때, 금속이나 거울이 완전 반사를 하는 걸 정의한다. 금속이나 거울이 빛을 많이 받았을때, 완전반사가 되어서 우리눈에 부시게 하는 성질을 표현할때 사용된다.

Specular 는 특히나 보는 관점에 따라서 다르다. 즉 눈의 시점에 따라서 아무리 강력한 빛이 오더라도, Light 에 관련된 Power 가 다를 수도 있다.

구현을 해보자면, 아래와 같다

const vec3 reflectDir = 2 * glm::dot(hit.normal, dirToLight) * hit.normal - dirToLight;
const float specular = glm::pow(glm::max(glm::dot(- ray.dir, reflectDir), 0.0f), sphere->alpha);

그래서 전부다 종합하면 아래와 같이 return 을 하면 result 를 볼수 있을거다.

return sphere->amb + sphere->diff * diff + sphere->spec * specular * sphere->ks;

Light Reflection

이제 Light 의 Reflection 을 추가하려면 어떻게 해야될까? 지금까지는 Phong Model 을 사용했더라면, 이제는 Material 에 reflection 을 넣어줘어야한다. 그리고 어떤 물체를 사용할지 모르니 일단 Object Manager 처럼 Object class 에다가 넣어주자.

class Object
{
public:
  vec3 amb = vec3(0.0f);  // Ambient
  vec3 dif = vec3(0.0f);  // Diffuse
  vec3 spec = vec3(0.0f); // Specular

  float alpha = 10.0f;
  float reflection = 0.0f;
  float transparency = 0.0f;

  std::shared_ptr<Texture> ambTexture;
  std::shared_ptr<Texture> difTexture;

  Object(const vec3 &color = {1.0f, 1.0f, 1.0f})
    : amb(color), dif(color), spec(color)
  {}

  virtual Hit CheckRayCollision(Ray &ray) = 0; 
};

이제 어떤 물체를 Spawn 이나 Create 할때, 이 Object class 를 상속받아서 만들면 된다. 그리고 Reflection 과 transparency 가 추가 되어있는 걸 확인 할수 있다. 일단 구현원리는 아래의 노트를 한번 참고 하면서 생각을 해보자.

일단 눈에서부터 나오는 Ray 들을 Screen 좌표에서 각 Pixel 에다가 쏴주고, 그게 어떤 물체에 부딫친다. 만약 그 물체의 Material 특성상 reflected 한 성질을 가지고 있다면, 다시 Reflection Ray 를 다시 쏴준다. 그렇지 않으면, ambient/diffuse/specular 값만 return 을 해준다. 근데 만약 계속 쏘다가 물체와 물체끼리만 reflected ray 를 핑퐁한다고 하면 어떡할까? 이건 어쩔수 없지만 implemenation 에서 recursive level 을 지정해줘서, ray 를 못쓰게 하면 된다. 일단 사용한 모형은 아래와 같다.

아래와 같은 Implementation 을 사용해서, 위의 두물체에 대해서 Reflection 을 적용할수있다. reflection 할 percentage 는 50% 이다.

if (hit.obj->reflection)
{
  const vec3 reflectedDirection = glm::normalize(hit.normal * 2.0f * dot(-ray.dir, hit.normal) + ray.dir);
	Ray ReflectionRay{ hit.point + reflectedDirection * 1e-4f, reflectedDirection };
	color += traceRay(ReflectionRay, recurseLevel - 1) * hit.obj->reflection;
}

이 Core 부분을 적절히 사용을 하면 아래와 같은 결과가 나온다.

Light Refraction

Light Reflection 과 달리 Light Refraction(굴절)은 Material 에 Transparency 를 가지고 있는 물체가 존재 한다는 가정하에 조명이 투과하는 현상이다. 물론 물체가 둘다 존재한다면 일부는 Reflected 하고 어떤부분은 굴절을 할거다. 그게 더 Realistic 한 Object 일거다. 일단 굴절과 관련된 Note 를 참고해보자.

처음에 Ray 가 물체에 부딫힌다고 한다면 그리고 material 에 transparency 가 있다고 가정하면, Ray 가 부딫히는 지점은 두군데이다. 일반적인 Ray Tracing 기법을 사용해서 Ray 를 쏘아줬을때, 제일 먼저 부딫히는 지점을 retrun 을 하지만 굴절을한다고 가정하면, 두번째에 부딫히는 point 가 중요하다. 그리고 Refraction 이 됬을때의 Ray 의 물체의 normal 한 각도와, incoming Ray 의 각도는 과학적으로 증명된 (eta = sin(theta1) / sin(theta2)) 이식이 constant 값을 반환한다. 그렇다고 한다면 우리가 구해야할 vector 는 바로 t 이다. t 위의 노트와 마찬가지로 구할수 있다.

처음에 Implementation 하기전의 모형을 보여주려고 한다. 일단 이 도형의 Material 의 transparency 는 1.0 으로 주어져있다. 그말은 굴절값이 없다는걸 표현 하려고 했었다.

그렇다면 위의 개념으로 코드를 짜보았다.

const float ior = 1.5f; // Index of refraction (유리: 1.5, 물: 1.3)

float eta; // sinTheta1 / sinTheta2
vec3 normal;

if (glm::dot(ray.dir, hit.normal) < 0.0f)// (ex: 공기->유리)
{
	eta = ior;
	normal = hit.normal;
}
else // (ex 유리->공기)
{
	eta = 1.0f / ior;
	normal = -hit.normal;
}
const float cosTheta1 = glm::dot(hit.normal, -ray.dir);
const float sinTheta1 = glm::sqrt(1 - cosTheta1 * cosTheta1);
const float sinTheta2 = sinTheta1 / eta;
const float cosTheta2 = glm::sqrt(1 - sinTheta2 * sinTheta2)
const vec3 m = glm::normalize(dot(hit.normal, -ray.dir) + ray.dir);
const vec3 a = -normal * cosTheta2;
const vec3 b = m * sinTheta2;

const vec3 refractionDir = glm::normalize(a + b);
Ray refractionRay{ hit.point + refractionDir + 0.0001f, refractionDir };
color += traceRay(refractionRay, recurseLevel - 1) * hit.obj->transparency;

return color;

결과는 아래와 같다.

Resource

Prep

Graphics 를 다루기 앞서서, glm 과 imgui 가 필요하다는걸 말씀드리고 싶다. vcpkg 로 설치가 편하니, vcpkg 찾아보기 바란다.

How to draw the Circle in Image Coordinates (2D)

일단 Image Coordinates 에서는 쉽게 far left corner 이 (0, 0) 을가지고 있고, far right corner in bottom 은 (width - 1, height -1) 로 되어있다. 어떠한 Point 가 원안에 있는지 확인을 하려면, 어떠한 Point 와 x_center 값의 절대값이 r 보다 크기 비교를 하면 된다.

일단 Circle 이라는 class 를 만들어보자. 일단 편의성을 위해 접근지정자를 public 으로 해놓고 보면 된다. 그리고 생성자(Constructor)는 원에 필요한 인자로 받는다.

첫번째 방법 같은 경우는 약간 Brute-Force 처럼 곱셈을 할수 있다. 다른 방법같은경우 glm 을 사용해서 point - center 를 뺀값의 distance 를 구하는 방법이 있고, 더 최적화 하는 방법은 radius squared 한값을 가지고 distance squared 를 비교하는 방법이 있다.

#include <glm/glm.hpp>
#include <glm/gtx/string_cast.hpp>
#include <glm/gtx/norm.hpp>

class Circle
{
public:
    glm::vec2 center;
    float radius;
    glm::vec4 color; 

    Circle(const glm::vec2& center, const float radius, const glm::vec4& color)
        : center(center), color(color), radius(radius)
    {}

    bool IsInside(const glm::vec2& point)
    {
        const float distance = (point.x - center.x) * (point.x - center.x) + (point.y - center.y) * (point.y - center.y);
        
        // 최적화 방법은 여러가지
        // const float distance = glm::length(point - center) or 
        
        // const float distanceSquared = glm::dot(point - center, point - center);
        

        if(distance <= radius){
            return true;
        }
        else{
            return false;
        }
    }
}

아래 처럼 결과를 볼수 있다.

How to draw the Circle for Transformation (2D)

가끔씩은 In Game 내부 안에서는 좌표계 변환을 할 필요가 있다. 예를 들어서 Unity 나 Unreal 같은 경우, 내부 안에 따른 카메라 모듈이 있다고 가정하면, Player 가 보는 관점과 그 카메라가 담고 있는 시점이 다르다. 즉 상대적으로 보고 있는게 다르기 때문에 좌표계 변환이 필요하다. 일단 좌표계 변환에 앞서서 aspect ratio 라는 개념이 필요하다. Screen 에서 aspect ratio 를 구할려면, 뿌려질 화면(Screen) 에 width 와 height 로 나눠줘야 한다.

그렇다면 이런식으로 나타낼수 있을거다.

const float aspectRatio = (float)width / height;

그리고 가정이 또 필요한데 Screen 좌표계에서 직접 World Coordinate System 으로 지정해야한다. Custom 하게 제작을 한다면 Screen 좌표계는 [0 x with - 1] x [0 x height -1] 이고, 정의하고자 하는 좌표계를 [-aspectRatio, +aspectRatio] x [1 + -1] 이라고 지정을 하자. 다시말하면 far left corner on the top 의 위치는 [0, 0] 이였던게 [-aspectRatio, 1] 이되고, [width - 1, height - 1] 이 였던게 [+aspectRatio, -1] 이 되는거다.

그렇다면 변환을 하는식을 코드로 표현 해보자. 설명을 해보자면 일단 도형을 그린다고 했을때 좌표계변환은 원하는 도형이 안나올수 있기때문에 여기서 Scaling 을 구하려면 aspectRatio 에 곱해주어야만 원하는 도형이 나올 수 있다(예: 원이라고 하면 타원이 나올수도 있다.) 그리고 positionScreen 값에 scale 을 곱해서 해당의 한칸당 움직임을 알수 있고 이 좌표계를 -aspectRatio 가 제일 맨윗점이 되어야하므로 -aspectRatio 를 하고, y 같은 경우도 마찬가지로 위의 방향이 -1 부터 시작해야하므로 -1 을 빼준다. 하지만 이게 다가 아니다. 이때 y 의 부호값도 뒤집어줘야 위로 갈땐 양수 아래로 갈땐 음수 이렇게 표현하기위해선 전체 부호를 뒤집어 줘야한다.

glm::vec2 TransformScreenToWorld(glm::vec2 positionScreen)
{
    const float aspectRatio = float(width) / height;
    const float xScale = 2 * aspectRatio / (width - 1);
    const float yScale = 2 / (height - 1)

    return glm::vec2(positionScreen.x * xScale - aspectRatio, -(positionScreen.y * yScale -1));
}

How to draw the Sphere

Rendering 기술이나 Brute force 를 사용한 Ray-tracing 을 보면 주로 Sphere 을 찾아 보기가 쉽다. 그 이유는 Sphere 을 그리기가 쉽기 때문이다. 그렇다면 RayTracing 을 한번 봐보자.

  1. 일단 사람의 눈을 기준으로 잡고 Screen, 즉 한 Pixel 에서 광선을 여러개 쏜다.
  2. 그 다음 각 Pixel 에 있는 Ray 중에, 하나가 구에 부딫친다.
  3. 구에 부딫힌 Ray 는, 구의 색깔의 Pixel 을 가져와서 Screen 에 보여진다 (다음 그림)

이런식으로 Simple 한 Ray Tracing 구조를 가져 올수 있다. 그러면 바로 코드로 표현 해보자. 일단, 구체적인 DirectX 에 관련된 부분은 주제와 조금 알맞지 않으므로 작성하지 않았다. 아래에서 중요한 부분은 Ray, Hit, Sphere 안에 있는 IntersectRayCollision 함수 이부분이다. 일단 Ray 같은 경우는 어떤 시작점에서 어떤방향으로 출발한다는 벡타와 그 Point 를 가지고 있어야하고, Hit 같은 경우 distance 정보와 Hit 을 했을때의 point 좌표와 그거에 해당되는 Normal vector 등 필요할것이다. 그리고 현재 위에서 했던것과 달리 World Coordinate 이 [-1, 1] x [1, -1] 로 바뀌었다는 점을 찾을수 있다. 그래서 여기서 중요한 알고리즘은 Rendering 이 Update 이 될때, RayTracing 에서 Render 라는 함수를 호출하고, Render 에서, 각각의 screen 좌표계에 있는걸 좌표계 변환으로 통해서, 바꾼 다음에 Ray 를 쏠 준비를 하는 것이다.

그런다음에 Ray 를 trace 하면서 물체의 거리를 비교하면서, Sphere 에 Hit 이 됬으면, 그 color 값을 가지고 오는것이다.

struct Vertex
{
    glm::vec4 pos;
    glm::vec2 uv;
};

class Ray
{
public:
    glm::vec3 start;    // start position of the ray
    glm::vec3 dir;      // direction
}

class Hit
{
public:
    float d;            // distance from the start to hit point
    glm::vec3 point;    // point where ray hits
    glm::vec3 normal;   // normal vector that are perpendicular to the surface of sphere 
}

class Sphere
{
public:
    // Property
    glm::vec3 center;
    float radius;
    glm::vec3 color;

    // Constructor
    Sphere(const glm::vec3 &center, const float radius, const glm::vec3 &color) 
        : center(center), color(color), radius(radius)
    {}

    Hit IntersectRayCollision(Ray &ray)
    {
        Hit hit = Hit(-1.0f, vec3(0.0f), vec3(0.0f));
        return hit;
    } 
};

class RayTracer
{
public:
    int width, height;
    shared_ptr<Sphere> sphere;

    RayTracer(const int &width, const int &height)
        : width(width), height(height)
    {
        sphere = make_shared<Sphere>(vec3(0.0f, 0.0f, 0.0f), 0.4f, vec3(1.0f, 1.0f, 1.0f));
    }

    glm::vec3 TransformScreenToWorld(glm::vec2 posScreen)
    {
        const float xScale = 2.0f / (this->width - 1);
        const float yScale = 2.0f / (this->height - 1);
        const float aspect = float(this->width) / this->height;

        return glm::vec3((posScreen.x * xScale -1.0f) * aspect, -posScreen.y * yScale + 1.0f, 0.0f);
    }

    vec3 traceRay(Ray &ray)
    {
        const Hit hit = sphere->IntersectRayCollision(ray);

        if(hit.d < 0.0f)
        {
            return vec3(0.0f);
        }
        else
        {
            return sphere->color * hit.d;
        }
    }

    void Render(std::vector<glm::vec4> &pixels)
		{
            // init all black as background color
			std::fill(pixels.begin(), pixels.end(), vec4{0.0f, 0.0f, 0.0f, 1.0f});

// multi-threading
#pragma omp parallel for
			for (int j = 0; j < height; j++)
				for (int i = 0; i < width; i++)
				{
					const vec3 pixelPosWorld = TransformScreenToWorld(vec2(i, j));

					const auto rayDir = vec3(0.0f, 0.0f, 1.0f);

					Ray pixelRay{pixelPosWorld, rayDir};

					pixels[size_t(i + width * j)] = vec4(traceRay(pixelRay), 1.0f);
				}
		}
};

class RayTracingModule
{
public:
    int width, height;
    Raytracer rayteracer;

    // DirectX11 setups..
    ID3D11Device *device;
	ID3D11DeviceContext *deviceContext;
	IDXGISwapChain *swapChain;
	D3D11_VIEWPORT viewport;
	ID3D11RenderTargetView *renderTargetView;
	ID3D11VertexShader *vertexShader;
	ID3D11PixelShader *pixelShader;
	ID3D11InputLayout *layout;

	ID3D11Buffer *vertexBuffer = nullptr;
	ID3D11Buffer *indexBuffer = nullptr;
	ID3D11Texture2D *canvasTexture = nullptr;
	ID3D11ShaderResourceView *canvasTextureView = nullptr;
	ID3D11RenderTargetView *canvasRenderTargetView = nullptr;
	ID3D11SamplerState *colorSampler;
	UINT indexCount;

public:
    RayTracingModule(HWND window, int width, int height)
        : raytracer(width, height)
    {
        Initialize(window, width, height);
    }

    void Update()
    {
        // set pixels as 1D array with color info
        std::vector<glm::vec4> pixels(width * height, glm::vec4(0.8f, 0.8f, 0.8f, 1.0f));

        // Raytracer Render
        raytracer.Render(pixels);

        // Copy CPU Mem -> GPU mem
        // 렌더링 결과를 GPU 메모리로 복사
		D3D11_MAPPED_SUBRESOURCE ms;
		deviceContext->Map(canvasTexture, NULL, D3D11_MAP_WRITE_DISCARD, NULL, &ms);
		memcpy(ms.pData, pixels.data(), pixels.size() * sizeof(glm::vec4));
		deviceContext->Unmap(canvasTexture, NULL); 
    }

    void Initialize(HWND window, int width, int height)
    {
        this->width = width;
        this->height = height;

        // Swapchain... Set

        // Create Render Target

        // Set the view port

        // Create texture and rendertarget

        // Create the sample state

        // vertex buffer
    }

    void Render()
    {
        // ...
    }

    void Clean()
    {
        // ....
    }
};

int main()
{
    const int width = 1280, height = 720;
    // Imgui setup & winAPI setup

    // HWND hwnd...
     
    auto rayTracingModule = std::make_unique<RayTracingModule>(hmwd, width, height);
    
    MSG msg = {};
    while(WM_QUIT != msg.message)
    {
        if(PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
        {
            // ... do Something
        }
        else
        {
            // ... Imgui start setup
            rayTracingModule->Update();
            rayTracingModule->Render()

            // swap the back buffer and the front buffer
            rayTracingModule->swapChain->Present(1, 0)
        }

    }

    // clean up
}

Resource

Introduction to Raytracing Line-sphere Intersection

Continue reading Prep

Shadow Mapping

결국 한마디로 그림자를 어떻게 만들지? 의 문제이다. 현실에서 그림자가 생기는 이유는, 어떠한 빛(Directional Light) 에 의해서 물체에 비추게 되어있을때, 그 물체가 만약에 빛이 통과되지 않는 물체라고 가정하에, 그 뒤에 배경이 빛을 못받아서 그림자가 생기는 이유이다.

Pixel Shader 에서 물체의 색상을 고려할때, 앞에 물체가 있는지 없는지만 판단?

Directional Light 에 카메라를 두고, WorldViewProjMatirx -> ClipPos -> W 로 나누기 -> ProjPos(투영) -> Depth = Proj.Pos.z Depth 를 RenderTarget 에 저장.

그럼 우리가 화면에서 보여지는거는, 우리가 현재 보고 있는 카메라를 기준으로 ViewPos -> ViewInverseMatrix -> WorldPos 으로 간다음에, Directional Light 에 있는 카메라로 가서 ViewPojMatrix 를 보고 있는 화면 카메라의 WorldPos 에 곱해주면 -> ClipPos 로 넘어오고 -> w 로 나누면 -> ProjPos(투영) 좌표계로 넘어오면 [-1, 1] -> [0 ~ 1], [1 ~ -1] -> [0 ~ 1] -> 이 결과는 UV 좌표계로 넘어오면 Depth 정보를 받아서 비교 후에 그림자 적용을 할수 있다.

Render Target View

Render Target

  • Forward Shader

Rendering Pipeline 물체의 Index 와 Vertex 정보(Topology) 정보를 input assembler 에게 넘겨준다. 그런다음에 이 정점들을 가지고 어떤일을 해야되는지 기술한 다음에 Vertex Shader 와 Pixel Shader 를 통해서 처리를 했다. Output merger 에서 Render Target View 에서 결과를 받아서 뿌려졌었다.

그래서 RTV 를 처리하기 위해서 Command Queue 에서, 어떤 Buffer 에다가 render 를 할지 결정을 했었다.

D3D12_CPU_DESCRIPTOR_HANDLE backBufferView = _swapChain->GetBackRTV();
_cmdList->ClearRenderTargetView(backBufferView, Colors::Black, 0, nullptr);

D3D12_CPU_DESCRIPTOR_HANDLE depthStencilView = GEngine->GetDepthStencilBuffer()->GetDSVCCpuHandle();
_cmdList->OMSetRenderTargets(1, &backBufferView, FALSE, &depthStencilView);
// --> 즉 여기서 OMSetRenderTarget 이 어디에다가 뿌려줄것인가 였다.

_cmdList->ClearDepthStencilView(depthStencilView, D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr);

그래서 실질적으로 그려지는 부분은 Shader 쪽에서 위에서 작성된 코드와 맞물려서 실행된다.

float4 PS_MAIN(VS_OUT input) : SV_Target
{
    float4 color = float4(1.f, 1.f, 1.f, 1.f);
    if(g_text_on_0)
	color = g_text_0.Sample(g_same_0, input.uv);
    float3 viewNormal = input.viewNormal;
    if(g_text_on_1)
    
    LightColor totalColor = (LightColor)0.f;
    
    for (int i = 0; i < g_lightCount; ++i)
    
    color.xyz = (totalColor.diffuse.xyz * color.xyz) + totalColor.ambient.xyz * color.xyz + totalColor.specular.xyz;

    return color;
}

이 방식에서 아쉬운 (중요한) 점은, 우리가 열심히 중간 부품을 만들어서 사용하는데, 그게 다 날라간다는것이다. 즉 빛 연산이나, Normal 값들을 사용하지 않은채, Color 로만 return 을 하니까 중간들 계산값들이 날아가게(손실) 된다는것이다.

Pagination