mesh 표면에 색상을 입히는 방법에 대해 배워볼 것이다.
0. 라이팅 계산
1. shading 종류-부드러운 shading, 평평한 shading
2. 월드 공간에서 뒤섞기(Swizzle) 연산자 적용
3. 노말 행렬(normal matrix)
4. 라이팅 계산과 노말
5. 내적을 사용한 shading
0. 라이팅 계산
0.1. 이전에 사용한 색상 입히는 방법
= texture을 입히는 방법 사용 불가. (texture이 없기 때문)
= mesh의 UV는 자연스럽지 않음. (quad처럼 연속적이며 점진적으로 변하지 않기 때문)
0.2. 라이팅 계산
= 노말 벡터(normal vector)라는 새로운 vertex 속성에 의존한다.
(normal vector : mesh 표면과 직각으로 교차하면서 mesh 밖을 향하는 벡터.
해당 벡터는 각 fragment가 속하는 face의 형태 정보를 제공한다.)
= vertex shader에서 fragment shader로 넘어가는 과정에서 보간된다.
=> 어떤 fragment의 normal vector : 그 fragment가 속한 face를 구성하는 세 vertex의 normal vector 보간 결과
0.3. normal vector를 사용한 색상 데이터 받아오기
이전에 사용한 torus.ply를 사용하여 uv가 아닌 normal vector로 데이터를 받아온 결과를 확인할 것이다.
0.3.1. vertex shader 수정
= mesh.vert로 이름을 수정한다.
= normal vector 관련 정보를 보낼 수 있도록 수정한다.
mesh.vert
#version 410
layout (location=0) in vec3 pos;
layout (location=2) in vec3 nrm;
uniform mat4 mvp;
out vec3 fragNrm;
void main()
{
gl_Position=mvp*vec4(pos, 1.0);
fragNrm=nrm;
}
= normal vector는 location 2에 저장한다.
(해당 순서는 openframeworks가 정한 규칙이다.)
0.3.2. fragment shader 수정
= vertex shader로부터 전달된 벡터를 정규화한다.
(벡터의 정규화가 모든 컴포넌트를 양수로 만들지는 않음
normal_vis.frag
#version 410
uniform vec3 lightDir;
uniform vec3 lightCol;
in vec3 fragNrm;
out vec4 outCol;
void main()
{
vec3 normal = normalize(fragNrm);
outCol = vec4(normal, 1.0);
}
= 대부분의 normal vector은 벡터가 정규화된 상태를 전제로 하므로, vertex shader에서 보간된 벡터는 크기가 보존되지 않을 수 있으므로 다시 정규화를 해야 한다.
0.3.3. draw() 수정
= setup()에서 shader명을 수정하고, 이전의 perspective projection 코드와 유사하게 draw()를 작성한다.
ofApp.cpp
void ofApp::draw(){
using namespace glm;
cam.pos = vec3(0, 0, 1);
cam.fov = radians(90.0f);
float aspect = 1024.0f / 768.0f;
mat4 model = rotate(1.0f, vec3(1, 1, 1)) * scale(vec3(0.5, 0.5, 0.5));
mat4 view = inverse(translate(cam.pos));
mat4 proj = perspective(cam.fov, aspect, 0.01f, 10.0f);
mat4 mvp = proj * view * model;
uvShader.begin();
uvShader.setUniformMatrix4f("mvp", mvp);
torusMesh.draw();
uvShader.end();
}
결과는 아래와 같다.

= 모든 벡터를 정규화했지만 검정색 영역 존재
(검정색 fragment에 해당하는 normal vector 컴포넌트가 모두 음수. 음수에 상응하는 색상은 없기 때문.)
1. shading 종류-부드러운 shading, 평평한 shading
1.1. 부드러운 shading
= 곡면을 만드는 페이스에 적용 : normal vector 보간으로 점진적으로 부드럽게 변하는 색상 구현 가능.
(인접한 fragment와 조금씩 다른 normal vector를 갖기 때문.)
= 평평한 오브젝트(ex: 육면체)에는 부적합.(모서리에서 색이 부드럽게 보여서 부자연스럽기 때문.)
= 곡면 shading에 적합.
1.2. 평평한 shading
= 평평한 페이스에 속한 모든 fragment가 동일한 normal vector 소유하도록 설정.
= 각 페이스가 자신이 가진 vertex 개수만큼 자신만의 vertex 세트를 갖고, normal vector는 자신이 속한 페이스 방향과 같아야 한다.
++ 실제 개발에서 사용할 shading 종류는 주로 아티스트가 결정해서 원리만 이해해도 좋음.
2. 월드 공간에서 뒤섞기(Swizzle) 연산자 적용
= 오브젝트 공간에서 fragment normal vector이 아닌, 보통 월드 공간에서 fragment normal vector가 구현되도록 수정할 것이다.
=> 씬에 존재하는 라이트 위치 정보들 다룰 때 편하기 때문.
2.1. 모델 행렬로 벡터 월드 공간으로 변환.
= 오브젝트 공간에서 월드 공간으로 변환한다.
mesh.vert
#version 410
layout (location = 0) in vec3 pos;
layout (location = 2) in vec3 nrm;
uniform mat4 mvp;
uniform mat4 model;
out vec3 fragNrm;
void main(){
gl_Position = mvp * vec4(pos, 1.0);
fragNrm = (model * vec4(nrm, 0.0)).xyz;
}
= .xyz 연산자로 vec3 타입의 fragNrm에 적용
=> 한 번에 1개 벡터 컴포넌트에 접근하는 것이 아닌, 최대 4개의 컴포넌트를 자유롭게 조합 가능.
(GLSL의 고유 문법인 뒤섞기(swizzle))
++ Swizzle 예시
vec2 uv = vec2(1,2);
vec2 a = uv.xy; // (1,2)
vec2 b = uv.xx; // (1,1)
vec3 c = uv.xyx; // (1,2,1)
vec4 color = vec4(1,2,3,4);
vec2 rg = color.rg; // (1,2)
vec3 allBlue = color.bbb; // (3,3,3)
vec4 reversed = color.wzyx; // (4,3,2,1)
++ Swizzle이 필요한 이유?
= 변환 행렬의 타입은 vec4인데, fragNrm(fragment에서 넘겨받는 normal vector)는 vec3이어서 vec4에서 vec3으로 저장할 컴포넌트를 임의로 정해야 하기 때문.
3. 노말 행렬(normal matrix)
3.1. 목적
= 월드 공간으로 변환할 때 기존 연산으로 normal이 바라보는 방향을 유지할 수 없는 경우가 발생하기 때문.
(모델 행렬로 mesh의 크기 변환 비율이 모든 축에 동일하게 적용되지 않을 때 발생.)
3.2. 정의
= normal vector를 월드 공간으로 변환시 모델 행렬 대신 사용하는 행렬.
= 모델 행렬의 상단 3 * 3의 역행렬의 전치(transpose) 행렬
3.3. 생성 방법
mat3 normalMatrix = (transpose(inverse(mat3(model))));
= draw()에 추가한다.
= mat3을 사용함으로써 더 큰 행렬 우측 3*3으로 이루어진 새로운 행렬을 생성.
=> 모델 행렬에서 회전과 크기 데이터를 유지하면서 이동 정보를 뺀 행렬을 생성하는 가장 빠른 방법.
= 해당 행렬을 적용한 mesh.vert
#version 410
layout (location = 0) in vec3 pos;
layout (location = 2) in vec3 nrm;
uniform mat4 mvp;
uniform mat3 normal;
out vec3 fragNrm;
void main(){
gl_Position = mvp * vec4(pos, 1.0);
fragNrm = (normal * nrm).xyz;
}
4. 라이팅 계산과 노말(normal)
위까지 normal vector의 의미, shader에서 normalize를 적용하는 방법을 배웠다.
여기서는 mesh에 라이팅을 더해볼 것이다.
4.1. normal
= 라이팅 계산에서 핵심적인 요소.
(mesh 표면에 도잘하는 라이트의 방향과 표면 자체의 방향 간의 관계를 파악하기 위해서는 normal이 필수적이기 때문.)
= mesh 형태에 상관없이 mesh fragment를 작은 점으로 상상한 경우, 들어오는 라이트가 normal vector과 가까울수록 많은 라이트가 점에 도달한다.
=> normal vector과 들어오는 라이트의 벡터 각도가 180도에 가까울수록, 점이 받게 되는 라이트 양은 증가한다.
= 두 벡터 사이의 각도를 구하기 위해 벡터의 내적을 사용한다.
4.2. 벡터의 내적(dot product)
= 두 벡터 간의 관계를 하나의 숫자로 나타내는 연산.
= 각 벡터의 컴포넌트를 서로 곱하고, 결과를 모두 더한다.(같은 컴포넌트끼리 곱한다.)
= 주로 두 벡터 간의 정확한 각도를 구하기 위해 사용한다.
<내적 구현>
float dot(vec3 a, vec3 b)
{
float x = a.x * b.x;
float y = a.y * b.y;
float z = a.z * b.z;
return x + y + z;
}
= 내적의 반환값의 따른 결과는 아래와 같다.
내적 값 = 0 : 두 벡터가 직각으로 교차.
내적 값 > 0 : 두 벡터의 각도가 90도보다 작다.
내적 값 < 0 : 두 벡터의 각도가 90도보다 크다.
<내적을 사용한 두 벡터 간 각도 구하기>
float angleBetween(vec3 a, vec3 b){
float d = dot(a, b);
float len = length(a) * length(b);
float cosAngle = d / len;
float angle = acos(cosAngle);
return angle;
}
=> shader에서 벡터는 이미 정규화된 상태이기 때문에 len, cosAngle을 구할 필요 없이 두 벡터간 각도 및 코사인값을 구할 수 있다.
=> 추후 라이팅 계산에서 사용된다.
++ fragment normal vector 방향: mesh 표면->밖, 들어오는 라이트 벡터 : 밖->mesh 표면
-> 해당 코드를 보면 오브젝트에서 라이트를 마주하는 영역이 더 어두워보인다.
-> cpp에서 라이트 방향을 반대로 저장.
++ 내적 결과가 음수인 경우, 음수인 라이트 색상이 나올 수 있다.
(이 경우, 라이트가 2개 이상인 경우 mesh의 특정 부분이 필요 이상으로 어두워진다.)
=> 내적 결과 최소값 0으로 제한.
float lightAmt = max(0.0, dot(normal, lightDir));
5. 내적을 사용한 shading
= 구현할 라이팅 유형은 디퓨즈(diffuse) / 램버트(lambertian) 라이팅이다.
5.1. 디퓨즈 라이팅
= 라이트가 거친 물체의 표면에 도달했을 때의 상황 구현.
= 물체에 도달한 빛이 모든 방향으로 흩어지고, 광택이 없는 표면을 가진다. (거울과 대조된다.)
5.1.1. 구현
= 특정 fragment의 normal, 그곳으로 들어오는 라이팅의 방향을 비교한다.
= 두 벡터 간 각도 차에 따라 내적값이 달라진다.
=> normal vector, 라이트 간 내적을 사용하면 각 fragment shading시 받는 라이트 정도를 결정한다.
5.1.2. fragment shader 구현
아래와 같이 diffusion을 구현한다.
diffuse.frag
#version 410
uniform vec3 lightDir;
uniform vec3 lightCol;
uniform vec3 meshCol;
in vec3 fragNrm;
out vec4 outCol;
void main()
{
vec3 normal = normalize(fragNrm);
float lightAmt = dot(normal, lightDir);
vec3 fragLight = lightCol * lightAmt;
outCol = vec4(meshCol * fragLight, 1.0);
}
= GLSL의 dot()를 사용해 벡터의 내적을 구한다.
= fragment가 얼마나 많은 라이트를 받는지 결정하기 위해 들어오는 light 색을 내적의 값으로 곱한다.
(내적 = 1 : fragment가 라이트의 영향을 최대로 받는다.
내적 = 0. fragment가 라이트의 영향을 받지 않는다. (두 벡터의 각도가 90도 이상))
= fragment 색상 * (내적 * 라이트 색상) => 최종적 fragment 색상을 얻는다.
여기까지, normal vector, normal matrix에 대해 알아보고 shader에 적용하는 방법을 배웠다. 다음 게시글에서는 라이트 데이터를 설정해 디퓨즈 라이팅이 적용된 결과를 확인하고, 림 라이트 효과에 대해서도 학습할 것이다.