셰이더 프로그래밍 - Part 5-2 : 쉐도우 매핑 (Shadow Mapping)
이번 Part 5-2 에서는 spotlights가 물체에 그림자를 드리우도록 할 것 입니다.
쉐도우 매핑 알고리즘은 rasterization 기반의 렌더링 파이프라인에서 그림자를 근사하는 방법입니다.
쉐도우 매핑은 두 가지 스텝이 필요합니다.
- 각 광원마다 그 위치에 카메라가 있다고 가정하고, Scene 지오메트리를 렌더링합니다. 이 지점에서 렌더링 결과는 depth buffer 에 저장되며, 이 값은 광원에서 가장 가까운 지점을 의미합니다. depth buffer는 단일 채널의 텍스쳐 맵으로 만들어져서 fragment shader로 넘겨집니다. 이 텍스쳐 맵을 쉐도우맵 텍스쳐라고 합니다.
- 실제 렌더링 과정에서 illumination을 계산할 때, (표면 point를 셰이딩할 때) fragment shader는 그 지점이 광원 관점에서 shadow인지를 계산해야합니다. 이를 계산하기 위해서는 fragment shader는 현재 표면 point를 "광원에 카메라가 있었다고 가정하였을 때의 렌더링된 coordinate system" (이를 light space라고 부르겠습니다.) 에서의 좌표로 변환하여 계산하여야합니다. light space의 (x,y) 의 값을 사용하여 Step1에서 만들어진 쉐도우맵 텍스쳐의 값을 얻어옵니다. 만약 이 표면 점이 광원과 가장 가까운 지점이 아니라면, 이 점은 그림자에 있다고 볼 수 있습니다.
수정해야할 부분
- src/dynamic_scene/mesh.cpp:Mesh::internalDraw()
- src/dynamic_scene/scene.cpp:Scene::renderShadowPass()
우리는 각각의 spotlight마다 프레임버퍼를 만들었습니다. 각 light마다 C++코드는 light의 관점에서 Scene을 렌더링 해 프레임버퍼에 저장해둡니다. (이는 shadow map generation pass 혹은 shadow pass 라고 불립니다. "pass"라는 단어는 보통 렌더링할 때 모든 지오메트리를 한번 순회하는데 사용되는 단어입니다.)
이 shadow map 프레임버퍼의 핸들은 shadowFrameBufferId_에 저장됩니다.
Scene::renderShadowPass 에서, shadow map generation pass를 실행할 때, 프레임버퍼를 올바르게 계산하도록 해야합니다.
또한, shadow pass rendering을 위해 적절한 view 프로젝션, perspective 프로젝션 매트릭스를 계산해야합니다.
Scene::render 을 보면, 진짜 씬을 렌더링하는 카메라가 어떻게 view 프로젝션, perspective 프로젝션 매트릭스를 계산하는지 참고해볼 수 있습니다. 마지막으로 모든 light마다 world space에서 "light space"로 옮기는 worldToShadowLight 라는 매트릭스를 계산해서 저장해야합니다. 스타터 코드에 더 자세한 사항이 적혀있습니다.
src/dynamic_scene/scene.cpp:Scene::renderShadowPass() 을 열어보면 자세한 설명이 있습니다.
void Scene::renderShadowPass(int shadowedLightIndex) {
checkGLError("begin shadow pass");
Vector3D lightDir = spotLights_[shadowedLightIndex]->direction;
Vector3D lightPos = spotLights_[shadowedLightIndex]->position;
float coneAngle = spotLights_[shadowedLightIndex]->angle;
// I'm making the fovy (field of view in y direction) of the shadow map
// rendering a bit larger than the cone angle just to be safe. Clamp at 60 degrees.
float fovy = std::max(1.4f * coneAngle, 60.0f);
float aspect = 1.0f;
float near = 10.f;
float far = 400.;
// TODO CS248 Part 5.2 Shadow Mapping
// Here we render the shadow map for the given light. You need to accomplish the following:
// (1) You need to use gl_mgr_->bindFrameBuffer on the correct framebuffer to render into.
// (2) You need to compute the correct worldToLightNDC matrix to pass into drawShadow by
// pretending there is a camera at the light source looking at the scene. Some fake camera
// parameters are provided to you in the code above.
// (3) You need to compute a worldToShadowLight matrix that takes the point in world space and
// transforms it into "light space" for the fragment shader to use to sample from the shadow map.
// Note that this is almost worldToLightNDC with an additional transform that converts
// coordinates in the [-w,w]^3 normalized device coordinate box
// (the result of the perspective projection transform) to coordinates in a [0,w]^3 volume.
// After homogeneous divide. this means that x,y correspond to valid texture
// coordinates in the [0,1]^2 domain that can be used for a shadow map lookup in the shader.
// You should put it in the right place in worldToShadowLight_ array.
// Caveat: GLResourceManager::bindFrameBuffer uses the RAII idiom (https://en.cppreference.com/w/cpp/language/raii)
// Which means you have to give the return value a name since its destructor will release the binding.
// Bad:
// gl_mgr_->bindFrameBuffer(100); // Return value is destructed immediately!
// drawTriangles(); // <- Framebuffer 100 is not bound!!!
// Good:
// auto fb_bind = gl_mgr_->bindFrameBuffer(100);
// drawTriangles(); // <- Framebuffer 100 is bound, since fb_bind is still alive here.
//
// Replaces the following lines with correct implementation.
Matrix4x4 worldToLightNDC = Matrix4x4::identity();
worldToShadowLight_[shadowedLightIndex].zero();
glViewport(0, 0, shadowTextureSize_, shadowTextureSize_);
glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
// Now draw all the objects in the scene
for (SceneObject *obj : objects_)
obj->drawShadow(worldToLightNDC);
checkGLError("end shadow pass");
}
// Replaces the following lines with correct implementation.
해당 주석 하단을 수정하겠습니다.
std::unique_ptr<Cleanup> c1 = gl_mgr_->bindFrameBuffer(shadowFrameBufferId_[shadowedLightIndex]);
먼저 gl_mgr_->bindFrameBuffer 함수를 사용하여 원하는 light index의 frame buffer를 바인딩합니다.
이 바인딩이라는 의미는 이후 OpenGL API를 통해 드로우콜을 보내는 것을 frame buffer쪽에 하겠다는 의미입니다.
평소에는 render frame이 바인딩 되어서 드로우콜들이 모두 render frame으로 보내지기 때문에, 최종 이미지에 반영될 수 있는 것입니다.
그리고 스타터코드 설명에 이 bindFrameBuffer 함수는 RAII (Resource Acquisition Is Initialization) idiom을 사용한다고 하였는데, "리소스 획득은 초기화"라는 뜻으로, 하단의 코드에서 c1이라는 변수가 초기화되는 순간 frame buffer가 유지되고, c1 변수가 해제되는 순간 frame buffer 접근도 해제라고 생각하시면 됩니다. 즉 c1이 유지되는 function scope 내에서의 드로우콜만 frame buffer에 바인딩되는 것입니다.
Matrix4x4 worldToLight = createWorldToCameraMatrix(lightPos, lightPos+lightDir, Vector3D(0,1,0));
그리고 light space 기준에서 frame buffer에 scene을 렌더링해주면 됩니다. 그렇게하기 위해서는 worldToLightNDC를 적절히 계산하여 넘겨주어야 합니다. 마침 우리는 Part1에서 WorldToCamera 변환 매트릭스 계산 함수를 구현했었습니다. 이를 이용하여 WorldToLight 매트릭스를 쉽게 구현할 수 있습니다. 카메라 포지션 위치에 lightPos를 넣고, at 변수에는 lightPos+lightDir 값을, 마지막으로 upVector는 y-axis 를 넣었습니다.
Matrix4x4 proj = createPerspectiveMatrix(fovy, aspect, near, far);
마지막으로, LightNDC로 변환해주어야합니다. NDC란 Normalized Device Corrdinate로, shadow map이 그려지는 2d pixel공간이라고 생각하면 됩니다. 3차원 light space 공간으로 변환 후, 이를 projection matrix를 통해 2d로 마치 카메라로 사진을 찍듯 변환하는 것입니다. 이 projection은 카메라 렌더링과 같아서, fovy, aspect (ratio), near clip, far clip 이라는 값이 필요로 하는데요, 마침 이 값을 projection matrix로 변환해주는 함수가 있습니다. createPerspectiveMatrix함수인데요, 하단의 매트릭스처럼 생겼습니다.

Matrix4x4 worldToLightNDC = proj * worldToLight;
위의 두 proj, worldToLight 을 곱해주면 worldToLight and LightToNDC 가 연속변환이 되어서 worldToLightNDC로 변환하는 매트릭스가 완성이됩니다. (OpenGL은 column major이기 때문에 뒤에서부터 곱해주어야한다는 것을 명심하세요)
하단은 완성 코드입니다.
이 worldToLightNDC값을 drawShadow함수의 인자로 넣어주면 obj가 worldToLightNDC 매트릭스를 사용해 변환되어 프레임 버퍼에 저장이됩니다. 그리고 이 worldToLightNDC는 나중에 fragment shader에서 재사용 예정이기 때문에, 멤버변수인 worldToShadowLight_배열에 저장해둡니다. (다음 글 참고)
// Replaces the following lines with correct implementation.
std::unique_ptr<Cleanup> c1 = gl_mgr_->bindFrameBuffer(shadowFrameBufferId_[shadowedLightIndex]);
Matrix4x4 worldToLight = createWorldToCameraMatrix(lightPos, lightPos+lightDir, Vector3D(0,1,0));
Matrix4x4 proj = createPerspectiveMatrix(fovy, aspect, near, far);
Matrix4x4 worldToLightNDC = proj * worldToLight;
//worldToShadowLight_[shadowedLightIndex].zero();
worldToShadowLight_[shadowedLightIndex] = worldToLightNDC;
glViewport(0, 0, shadowTextureSize_, shadowTextureSize_);
glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
// Now draw all the objects in the scene
for (SceneObject *obj : objects_)
obj->drawShadow(worldToLightNDC);
checkGLError("end shadow pass");
완성되었으면, 컴파일 하고 프로그램을 실행합니다.
$ make
$ ./render ../media/spheres/spheres_shadow.json
그리고 실행된 상태에서 'v' 키를 누르면, 첫 번째로 저장된 (light index가 0인) shadow map 프레임버퍼 디버깅모드로 전환됩니다.

이렇게 그림자 처럼 생긴 shadow map 프레임버퍼가 완성되었습니다. 이 씬은 3개의 라이트가 있어서 총 3개의 프레임 버퍼가 있을 것 입니다. 저는 가운데 라이트가 디버깅모드에서 보입니다.
다음 "Part 5-2 : 쉐도우 매핑 (Shadow Mapping) - 2" 글에서 shadow map을 사용하여 렌더링을 이어 구현하겠습니다.
소스코드
https://github.com/Jooh34/shading
GitHub - Jooh34/shading: Stanford CS248 Assignment 3: Real-time Shading
Stanford CS248 Assignment 3: Real-time Shading. Contribute to Jooh34/shading development by creating an account on GitHub.
github.com
Reference
https://github.com/stanford-cs248/shading
'OpenGL > CS-248 셰이더 프로그래밍' 카테고리의 다른 글
| Part 5-2 : 쉐도우 매핑 (Shadow Mapping) - 2 (0) | 2023.01.29 |
|---|---|
| 셰이더 프로그래밍 - Part 5-1 : Spotlights 추가하기 (0) | 2023.01.16 |
| 셰이더 프로그래밍 - Part 4: 환경광 추가하기 (0) | 2023.01.15 |
| 셰이더 프로그래밍- Part 3: 노말 매핑 (Normal mapping) (0) | 2023.01.05 |
| 셰이더 프로그래밍 - Part 2: 퐁 반사모델 구현하기 (0) | 2023.01.03 |