Skip to content

10. 3D 셰이딩 기법


목차

  • 셰이딩이란?

  • 코드 준비하기

  • 정점 셰이더 수정하기 - normal_matrix

  • 셰이딩 방식 - 고러드, 플랫, 퐁 셰이딩 (glsl flat keyword)

  • 라이팅 모델 - 램버트 / 하프 램버트 (난반사)

  • 라이팅 모델 - 퐁 (램버트+정반사) / 블린-퐁 (half벡터)

  • 라이팅 모델 - 툰 / 구치

셰이딩이란?

게임이던, 시뮬레이션이던 간에

3차원 그래픽스에서 물체가

완전한 단색으로 그려진다면

너무 부자연스러워지기 마련입니다.

현실 세계에서는 '빛' 이 있어서,

어떤 물체의

빛이 많이 닿는 부분은 밝게,

빛이 닿지 못하는 부분은 어둡게 나타나죠.

그러한 사실적인 효과를 주기 위한 모든 그래픽스 기법들을

셰이딩 (Shading) 이라고 합니다.


그러한 빛을 표현하기 위한 방법을

라이팅 모델이라고 하는데,

가장 많이 쓰이는 퐁 라이팅 모델의 경우

  • Ambient Light (주변광)
  • Diffuse Light (난반사광)
  • Specular Light (정반사광)

이 세 종류의, 빛의 영향을 모두 합쳐서 표현하게 됩니다.

10_0.png
출처 : Wikipedia (CC BY-SA 3.0)

주변(Ambient) 광이란?

주변에서 전체적으로 퍼져 나와, 여러번 반사되어 물체에 비춘 빛입니다.

물체의 컬러 기본값, 시작값이라고 보면 됩니다.

난반사(Diffuse) 광이란?

물체 표면의 미세한 굴곡에 빛이 닿을 때, 여러 방향으로 흩어지는 반사광입니다.

위 사진을 보면, 난반사로 인해 명암이 표현되는 걸 알 수 있습니다.

정반사(Specular) 광이란?

빛이 물체에 비칠 때, 입사각과 반사각이 거의 같아지는 부분에 대한 반사광입니다.

위 사진과 같이, 강한 하이라이팅을 담당하는 부분입니다.


중요한 것은,

'물체 표면의 법선 벡터' 와

'물체에서 빛을 향하는 벡터' 가

'유사하면 유사할수록'

빛이 더 세게 비춘다는 것입니다.


이번 글에서는

3개의 셰이딩 방식과

여러 라이팅 모델들을 하나하나 구현해봅시다.

코드 준비하기

먼저, 추후에 쓸 전역 변수를 선언하고

GLFW의 키보드 콜백을 연결합시다.

Camera3D cam3d{};
// 현재 셰이딩 타입
int shading_type = 0;
// 스페이스 키를 누르면 셰이딩타입을 바꿀 수 있게.
static void on_key(GLFWwindow* window, int key, int scancode, int action, int mods) {
    if (key == GLFW_KEY_SPACE && action == GLFW_PRESS) {
        if (shading_type == 5) {
            shading_type = 0;
        }
        else {
            shading_type++;
        }
    }
}


    ...

    glfwSetCursorPosCallback(window, on_cursor);
    // 키보드 콜백 연결
    glfwSetKeyCallback(window, on_key);
    glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
    if (glfwRawMouseMotionSupported()) {
        glfwSetInputMode(window, GLFW_RAW_MOUSE_MOTION, GLFW_TRUE);
    }

이제 정점 버퍼 / 인덱스 버퍼를 완전 새로 만들어줍시다.

물론 전에 계속 쓰던 큐브에 셰이딩을 넣어도 좋지만,

구에다가 빛을 비추면 더 멋지기 때문에

큐브 대신 구를 생성해보겠습니다.

이전의 정점 / 인덱스 버퍼 생성 코드를

다음과 같이 수정해주세요.

    // 위도 / 경도 / 반지름 상수 정의해놓기
    float PI = 3.14159265358979323846f;
    uint32_t stacks = 32;
    uint32_t slices = 64;
    float radius = 0.5f;

    // position + normal + uv
    BufferData vertices_data{}; {
        for (uint32_t stack = 0; stack <= stacks; stack++) {
            float v = static_cast<float>(stack) / stacks;
            float phi = v * PI;
            float y = cosf(phi);
            float r = sinf(phi);
            for (uint32_t slice = 0; slice <= slices; slice++) {
                float u = static_cast<float>(slice) / slices;
                float theta = u * PI * 2.0f;
                float x = r * cosf(theta);
                float z = r * sinf(theta);
                // Position
                vertices_data.add_vec3({
                    x * radius,
                    y * radius,
                    z * radius
                    });
                // Normal
                vertices_data.add_vec3({
                    x,
                    y,
                    z
                    });
                // UV
                vertices_data.add_vec2({
                    u,
                    1.0f - v
                    });
            }
        }
    }
    // Index (CCW)
    BufferData indices_data{}; {
        const uint32_t stride = slices + 1;
        for (uint32_t stack = 0; stack < stacks; stack++) {
            for (uint32_t slice = 0; slice < slices; slice++) {
                uint32_t a = stack * stride + slice;
                uint32_t b = a + stride;
                uint32_t c = a + 1;
                uint32_t d = b + 1;
                indices_data.add_uint32(a);
                indices_data.add_uint32(c);
                indices_data.add_uint32(b);
                indices_data.add_uint32(c);
                indices_data.add_uint32(d);
                indices_data.add_uint32(b);
            }
        }
    }

위의 알고리즘은 한 축을 기준으로 회전하면서

일정한 반지름의 구 정점을 생성하고 잇는 코드입니다.

(깊게 이해하실 필요는 없습니다)

여기서 중요한 건,

저희가 지금까지 Position + UV 조합으로만

하나의 정점을 정의했었죠?

이제 속성 1개를 추가할 차례입니다.

바로 법선(Normal) 정보이죠.

추후에도 설명하겠지만, 셰이딩을 구현하려면

'이 삼각형이 어느 방향을 바라보고 있는가' 를

반드시 알아야 합니다.

그것을 vec3 형으로 나타낸 것이 Normal 성분입니다.

정점 배열도 다음처럼 수정해주세요.

    VertexArray va{};
    Buffer vb{}, eb{};

    vb.upload_data(vertices_data, BufferUsage::StaticDraw);
    eb.upload_data(indices_data, BufferUsage::StaticDraw);

    VertexLayout va_layout{}; {
        va_layout.push_attrib(DataType::Float, 3); // Pos
        va_layout.push_attrib(DataType::Float, 3); // Normal 추가
        va_layout.push_attrib(DataType::Float, 2); // UV
    }

    va.bind_vertex_buffer(0, &vb, 0, va_layout);
    va.bind_element_buffer(&eb);

다음으로 셰이더 등록과 텍스쳐 로딩입니다.

셰이더는 그대로 써 주시고, 텍스쳐 부분의 경로는

가지고 계신 이미지 경로로 수정해 주세요.

저는 인터넷에 있는 펼친 달 사진을 사용했습니다.

ShaderProgram program{};
program.attach_shader(ShaderType::Vertex, s_vertex_shader_src);
program.attach_shader(ShaderType::Fragment, s_fragment_shader_src);
program.link();
program.register_uniform("uSize", UniformType::Vec3f);
program.register_uniform("uProj", UniformType::Mat4f);
program.register_uniform("uView", UniformType::Mat4f);
program.register_uniform("uWorld", UniformType::Mat4f);
program.register_uniform("uTexture", UniformType::Sampler2D);
program.register_uniform("uShadingType", UniformType::Int);
program.register_uniform("uCamPos", UniformType::Vec3f);
program.register_uniform("uLightPos", UniformType::Vec3f);
    Texture2D tex{};
    {
        TextureDesc desc{ };
        int w{}, h{}, c{};
        stbi_set_flip_vertically_on_load(true);
        auto* data = stbi_load("b.png", &w, &h, &c, 0);
        assert(data != nullptr);
        assert(c == 4);

        tex.initialize_by_bytes({ w, h }, data, desc);

        stbi_image_free(data);
    }

다음으로, 셰이딩을 하려면 빛이 필요하겠죠?

현재 빛의 위치를 선언하고,

루프 안에서 회전하게 만들어주세요.

    cam3d.set_fov(glm::radians(60.0f));
    cam3d.set_position({ 0.0f, 0.0f, 100.0f });

    Transform3D world_mat{};
    world_mat.set_position({ 0.0f, 0.0f, 0.0f });

    // 빛 위치
    glm::vec3 light_pos{0.0f, 0.0f, 50.0f};
        {
            light_pos = { 50.0f * std::cos(float(glfwGetTime())), 0.0f, 50.0f * std::sin(float(glfwGetTime())) };
        }

마지막으로 렌더링 부분입니다.

섀이더에 uCamPos, uShadingType, uLightPos 를 넘겨주는 것에 유의하시고,

glDrawElements 도 고정값이 아닌 버퍼 크기로 계산해서 넘겨주세요.

            va.bind();
            program.use();

            program.upload_uniform_vec3f("uSize", { 30.0f, 30.0f, 30.0f });
            program.upload_uniform_mat4f("uProj", cam3d.get_projection_matrix().get_matrix());
            program.upload_uniform_mat4f("uView", cam3d.get_view_matrix().get_matrix());
            program.upload_uniform_mat4f("uWorld", world_mat.get_matrix());
            tex.bind_unit(0);
            program.upload_uniform_sampler2d("uTexture", 0);

            program.upload_uniform_vec3f("uCamPos", cam3d.get_position());
            program.upload_uniform_int("uShadingType", shading_type);
            program.upload_uniform_vec3f("uLightPos", light_pos);

            glDrawElements(GL_TRIANGLES, indices_data.get_length() / sizeof(uint32_t), GL_UNSIGNED_INT, 0);

사실 이 글의 핵심은 CPP 코드가 아닌 셰이더이기 때문에

밑에서 셰이더를 하나씩 수정해가도록 하겠습니다.

정점 셰이더 수정하기 - normal_matrix

가장 먼저, 정점 셰이더를 다음과 같이 수정해주세요.

#version 460 core

//     이제 3차원 정점이므로 vec3으로 받아야 합니다.
layout (location = 0) in vec3 aPos; 
layout (location = 1) in vec3 aNormal; 
layout (location = 2) in vec2 aTexCoord; 

layout (location = 0) uniform vec3 uSize = vec3(64, 64, 64);

layout (location = 1) uniform mat4 uProj;
layout (location = 2) uniform mat4 uView;
layout (location = 3) uniform mat4 uWorld;

layout (location = 0) out vec2 TexCoord;
layout (location = 1) out vec3 Normal;
layout (location = 2) out vec3 WorldPos;

void main() {
    vec3 pos = (aPos * uSize);

    gl_Position = uProj * uView * uWorld * vec4(pos, 1);

    WorldPos = vec3(uWorld * vec4(pos, 1));

    mat3 normal_matrix = transpose(inverse(mat3(uWorld)));
    Normal = normalize(normal_matrix * aNormal);

    TexCoord = aTexCoord;
}

정점 속성에 Normal이 추가되고,

출력으로 월드 공간 정점과

월드 공간 Normal 을 전달하게 됩니다.

위 정점 셰이더에서 눈여겨 볼 것은

입력, 즉 aNormal 이 어떻게

Normal 출력값으로 나가는지 입니다.

이전에 aPos 에 uWorld 를 곱해서

모델 공간 -> 월드 공간으로 변환했었죠?

그것과 마찬가지로,

삼각형의 법선을 나타내는 Normal 에도 월드 행렬을 곱해줘야 합니다.

이렇게 말이죠.

월드 행렬을 3x3 행렬로 만들어서 translation 정보를 제거해 곱합니다.

Normal = normalize(mat3(uWorld) * aNormal);

그러나, 이렇게 곱해주면 문제가 생깁니다.

저 mat3(uWorld) 안에는 scaling 정보가 있기 때문이죠.

먼저 이상적인 예를 들어봅시다.

XZ 평면에 삼각형 면이 하나 있고,

이에 대한 Normal 값이

(0, 1, 0) 이라고 해 보죠.

scaling 값을 (2, 3, 4) 로 주면

VS에서 출력되는 월드 공간의 법선은

(0 * 2, 1 * 3, 0 * 4) = (0, 3, 0)이고, 정규화되면

(0, 1, 0) 으로 올바릅니다.


이제, 문제가 되는 기울어진 삼각형을 생각해보죠.

다음 3개의 정점으로 이뤄진 삼각형입니다.

(0, 0, 0)

(1, 0, 1)

(0, 1, 1)

이 삼각형의 법선을 구하려면, 삼각형의 변이 이루는

벡터 2개를 외적해주면 됩니다.

Cross(Vector3f(1, 0, 1), Vector3f(0, 1, 1)) 의 결과는

Model_N = (-1, -1, 1) 이네요.

이제 이 삼각형에 scaling = (2, 1, 1) 을 적용해 봅시다.

적용한 정점 3개는?

(0, 0, 0)

(2, 0, 1)

(0, 1, 1)

이고, 법선은

World_N = Cross(Vector3f(2, 0, 1), Vector3f(0, 1, 1)) = (-1, -2, 2) 가 되네요.

이때 mat3(uWorld) 을 그대로 곱한 경우

(=Model_N에다가 scaling을 그대로 적용)

(-1 * 2, -1 * 1, 1 * 1) = (-2, -1, 1)

이렇게 올바르지 않은 법선이 나오게 됩니다.


아무튼, 이 문제를 해결하기 위해서는

이러한 코드가 필요합니다.

    mat3 normal_matrix = transpose(inverse(mat3(uWorld)));
    Normal = normalize(normal_matrix * aNormal);

uWorld 를 3x3 행렬로 만들고,

그것의 역행렬을 전치해주는 겁니다.

(inverse - transpose 라고도 불리는 이 기법은,

transpose - inverse 순서로도 가능합니다)

유도는 밑을 참고해주세요.

1. 삼각형은 3개의 정점으로 분해되는데, 각 정점을 '(0, 0, 0)에서 시작하는 벡터' 로 해석해 보자.
   법선이 N이고, 월드 행렬이 W이면, 

   각 벡터 V에 대해 W_V = W * V 이다. 

   V와 N은 항상 수직이므로 Dot(V, N) = 0 이고,
   이는 월드 공간에서도 적용된다. Dot(W_V, W_N) = 0이다.

2. W_N 에 대해서,
    W_N = transpose(inverse(W)) * N 이라고 가정하면

    Dot(W_N, W_V) = transpose(transpose(inverse(W)) * N) * W * V
                  = transpose(N) * inverse(W) * W * V
                  = transpose(N) * V
                  = 0 
    이므로

    W_N = transpose(inverse(W)) * N 은 성립.

셰이딩 방식 - 고러드, 플랫, 퐁 셰이딩

컴퓨터 그래픽스의 관점에서,

셰이딩은

  • 어떤 방식을 사용하는가?
  • 어떤 라이팅 모델을 사용하는가?

이 두 가지로 정의할 수 있습니다.

여기서 '방식' 이라는 단어가 의미하는 것은,

'어느 시점에서 빛을 계산하는가'

입니다.

시점이라고 하면 감이 잘 오지 않으실 텐데요.

혹시

'컴퓨터 그래픽에서, 모든 것은 삼각형으로 이루어진다'

라고 했던 게 기억나시나요?

OpenGL 은

사용자가 정점을 제공해 주면

사용자가 지정한 프리미티브 타입 (여기서는 GL_TRIANGLES 라고 가정합니다) 에 따라서

정점을 이어 하나의 프리미티브를 만듦니다.

이후 해당 프리미티브는 화면에 그려진다고 판단이 나면

'여러 픽셀의 조합' 으로 나타난다고

이전 글에서 다루었습니다.

'빛을 계산하는 시점' 은,

  • 삼각형의 각 '정점'에 대해
  • 삼각형의 각 '면'에 대해
  • 삼각형의 각 '픽셀'에 대해

이렇게 3개로 나뉩니다.

그리고 - 예상하셨듯이,

저 3개가 각각

  • 고러드 셰이딩
  • 플랫 셰이딩
  • 퐁 셰이딩

을 나타냅니다.

어떤 교재에서는 더 직관적인 이해를 위해서

저 세 방식을

  • Per-Vertex
  • Per-Face
  • Per-Pixel

로 설명하기도 합니다.

현대의 게임 엔진에서는

특수한 목적(로우폴리 등) 이 있지 않는 이상

Per-Pixel, 즉 프래그먼트 셰이더에서 빛을 계산하는

퐁 셰이딩을 사용합니다.

이제 저 3개를 각각 소개해보겠습니다.

고러드 셰이딩

삼각형의 3개의 3차원 정점들에 대해서,

(= 정점 셰이더에서)

각 점에 빛이 얼마나 영향을 주는지

3번 계산하고, 이를 삼각형 면 단에서

섞어(=보간)주는 방식입니다.

정점 셰이더에서 빛을 계산하고,

이를 프래그먼트 셰이더에 넘기면

OpenGL이 자동으로 보간해서 빛을 주게 되죠.

플랫 셰이딩보다는 훨씬 사실적이지만,

3D 모델이 복잡하지 않다면 어색한 부분이 많이 생깁니다.

플랫 셰이딩

삼각형의 한 면에 대해서

빛이 이 한 면에 얼마나 영향을 끼치는지 계산하는 방법입니다.

이 방식을 쓰면,

인디 게임에서 자주 보이는

'로우 폴리 스타일' 형식의 3D 게임을 만들 수 있습니다.

면 1개당 색상 1개로 계산되다 보니,

계산량이 매우 줄어들지만

사실적인 물체와는 꽤 거리감이 생기죠.

이 셰이딩을 사용하려면, GLSL의 키워드인

flat 을 사용해야 합니다.

밑에서 자세히 설명하겠습니다.

퐁 셰이딩

이미 2차원 픽셀이 되어버린 상태에서,

(= 프래그먼트 셰이더에서)

현재 픽셀의 3차원 정보(위치, 법선 등)를 정점 셰이더에서 받아

'이 빛이 이 픽셀에 얼마나 영향을 끼치는지' 계산하는 방법입니다.

모든 픽셀에 대해서 빛을 계산하다보니

위의 두 셰이딩에 비해서

훨씬 정밀한 빛을 계산할 수 있지만 -

당연히도 계산량은 기하급수적으로 늘어납니다.

Note

재미있는 점은,
2020년대 현재에는 모든 게임 엔진에서
퐁 셰이딩을 당연하게 사용하고 있지만 -
불과 20년 전만 하더라도
퐁 셰이딩은 엄청난 계산량으로 인해 채택되지 못했습니다.
고전 게임들을 보면 조잡한 폴리곤으로, 고러드 셰이딩을 통한
어색한 명암을 구현하는데 -
이것이 그때 당시 PC 성능의 한계였습니다.
2000년대에 나온 'Real-Time Rendering' 이라는
GPU 관련 서적을 읽어보면
퐁 셰이딩에 대해서 '아직까지 상용화되기 어려운 기술' 이라고 언급됩니다.

참고로, 밑에서 언급할 '퐁 라이팅 모델' 과는 다른 의미로 쓰이는 용어입니다.


이제 밑에서 설명할 모든 라이팅 모델들은

퐁 셰이딩 방식을 사용하여

프래그먼트 셰이더에서 실습해보겠습니다.

라이팅 모델 - 램버트 / 하프 램버트 (난반사)

우선 프래그먼트 셰이더를 수정해주세요.

#version 460 core

layout (location = 0) out vec4 FragColor;

layout (location = 0) in vec2 TexCoord;
layout (location = 1) in vec3 Normal; // World Space
layout (location = 2) in vec3 WorldPos; // World Position 필요

layout (location = 4) uniform sampler2D uTexture;
layout (location = 5) uniform int uShadingType = 0;
layout (location = 6) uniform vec3 uCamPos;
layout (location = 7) uniform vec3 uLightPos;

위 VS에서 전달받는 Normal, WorldPos 를 추가하고

uShadingType, uCamPos, uLightPos 유니폼 또한 추가해주세요.

또, main() 을 다음처럼 세팅해 주세요.

void main() {
    // 빛의 위치
    const vec3 lightPos = uLightPos;
    // 기본값 컬러
    vec3 baseColor = texture(uTexture, TexCoord).rgb;

    // 셰이딩에 쓰이는, 매우 기본적인 파라메터들입니다.
    vec3 N = normalize(Normal);
    vec3 L = normalize(lightPos - WorldPos);
    vec3 V = normalize(uCamPos - WorldPos);

    FragColor = vec4(baseColor, 1.0);
}
  • N (Normal vector): 월드 공간 법선.
  • L (Light vector): 현재 픽셀의 3차원 (월드공간) 위치에서, '빛'을 향하는 방향.
  • V (View vector) : 현재 픽셀의 3차원 위치에서, '카메라' 를 향하는 방향.

위에서 나온 3개의 이니셜들은,

나중에 그래픽스 개념들에서 매우 자주 나오므로

기억해두시면 좋습니다.


가장 먼저 알아볼 라이팅 방식은

램버트 라이팅(Lambert Lighting)입니다.

이는 난반사광, 즉 Diffuse Light 를 계산하는 방법으로,

다음 수식으로 나타납니다. $$ LambertDiffuse = max(dot(N, L), 0) $$ 위에서 '법선 벡터' 와 '빛을 향하는 벡터' 의

'유사도' 를 언급했는데요,

이를 N과 L의 내적으로 표현이 가능합니다.

만일 N과 L의 내적이

1에 가까운 경우, 유사도가 높으므로 빛이 더 강하게 쬐이는 상황입니다.

0에 가까운 경우, 유사도가 낮아 빛이 약하게 쬐이는 상황입니다.

0보다 작고 -1에 가까운 경우는 표면이 빛을 받는 범위 (180도) 를 벗어났으므로,

무시해줘야 합니다.

이를 위해서 max(X, 0) 을 쓴 것입니다.

vec4 Lambert(vec3 baseColor, vec3 N, vec3 L) {
    const float ambient = 0.1;

    float diffuse = max(dot(N, L), 0.0); // NdotL

    vec3 color = baseColor * (ambient + diffuse);

    return vec4(color, 1.0);
}

위 함수를 정의해주세요.

주변광은 0.1 로 고정하고,

diffuse 값만 램버트로 계산해서

라이팅 결과를 반환하는 함수입니다.


다음은 하프 램버트 라이팅 (Half Lambert Lighting) 입니다.

램버트 라이팅의 경우, 수식이 매우 간단명료하다는 장점이 있지만 -

빛이 받는 양에 비해, 물체가 급격히 어두워지는 단점이 존재합니다.

이를 개선한 것이 이 수식입니다. $$ HalfLambertDiffuse = dot(N, L) * 0.5 + 0.5 $$ 단점이 생긴 이유는

내적 결괏값의 범위가 [-1, 1] 인데

0보다 작은 부분은 0으로 올려버리고

해당 부분의 변화는 무시하기 때문입니다.

하프 램버트에서는 [-1, 1] 범위를

0.5를 곱하고, 0.5를 더해서

[0, 1] 범위로 만들어 변화를 살려주는 거죠.

참고로 [0, 1] 범위와 [-1, 1] 범위 사이의 변환은

  • (a + 0.5) * 0.5
  • (a + 1.0) / 2.0

이렇게 할 수 있고,

셰이더 프로그래밍에서 매우 흔히 쓰이는 기술이기에

꼭 기억해두세요.

추가로 설명할 게 하나 있습니다.

저 수식 그대로 쓸 경우, 다른 파라메터와 조합했을 때

음영 효과가 약해집니다.

그래서 보통은 결괏값에

N제곱을 하는 관습이 생겼다고 합니다.

밑의 코드에서는 제곱을 취해줍니다.

vec4 HalfLambert(vec3 baseColor, vec3 N, vec3 L) {
    const float ambient = 0.1;

    float diffuse = dot(N, L); 
    diffuse = diffuse * 0.5 + 0.5;
    diffuse = pow(diffuse, 2);

    vec3 color = baseColor * (ambient + diffuse);

    return vec4(color, 1.0);
}

라이팅 모델 - 퐁 (램버트에다 spec추가) / 블린-퐁 (half벡터)

이제, 드디어 퐁 라이팅입니다.

퐁 라이팅은 위에서 소개했다시피,

  • 주변광(Ambient)
  • 난반사광(Diffuse)
  • 정반사광(Specular)

이 3개의 조합으로 이뤄집니다.

우선 함수부터 보시죠.

vec4 Phong(vec3 baseColor, vec3 N, vec3 L, vec3 V) {
    const float ambient = 0.1;
    const float shininess = 32.0;
    const float specStrength = 0.6;

    float diffuse = max(dot(N, L), 0.0);

    vec3 R = reflect(-L, N);
    float specular = pow(max(dot(R, V), 0.0), shininess);

    vec3 color = baseColor * (ambient + diffuse) +
                     vec3(specStrength * specular);

    return vec4(color, 1.0);
}

램버트에서도 쓰였던 N, L 이 아닌,

vec3 V 라는 인자가 하나 더 추가되었습니다.

이 View vector 는 하이라이팅, 즉

난반사광(Specular) 을 계산하기 위한 인자입니다.

램버트 / 하프 램버트가 diffuse 만을 계산하는 방법이었다면,

퐁 라이팅에서는 (어떤 식으로든) diffuse 를 계산하고

specular 를 추가해줍니다.

위에서는 그냥 램버트 공식을 사용했네요.

이제 specular 를 구하는 부분을 차근차근 보겠습니다.

먼저 R을 구합니다.

vec3 R = reflect(-L, N);

이는 '빛이 현재 픽셀로 들어오는 방향' 을 입사각으로,

법선 기준 반사된 반사 벡터입니다.

다음으로 R과 V에 대해서 유사도를 구하는데요,

max(dot(R, V), 0.0); // RdotV

이는 반사된 빛이 우리 눈에 얼마나 많이 들어오는가를

나타낸 것입니다.

이것이 specular를 구하는 공식입니다.

    float specular = pow(max(dot(R, V), 0.0), shininess);

    vec3 color = baseColor * (ambient + diffuse) +
                     vec3(specStrength * specular);

그 후, shininess 라는 값만큼 N제곱을 해서

하이라이팅을 더욱 강조시키고 -

specStrength 라는 계수를 곱해서

영향도를 조절해줍니다.


이어서, 블린-퐁(Blin-Phong) 라이팅이라는 모델을 소개하겠습니다.

램버트 - 하프램버트와 비슷한 맥락에서,

이 또한 퐁 라이팅 모델의 단점을 개선하기 위해 나온 모델입니다.

먼저 함수부터 보죠.

vec4 BlinnPhong(vec3 baseColor, vec3 N, vec3 L, vec3 V) {
    const float ambient = 0.1;
    const float shininess = 64.0;
    const float specStrength = 0.6;

    float diff = max(dot(N, L), 0.0);

    vec3 H = normalize(L + V);
    float spec = pow(max(dot(N, H), 0.0), shininess);

    vec3 color =
        baseColor * (ambient + diff) +
        vec3(specStrength * spec);

    return vec4(color, 1.0);
}

diffuse를 계산하는 부분까지는 동일합니다.

라이팅 모델 - 툰 / 구치

전체 코드 보기

1
2
3
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <cassert>