Gouraud shading에 대해 알아보자.
Gouraud shading은 flat shading과 다르게 폴리곤 별로 색상 하나를 지정해주는 방식의 shading이 아니라 모든 폴리곤들에 대해 색을 interpolate 시켜서 부드러운 명암 효과를 줄 수 있다.
이를 시각적으로 쉽게 알아보기 위해서는 구 오브젝트에 셰이딩을 적용시켜보는 게 가장 직관적인데, 이를 위해 우선 구를 제작해보자.
구형 오브젝트를 만드는 방법은 여러가지가 있지만 이번 글에서는 위도와 경도를 이용한 방법을 사용하겠다.
이 방법을 사용해 일단 모든 정점들을 생성하고 (격자의 교점) 그 정점들이 생성하는 모든 폴리곤들을 점과 맵핑해 생성한 후 위 뚜껑과 아래 뚜껑을 제작하면 구 생성이 종료된다.
이를 구현하기 위해 우리는 z축(꼭 z가 아니어도 되긴 하다)으로 radius만큼의 길이를 갖는 벡터 하나를 생성해 이 벡터의 각을 변화시키며 회전시켜서 구면을 따라 움직이도록 하면서 점을 찍는 방식으로 구를 생성할 것이다.
이렇게 구현하게 되면 구면의 각 교점들은 위도와 경도, 즉 평면좌표상의 (a,b) 형태로 나타낼 수 있다.
이 점을 이용해 구면을 구성하는 각 사각형들을 구성하는 두 삼각형의 좌표를 맵핑해 생성할 수 있다.
// Sphere.h
#pragma once
#include "Vec3.h"
#include "Mat3.h"
#include "IndexedTriangleList.h"
class Sphere
{
public:
template<class V>
static IndexedTriangleList<V> GetPlain( float radius = 1.0f,int latDiv = 12,int longDiv = 24 ) // latDiv와 longDiv는 위도와 경도를 각각 몇 개의 축으로 나눌 지를 나타낸다.
{
const Vec3 base = { 0.0f,0.0f,radius }; // 기준이 되는 벡터 생성
const float lattitudeAngle = PI / latDiv;
const float longitudeAngle = 2.0f * PI / longDiv;
std::vector<V> vertices;
for( int iLat = 1; iLat < latDiv; iLat++ )
{
const auto latBase = base * Mat3::RotationX( lattitudeAngle * iLat );
for( int iLong = 0; iLong < longDiv; iLong++ )
{
vertices.emplace_back();
vertices.back().pos = latBase * Mat3::RotationZ( longitudeAngle * iLong );
}
}
// add the cap vertices (뚜껑 씌우기 위해 NorthPole과 SouthPole의 극점 좌표를 미리 저장해 놓는다.)
const auto iNorthPole = vertices.size();
vertices.emplace_back();
vertices.back().pos = base;
const auto iSouthPole = vertices.size();
vertices.emplace_back();
vertices.back().pos = -base;
// 구면 만들기
const auto calcIdx = [latDiv,longDiv]( int iLat,int iLong )
{ return iLat * longDiv + iLong; };
std::vector<size_t> indices;
for( int iLat = 0; iLat < latDiv - 2; iLat++ )
{
for( int iLong = 0; iLong < longDiv - 1; iLong++ )
{
indices.push_back( calcIdx( iLat,iLong ) );
indices.push_back( calcIdx( iLat + 1,iLong ) );
indices.push_back( calcIdx( iLat,iLong + 1 ) );
indices.push_back( calcIdx( iLat,iLong + 1 ) );
indices.push_back( calcIdx( iLat + 1,iLong ) );
indices.push_back( calcIdx( iLat + 1,iLong + 1 ) );
}
// wrap band (옆면 감싸기)
indices.push_back( calcIdx( iLat,longDiv - 1 ) );
indices.push_back( calcIdx( iLat + 1,longDiv - 1 ) );
indices.push_back( calcIdx( iLat,0 ) );
indices.push_back( calcIdx( iLat,0 ) );
indices.push_back( calcIdx( iLat + 1,longDiv - 1 ) );
indices.push_back( calcIdx( iLat + 1,0 ) );
}
// cap fans (뚜껑 씌우기)
for( int iLong = 0; iLong < longDiv - 1; iLong++ )
{
// north
indices.push_back( iNorthPole );
indices.push_back( calcIdx( 0,iLong ) );
indices.push_back( calcIdx( 0,iLong + 1 ) );
// south
indices.push_back( calcIdx( latDiv - 2,iLong + 1 ) );
indices.push_back( calcIdx( latDiv - 2,iLong ) );
indices.push_back( iSouthPole );
}
// wrap triangles (뚜껑의 옆면 감싸기. 뚜껑의 옆면이 비는 이유는 구면의 옆면이 비는 이유와 동일)
// north
indices.push_back( iNorthPole );
indices.push_back( calcIdx( 0,longDiv - 1 ) );
indices.push_back( calcIdx( 0,0 ) );
// south
indices.push_back( calcIdx( latDiv - 2,0 ) );
indices.push_back( calcIdx( latDiv - 2,longDiv - 1 ) );
indices.push_back( iSouthPole );
return{ std::move( vertices ),std::move( indices ) };
}
// ...
};
우리는 기준 벡터를 일단 한 번 기울인 상태에서 회전시키기 때문에 위와 아래 양쪽이 뻥 뚫리게 되는 형상이 발생하는데,
이를 매꾸는 건 원리는 그렇게 어렵지 않다.
완성된 "구"의 모습은 아래와 같다.
이 구에 명암을 부드럽게 입히려면 어떻게 해야 할까?
단순히 latDiv와 longDiv의 값을 높여 폴리곤 갯수를 늘리는 것도 하나의 방법이지만, 많은 자원이 사용된다는 점에서 당연히 이상적이지는 못한 방법이다.
이 문제를 해결하기 위해 고안된 것이 바로 Gouraud shading이다.
어떤 구에 "이상적으로" 명암을 입힌다고 생각해보자.
구면의 각 점들은 각각의 점의 normal vector에 따라 빛으로부터 얼마만큼의 영향을 받는지가 결정될 텐데,
이 점의 normal vector는 폴리곤으로 구성된 3D 오브젝트의 각 vertex들의 normal 벡터로도 표현할 수 있다. (사진 참고)
Gouraud shading에서는 이 점을 이용해 3D 오브젝트 각 정점마다 normal vector를 구하고, 그 구한 normal 벡터에 따라 각 정점에 맞는 색상을 게산한 이후,
각 정점 사이사이의 빈 공간들은 그 두 정점의 색상 사이를 interpolate하면서 색칠한다.
각 정점의 normal vector를 어떻게 구하는지 의문이 들 수도 있는데, 구형 오브젝트의 경우 단순히 중심에서 그 정점까지 벡터를 잇기만 하면 되기 때문에 매우 간단하다.
그 밖에 색을 interpolate하는 부분이라거나 색을 칠하는 부분은 우리가 이전 학습에서 이미 다 구현해둔 기능들이기 때문에 사실 가져다 사용하기만 하면 된다.
// Sphere.h
// ...
// normal 벡터들을 구하는 함수로 단순히 구면의 각 정점들을 순회하면서 구 중심에서의 벡터를 만드는 것에 불과하다.
template<class V>
static IndexedTriangleList<V> GetPlainNormals( float radius = 1.0f,int latDiv = 12,int longDiv = 24 )
{
auto sphere = GetPlain<V>( radius,latDiv,longDiv );
for( auto& v : sphere.vertices )
{
v.n = v.pos.GetNormalized();
}
return sphere;
}
// ...
코드와 함께 살펴보자.
// GouraudEffect.h
#pragma once
#pragma once
#include "Pipeline.h"
#include "DefaultGeometryShader.h"
// flat shading with vertex normals
class GouraudEffect
{
public:
// the vertex type that will be input into the pipeline
class Vertex
{
public:
Vertex() = default;
Vertex( const Vec3& pos )
:
pos( pos )
{}
Vertex( const Vec3& pos,const Vertex& src )
:
n( src.n ),
pos( pos )
{}
Vertex( const Vec3& pos,const Vec3& n )
:
n( n ),
pos( pos )
{}
Vertex& operator+=( const Vertex& rhs )
{
pos += rhs.pos;
return *this;
}
Vertex operator+( const Vertex& rhs ) const
{
return Vertex( *this ) += rhs;
}
Vertex& operator-=( const Vertex& rhs )
{
pos -= rhs.pos;
return *this;
}
Vertex operator-( const Vertex& rhs ) const
{
return Vertex( *this ) -= rhs;
}
Vertex& operator*=( float rhs )
{
pos *= rhs;
return *this;
}
Vertex operator*( float rhs ) const
{
return Vertex( *this ) *= rhs;
}
Vertex& operator/=( float rhs )
{
pos /= rhs;
return *this;
}
Vertex operator/( float rhs ) const
{
return Vertex( *this ) /= rhs;
}
public:
Vec3 pos;
Vec3 n;
};
// calculate color based on normal to light angle
// no interpolation of color attribute
class VertexShader
{
public:
class Output
{
public:
Output() = default;
Output( const Vec3& pos )
:
pos( pos )
{}
Output( const Vec3& pos,const Output& src )
:
color( src.color ),
pos( pos )
{}
Output( const Vec3& pos,const Vec3& color )
:
color( color ),
pos( pos )
{}
Output& operator+=( const Output& rhs )
{
pos += rhs.pos;
color += rhs.color; // 색상이 interpolate되는 걸 알 수 있다.
return *this;
}
Output operator+( const Output& rhs ) const
{
return Output( *this ) += rhs;
}
Output& operator-=( const Output& rhs )
{
pos -= rhs.pos;
color -= rhs.color;
return *this;
}
Output operator-( const Output& rhs ) const
{
return Output( *this ) -= rhs;
}
Output& operator*=( float rhs )
{
pos *= rhs;
color *= rhs;
return *this;
}
Output operator*( float rhs ) const
{
return Output( *this ) *= rhs;
}
Output& operator/=( float rhs )
{
pos /= rhs;
color /= rhs;
return *this;
}
Output operator/( float rhs ) const
{
return Output( *this ) /= rhs;
}
public:
Vec3 pos;
Vec3 color;
};
public:
void BindRotation( const Mat3& rotation_in )
{
rotation = rotation_in;
}
void BindTranslation( const Vec3& translation_in )
{
translation = translation_in;
}
Output operator()( const Vertex& v ) const
{
// Flat shading과 동일하다. 기본적인 빛 - 색상 연산이다. 14.flat_shading 참고
// 중요한 점은, flat shading과 다르게 GouraudEffect의 Vertex 객체는 v.n 즉 normal을 Sphere객체의 GetPlainNormals() 함수에서 초기화한다는 점이다.
// 즉 flat shading에서 정점이 그 정점이 속한 면의 face normal 정보를 가지고 있던 것과 다르다. (참고: 우리의 flat shading 구현에서는 정점에 face normal을 저장하는 대신 Geometry shader를 사용해 동적으로 face normal을 추출했지만, flat shading을 구현하는 방법이 여러 가지 있으니 이 경우의 일단 차이를 알아두자.)
// calculate intensity based on angle of incidence
const auto d = diffuse * std::max( 0.0f,-(v.n * rotation) * dir );
// add diffuse+ambient, filter by material color, saturate and scale
const auto c = color.GetHadamard( d + ambient ).Saturate() * 255.0f;
return{ v.pos * rotation + translation,c };
}
void SetDiffuseLight( const Vec3& c )
{
diffuse = { c.x,c.y,c.z };
}
void SetAmbientLight( const Vec3& c )
{
ambient = { c.x,c.y,c.z };
}
void SetLightDirection( const Vec3& dl )
{
assert( dl.LenSq() >= 0.001f );
dir = dl.GetNormalized();
}
void SetMaterialColor( Color c )
{
color = Vec3( c );
}
private:
Mat3 rotation;
Vec3 translation;
Vec3 dir = { 0.0f,0.0f,1.0f };
Vec3 diffuse = { 1.0f,1.0f,1.0f };
Vec3 ambient = { 0.1f,0.1f,0.1f };
Vec3 color = { 0.8f,0.85f,1.0f };
};
// default gs passes vertices through and outputs triangle
typedef DefaultGeometryShader<VertexShader::Output> GeometryShader;
// invoked for each pixel of a triangle
// takes an input of attributes that are the
// result of interpolating vertex attributes
// and outputs a color
class PixelShader
{
public:
template<class Input>
Color operator()( const Input& in ) const
{
return Color( in.color );
}
};
public:
VertexShader vs;
GeometryShader gs;
PixelShader ps;
};
결과는 다음과 같다.
자세히 살펴보면 구의 겉면(테두리) 자체는 여전히 폴리곤 모델 특유의 울퉁불퉁함이 남아있다는 것을 알 수 있는데,
이는 어쩔 수 없는 한계로 옛날 게임에서도 이러한 3D 모델을 찾아볼 수 있는 것도 이 때문이다.
또 Gouraud shading의 또 한가지의 한계로, 구형이 아닌 오브젝트를 셰이딩할 경우 어떻게 해야 할지에 대한 문제가 여전히 남아있다.
위 사진과 같은 정육면체 오브젝트를 gouraud shading 할 때 여러 면에 겹쳐 사용되는 정점이 있기 때문에 각 정점이 어느 방향의 normal을 가져야 하는지가 애매해진다. 만약 flat shading의 정점 기반 구현 방식에서 그러했듯이 일정한 규칙 하에 어떤 정점이 어떤 면만을 나타나게 만들어버리면 평평해 보여야 할 면이 색이 shading 되어 굴곡져보이는 현상이 발생할 수도 있기 떄문에 이러한 접근을 할 수도 없다. 때문에 이러한 상황에서는 어쩔 수 없이 중복되는 vertex를 여러 개 사용해야 한다는 문제가 발생한다.
또 하나의 문제로, 구형이 아닌 오브젝트의 normal을 애초에 어떻게 구한느냐에 대한 문제이다. 구형 오브젝트의 경우 아주 단순하게 중심에서의 벡터로 구할 수 있었지만, 복잡한 오브젝트의 경우 그렇게 단순하게 구할 수가 없다.
그럼 어떻게 해야할까?
아쉽게도, 완벽한 해결법은 없다.
애초에 굴곡진 물체를 폴리곤으로 모델링하는 과정에서 정보의 손실이 발생하는데, 이를 완벽하게 복구하는 것은 불가능하다.
때문에 현업 등에서는 하이폴리 모델에서 로우폴리 모델을 만드는 과정에서, 우선 하이폴리 모델에서 normal들을 추출해 이후 만들어진 로우폴리 모델에 그 normal을 사용하는 식의 방식을 사용하기도 한다. 또 모델을 작업 하면서 수작업으로 normal을 조정하는 경우도 있다.
앞선 학습과정에서 사용했던 TinyObj 라이브러리를 이용해 normal 정보가 담긴 보다 복잡한 3d 오브젝트를 우리의 파이프라인에서 렌더링해보자.
코드는 다음과 같다. (대부분 API를 우리의 파이프라인과 연동시키는 작업으로, 크게 중요치 않다.)
#pragma once
#include <vector>
#include "Vec3.h"
#include "tiny_obj_loader.h"
#include "Miniball.h"
#include <fstream>
#include <cctype>
template<class T>
class IndexedTriangleList
{
public:
IndexedTriangleList() = default;
IndexedTriangleList( std::vector<T> verts_in,std::vector<size_t> indices_in )
:
vertices( std::move( verts_in ) ),
indices( std::move( indices_in ) )
{
assert( vertices.size() > 2 );
assert( indices.size() % 3 == 0 );
}
static IndexedTriangleList<T> Load( const std::string& filename )
{
IndexedTriangleList<T> tl;
// check first line of file to see if CCW winding comment exists
bool isCCW = false;
{
std::ifstream file( filename );
std::string firstline;
std::getline( file,firstline );
std::transform( firstline.begin(),firstline.end(),firstline.begin(),std::tolower );
if( firstline.find( "ccw" ) != std::string::npos )
{
isCCW = true;
}
}
// these will be filled by obj loading function
using namespace tinyobj;
attrib_t attrib;
std::vector<shape_t> shapes;
std::string err;
// load/parse the obj file
const bool ret = LoadObj( &attrib,&shapes,nullptr,&err,filename.c_str() );
// check for errors
if( !err.empty() && err.substr( 0,4 ) != "WARN" )
{
throw std::runtime_error( ("LoadObj returned error:" + err + " File:" + filename).c_str() );
}
if( !ret )
{
throw std::runtime_error( ("LoadObj returned false File:" + filename).c_str() );
}
if( shapes.size() == 0u )
{
throw std::runtime_error( ("LoadObj object file had no shapes File:" + filename).c_str() );
}
// extract vertex data
// attrib.vertices is a flat std::vector of floats corresponding
// to vertex positions, laid out as xyzxyzxyz... etc.
// first preallocate required space in OUR std::vector
tl.vertices.reserve( attrib.vertices.size() / 3u );
// iterate over individual vertices, construct Vec3s in OUR vector
for( int i = 0; i < attrib.vertices.size(); i += 3 )
{
tl.vertices.emplace_back( Vec3{
attrib.vertices[i + 0],
attrib.vertices[i + 1],
attrib.vertices[i + 2]
} );
}
// extract index data
// obj file can contain multiple meshes, we assume just 1
const auto& mesh = shapes[0].mesh;
// mesh contains a std::vector of num_face_vertices (uchar)
// and a flat std::vector of indices. If all faces are triangles
// then for any face f, the first index of that faces is [f * 3n]
tl.indices.reserve( mesh.indices.size() );
for( size_t f = 0; f < mesh.num_face_vertices.size(); f++ )
{
// make sure there are no non-triangle faces
if( mesh.num_face_vertices[f] != 3u )
{
std::stringstream ss;
ss << "LoadObj error face #" << f << " has "
<< mesh.num_face_vertices[f] << " vertices";
throw std::runtime_error( ss.str().c_str() );
}
// load set of 3 indices for each face into OUR index std::vector
for( size_t vn = 0; vn < 3u; vn++ )
{
const auto idx = mesh.indices[f * 3u + vn];
tl.indices.push_back( size_t( idx.vertex_index ) );
}
// reverse winding if file marked as CCW
if( isCCW )
{
// swapping any two indices reverse the winding dir of triangle
std::swap( tl.indices.back(),*std::prev( tl.indices.end(),2 ) );
}
}
return tl;
}
static IndexedTriangleList<T> LoadNormals( const std::string& filename )
{
IndexedTriangleList<T> tl;
// check first line of file to see if CCW winding comment exists
bool isCCW = false;
{
std::ifstream file( filename );
std::string firstline;
std::getline( file,firstline );
std::transform( firstline.begin(),firstline.end(),firstline.begin(),std::tolower );
if( firstline.find( "ccw" ) != std::string::npos )
{
isCCW = true;
}
}
// these will be filled by obj loading function
using namespace tinyobj;
attrib_t attrib;
std::vector<shape_t> shapes;
std::string err;
// load/parse the obj file
const bool ret = LoadObj( &attrib,&shapes,nullptr,&err,filename.c_str() );
// check for errors
if( !err.empty() && err.substr( 0,4 ) != "WARN" )
{
throw std::runtime_error( ("LoadObj returned error:" + err + " File:" + filename).c_str() );
}
if( !ret )
{
throw std::runtime_error( ("LoadObj returned false File:" + filename).c_str() );
}
if( shapes.size() == 0u )
{
throw std::runtime_error( ("LoadObj object file had no shapes File:" + filename).c_str() );
}
// extract vertex data
// attrib.vertices is a flat std::vector of floats corresponding
// to vertex positions, laid out as xyzxyzxyz... etc.
// first preallocate required space in OUR std::vector
tl.vertices.reserve( attrib.vertices.size() / 3u );
// iterate over individual vertices, construct Vec3s in OUR vector
for( int i = 0; i < attrib.vertices.size(); i += 3 )
{
tl.vertices.emplace_back( Vec3{
attrib.vertices[i + 0],
attrib.vertices[i + 1],
attrib.vertices[i + 2]
} );
}
// extract index data
// obj file can contain multiple meshes, we assume just 1
const auto& mesh = shapes[0].mesh;
// mesh contains a std::vector of num_face_vertices (uchar)
// and a flat std::vector of indices. If all faces are triangles
// then for any face f, the first index of that faces is [f * 3n]
tl.indices.reserve( mesh.indices.size() );
for( size_t f = 0; f < mesh.num_face_vertices.size(); f++ )
{
// make sure there are no non-triangle faces
if( mesh.num_face_vertices[f] != 3u )
{
std::stringstream ss;
ss << "LoadObj error face #" << f << " has "
<< mesh.num_face_vertices[f] << " vertices";
throw std::runtime_error( ss.str().c_str() );
}
// load set of 3 indices for each face into OUR index std::vector
for( size_t vn = 0; vn < 3u; vn++ )
{
const auto idx = mesh.indices[f * 3u + vn];
tl.indices.push_back( size_t( idx.vertex_index ) );
// write normals into the vertices
tl.vertices[(size_t)idx.vertex_index].n = Vec3{
attrib.normals[3 * idx.normal_index + 0],
attrib.normals[3 * idx.normal_index + 1],
attrib.normals[3 * idx.normal_index + 2]
};
}
// reverse winding if file marked as CCW
if( isCCW )
{
// swapping any two indices reverse the winding dir of triangle
std::swap( tl.indices.back(),*std::prev( tl.indices.end(),2 ) );
}
}
return tl;
}
void AdjustToTrueCenter()
{
// used to enable miniball to access vertex pos info
struct VertexAccessor
{
// iterator type for iterating over vertices
typedef std::vector<T>::const_iterator Pit;
// it type for iterating over components of vertex
// (pointer is used to iterate over members of class here)
typedef const float* Cit;
// functor that miniball uses to get element iter based on vertex iter
Cit operator()( Pit it ) const
{
return &it->pos.x;
}
};
// solve the minimum bounding sphere
Miniball::Miniball<VertexAccessor> mb( 3,vertices.cbegin(),vertices.cend() );
// get center of min sphere
// result is a pointer to float[3] (what a shitty fuckin interface)
const auto pc = mb.center();
const Vec3 center = { *pc,*std::next( pc ),*std::next( pc,2 ) };
// adjust all vertices so that center of minimal sphere is at 0,0
for( auto& v : vertices )
{
v.pos -= center;
}
}
float GetRadius() const
{
// find element with max distance from 0,0; that is our radius
return std::max_element( vertices.begin(),vertices.end(),
[]( const T& v0,const T& v1 )
{
return v0.pos.LenSq() < v1.pos.LenSq();
}
)->pos.Len();
}
std::vector<T> vertices;
std::vector<size_t> indices;
};
잘 작동하는 것을 확인할 수 있다.
'computer graphics > 3D Graphics' 카테고리의 다른 글
[3DGraphics] 17. Phong shading & Per-pixel lighting (0) | 2022.09.09 |
---|---|
[3DGraphics] 16. Point lights (0) | 2022.09.09 |
[3DGraphics] 14. Flat shading and Mesh loading (0) | 2022.09.09 |
[3DGraphics] 13. Geometry shader (0) | 2022.07.24 |
[3DGraphics] 12. Vertex shader (0) | 2022.07.24 |