계산 능력이 무한하다면 렌더링 방정식에 따라 이미지를 만드는 것이 이상적인 방법입니다. 하지만 실제 컴퓨터로 제한된 시간 안에 이미지를 만들려면 어떤 근사와 절충을 사용하는지 이해해야 합니다. 이 장의 나머지 부분은 그 기법들에 초점을 맞춰, 2010년 무렵의 전형적이고 단순한 실시간 그래픽스 파이프라인을 설명합니다.
먼저 파이프라인을 개관한 다음 그 흐름을 따라 내려가며 각 부분을 자세히 살펴보고, 도중에 중요한 수학적 아이디어에 초점을 맞출 것입니다. 이 절을 읽을 때는 이 설명에 다음과 같은 중요한 한계가 있음을 알아 두어야 합니다.
•
‘전형적인’ 현대 그래픽스 파이프라인이라는 것은 없습니다. 렌더링 전략의 수는 그래픽스 프로그래머의 수만큼 많습니다. 각자 선호하는 방법, 요령, 최적화가 있습니다. 그래픽스 하드웨어도 빠르게 발전합니다. 그 증거로, 게임 콘솔 같은 소비자용 하드웨어에서 셰이더 프로그램이 널리 사용되지만 이 책 초판을 집필할 때는 아직 초기 단계에 있던 기술이었습니다. 그래도 그래픽스 시스템과 프로그래머 사이의 차이가 크다는 사실과 별개로, 대부분의 시스템은 많은 공통점을 가집니다(원주 10-23). 이 장의 목적, 나아가 이 책 전체의 목적은 특히 수학과 관련하여 탄탄한 개관을 제공하고, 이를 토대로 독자가 지식을 넓힐 수 있도록 하는 것입니다. 최신 첨단 기법을 총정리하려는 책은 아닙니다. 집필 당시 그런 개관으로는 《Real-Time Rendering》[1]이 가장 뛰어납니다.
•
아주 기본적인 조명을 사용하여 렌더링 이미지 한 장을 만드는 기본 절차를 설명하려 합니다. 애니메이션은 고려하지 않으며, 전역 조명 기법도 간단히 언급하는 데 그칩니다.
•
그래픽스 파이프라인에서 데이터가 흐르는 개념적인 순서를 설명합니다. 실제로는 성능을 위해 작업을 병렬로 수행하거나 순서를 바꾸는 경우가 많습니다.
•
우리의 관심은 실시간 렌더링 시스템입니다. 집필 당시 이런 시스템은 주로 삼각형 메시를 렌더링하도록 설계되었습니다. 광선 추적처럼 이미지를 만드는 다른 방식은 여기서 설명하는 것과 상위 수준의 구조가 매우 다릅니다. 앞으로 병렬 광선 추적이 무어의 법칙에 따른 발전을 활용하는 더 경제적인 방법이 된다면, 실시간 렌더링과 오프라인 렌더링의 기법이 하나로 수렴할 수도 있음을 알아 두겠습니다.
원주 10-23: 인정하기는 싫을지 몰라도 대부분의 프로그래머끼리도 공통점이 많습니다.
위의 단순화를 염두에 두고 그래픽스 파이프라인의 데이터 흐름을 대략 정리하면 다음과 같습니다.
•
장면 설정. 렌더링을 시작하기 전에 장면 전체에 적용할 여러 옵션을 정해야 합니다. 예를 들어 카메라를 설정해야 합니다. 구체적으로는 장면을 어느 시점에서 바라보며, 화면의 어느 위치에 그릴지 정합니다. 이 과정의 수학은 10.2절에서 다루었습니다. 조명과 안개 옵션을 선택하고 깊이 버퍼도 준비해야 합니다.
•
가시성 판정. 카메라를 배치했으면 장면 속 어떤 물체가 보이는지 결정해야 합니다. 실제로 보이지 않는 것을 렌더링하는 데 시간을 낭비하면 안 되므로 실시간 렌더링에서는 매우 중요합니다. 이런 상위 수준의 컬링은 실제 게임에서 매우 중요하지만, 처음 배우는 단순한 응용에서는 흔히 생략하며 여기서도 다루지 않습니다.
•
물체별 렌더링 상태 설정. 물체가 보일 가능성이 있다는 것을 알았으면 실제로 그릴 차례입니다. 물체마다 자체적인 렌더링 옵션을 가질 수 있습니다. 그 물체에 속한 프리미티브를 렌더링하기 전에 이 옵션들을 렌더링 문맥에 설정해야 합니다. 물체와 연결된 가장 기본적인 속성은 표면의 성질을 설명하는 재질(material)일 것입니다. 가장 흔한 재질 속성 중 하나는 물체의 난반사 색입니다. 10.5절에서 설명했듯이 보통 텍스처 맵으로 제어합니다.
•
기하 형상의 생성과 전달. 다음으로 기하 데이터를 실제 렌더링 API에 제출합니다. 보통 개별 삼각형, 인덱스 삼각형 메시, 삼각형 스트립 등의 삼각형 형식으로 전달합니다. 이 단계에서 상세 수준(LOD)을 선택하거나 절차적으로 형상을 생성할 수도 있습니다. 렌더링 API에 기하 데이터를 전달할 때의 여러 문제는 10.10.2절에서 다룹니다.
•
정점 단위 연산. 렌더링 API가 삼각형 형식의 기하 데이터를 받으면 정점 단위로 다양한 연산을 수행합니다. 가장 중요한 연산은 정점 위치를 모델링 공간에서 카메라 공간으로 변환하는 것일 것입니다. 그 밖에 골격 모델의 애니메이션을 위한 스키닝, 정점 조명, 텍스처 좌표 생성 등이 있습니다. 집필 당시의 소비자용 그래픽스 시스템에서는 사용자가 제공하는 정점 셰이더(vertex shader)라는 작은 프로그램이 이런 연산을 수행합니다. 장의 끝부분인 10.11절에서 정점 셰이더와 픽셀 셰이더의 예를 여러 개 보여 줄 것입니다.
•
컬링, 클리핑, 투영. 다음으로 3차원 삼각형을 2차원 화면에 올리기 위해 세 가지 연산을 수행합니다. 실제 수행 순서는 달라질 수 있습니다. 먼저 클리핑(clipping)이라는 과정으로 뷰 프러스텀 밖에 있는 삼각형 부분을 제거합니다. 10.10.4절에서 설명합니다. 3차원 클립 공간에서 잘린 다각형을 얻으면, 그 정점들을 투영하여 출력 창의 2차원 화면 공간 좌표로 매핑합니다. 이 과정은 10.3.5절에서 설명했습니다. 마지막으로 정점이 시계 방향인지 반시계 방향인지에 따라 카메라 반대쪽을 향하는 삼각형을 제거, 즉 ‘컬링’합니다. 10.10.5절에서 다룹니다.
•
래스터화. 화면 공간에서 클리핑된 다각형을 얻으면 래스터화(rasterization)합니다. 이는 특정 삼각형을 그릴 화면 픽셀을 선택하고, 정점에서 계산한 텍스처 좌표·색·조명 값을 면 내부의 각 픽셀에 보간한 다음 픽셀 셰이딩 단계로 넘기는 과정입니다. 보통 하드웨어 수준에서 수행하므로 10.10.6절에서 간단히만 언급합니다.
•
픽셀 셰이딩. 다음으로 픽셀의 색을 계산합니다. 이를 셰이딩(shading)이라고 합니다. 물론 ‘색을 계산한다’는 평범해 보이는 말이야말로 컴퓨터 그래픽스의 핵심입니다! 색을 정하면 알파 블렌딩과 z 버퍼링을 적용할 수 있으며, 그 결과를 프레임 버퍼에 씁니다. 10.10.6절에서 설명합니다. 당시 소비자용 하드웨어에서는 사용자가 작성하는 작은 프로그램인 픽셀 셰이더(pixel shader)가 이를 수행합니다. 정점 셰이더가 출력하고 면 전체에 보간되어 픽셀별로 제공되는 값을 입력받아, 마지막 단계인 블렌딩으로 색 값을 출력합니다.
•
블렌딩과 출력. 렌더링 파이프라인의 맨 아래에서는 색, 불투명도, 깊이 값이 만들어져 있습니다. 깊이 값을 깊이 버퍼와 비교하여 픽셀 단위 가시성을 판정합니다. 카메라에서 더 먼 물체가 더 가까운 물체를 가리지 않게 하기 위해서입니다. 불투명도가 너무 낮은 픽셀은 버립니다. 출력 색은 알파 블렌딩(alpha blending)이라는 과정을 통해 프레임 버퍼에 있던 색과 결합합니다.
코드 10.10의 의사 코드는 위의 단순화된 렌더링 파이프라인을 정리한 것입니다.
// 먼저 장면을 어떻게 바라볼지 정한다.
setupTheCamera();
// z 버퍼를 비운다.
clearZBuffer();
// 환경 조명과 안개를 설정한다.
setGlobalLightingAndFog();
// 보일 가능성이 있는 물체들의 목록을 구한다.
potentiallyVisibleObjectList = highLevelVisibilityDetermination(scene);
// 보일 가능성이 있다고 판정한 모든 것을 렌더링한다.
for (all objects in potentiallyVisibleObjectList) {
// 경계 부피 검사로 더 낮은 수준의 가시 표면 판정을 수행한다.
if (!object.isBoundingVolumeVisible()) continue;
// 기하 데이터를 가져오거나 절차적으로 생성한다.
triMesh = object.getGeometry()
// 면들을 클리핑하고 렌더링한다.
for (each triangle in the geometry) {
// 정점을 클립 공간으로 변환하고 정점 단위 계산을 한다.
// 즉 정점 셰이더를 실행한다.
clipSpaceTriangle = transformAndLighting(triangle);
// 삼각형을 시야 부피에 맞게 클리핑한다.
clippedTriangle = clipToViewVolume(clipSpaceTriangle);
if (clippedTriangle.isEmpty()) continue;
// 삼각형을 화면 공간에 투영한다.
screenSpaceTriangle = clippedTriangle.projectToScreenSpace();
// 삼각형이 뒷면을 보이는가?
if (screenSpaceTriangle.isBackFacing()) continue;
// 삼각형을 래스터화한다.
for (each pixel in the triangle) {
// 삼각형을 프러스텀에 완전히 클리핑하지 않았다면
// 여기서 픽셀을 시저링한다.
if (pixel is off-screen) continue;
// 색, z 버퍼 값, 텍스처 매핑 좌표를 보간한다.
// 픽셀 셰이더는 보간값을 받아 색과 알파를 계산한다.
color = shadePixel();
// z 버퍼링을 수행한다.
if (!zbufferTest()) continue;
// '너무 투명한' 픽셀은 알파 검사로 무시한다.
if (!alphaTest()) continue;
// 프레임 버퍼와 z 버퍼에 쓴다.
writePixel(color, interpolatedZ);
// 이 삼각형의 다음 픽셀로 이동한다.
}
// 이 물체의 다음 삼각형으로 이동한다.
}
// 보일 가능성이 있는 다음 물체로 이동한다.
}
C++
복사
코드 10.10. 그래픽스 파이프라인의 의사 코드.
그리 오래전만 해도 그래픽스 프로그래머는 코드 10.10의 모든 단계를 소프트웨어로 직접 구현해야 했습니다. 당시에는 많은 작업을 DirectX나 OpenGL 같은 그래픽스 API에 맡깁니다. API는 일부 작업을 주 CPU의 소프트웨어로 수행하고, 다른 작업은 특수한 그래픽스 하드웨어로 보냅니다.
이상적으로는 가능한 한 많은 작업을 하드웨어에 맡깁니다. 현대 그래픽스 하드웨어는 정점 셰이더와 픽셀 셰이더를 통해 그래픽스 프로그래머, 즉 우리에게 매우 낮은 수준의 제어권을 줍니다. 이 셰이더들은 처리하는 각 정점과 픽셀에 대해 하드웨어에서 실행되는 작은 프로그램입니다.
단일 프로세서로 소프트웨어 렌더링을 하던 시절에는 손으로 최적화한 어셈블리 코드로 성능 문제를 해결했습니다. 당시에는 GPU를 최대한 효율적으로 사용하고, CPU가 무언가를 하기를 기다리며 GPU가 놀지 않게 하는 데 더 관심이 있습니다. 물론 예나 지금이나 렌더링을 빠르게 하는 가장 간단한 방법은 보이지 않는 것을 아예 그리지 않거나, 화면에서 작게 보이는 것을 더 싼 근사로 그리는 것입니다.
정리하면 현대 그래픽스 파이프라인은 우리 코드와 렌더링 API의 긴밀한 협력으로 이루어집니다. 여기서 ‘렌더링 API’는 API 소프트웨어와 그래픽스 하드웨어를 모두 뜻합니다. PC에서는 지원해야 하는 하드웨어가 매우 다양하므로 API 소프트웨어 계층이 필연적으로 아주 ‘두껍습니다’.
하드웨어가 표준화된 콘솔에서는 훨씬 얇을 수 있습니다. 주목할 만한 예가 PlayStation 2입니다. 프로그래머가 하드웨어 레지스터에 직접 접근하고 직접 메모리 접근(DMA)을 매우 낮은 수준에서 제어할 수 있었습니다. 그림 10.36은 이 협력에서의 역할 분담을 보여 줍니다.
그림 10.36 — 우리 코드와 그래픽스 API의 역할 분담
그림 10.36. 우리 코드와 그래픽스 API의 역할 분담.
그림 10.37은 실시간 그래픽스 파이프라인을 조금 다른 관점으로 정리합니다. 이번에는 파이프라인의 아래쪽과 개념적인 데이터 흐름에 더 초점을 맞춥니다. 파란 상자는 우리가 제공하는 데이터이고, 파란 타원은 우리가 작성하는 셰이더입니다. 노란 타원은 API가 수행하는 연산입니다.
이 장의 나머지 부분에서는 컴퓨터 그래픽스의 다양한 주제를 다룹니다. 대체로 그래픽스 파이프라인에서 그 주제를 만나는 순서대로 진행합니다.
그림 10.37 — 그래픽스 파이프라인의 데이터 흐름
그림 10.37. 그래픽스 파이프라인의 데이터 흐름.
10.10.1 버퍼
렌더링에는 많은 버퍼(buffer)가 관여합니다. 여기서 버퍼는 픽셀마다 어떤 데이터를 저장하는 사각형 메모리 영역을 뜻합니다. 가장 중요한 것은 프레임 버퍼와 깊이 버퍼입니다.
프레임 버퍼(frame buffer)는 픽셀마다 색 하나를 저장합니다. 즉 렌더링된 이미지가 들어 있습니다. 픽셀 하나의 색을 저장하는 형식에는 여러 가지가 있지만, 지금 설명에서는 그 차이가 중요하지 않습니다. 이미지 한 장을 렌더링한다면 프레임 버퍼를 일반 RAM에 두었다가 디스크에 저장할 수도 있습니다.
실시간 애니메이션에서는 더 흥미로운 상황이 생깁니다. 이 경우 프레임 버퍼는 보통 비디오 RAM에 있습니다. 비디오 카드는 이 영역을 계속 읽고 이진 데이터를 디스플레이 장치로 보낼 신호로 변환합니다. 그런데 우리가 그 메모리에 렌더링하는 동안 모니터는 어떻게 그것을 읽을까요?
렌더링이 완전히 끝나기 전에 이미지가 표시되는 것을 막기 위해 더블 버퍼링(double buffering)이라는 기법을 씁니다. 실제로 프레임 버퍼를 두 개 둡니다. 프런트 버퍼(front buffer)에는 모니터에 현재 표시 중인 이미지가 들어 있고, 화면 밖의 백 버퍼(back buffer)에는 현재 렌더링 중인 이미지가 들어 있습니다.
이미지 렌더링이 끝나 표시할 준비가 되면 버퍼를 ‘뒤집습니다’. 방법은 두 가지입니다. 페이지 플리핑(page flipping)을 사용하면 비디오 하드웨어가 방금까지 화면 밖 버퍼였던 것을 읽기 시작하도록 지시합니다. 그런 다음 두 버퍼의 역할을 바꿉니다. 방금 표시 중이던 버퍼가 새 화면 밖 버퍼가 됩니다. 또는 화면 밖 버퍼를 표시 버퍼 위로 블릿(blit), 즉 복사할 수도 있습니다. 그림 10.38이 더블 버퍼링을 보여 줍니다.
그림 10.38 — 더블 버퍼링
그림 10.38. 더블 버퍼링.
백 버퍼에 렌더링한 이미지를 보이게 하는 동작을 더 현대적인 용어로는 이미지를 프레젠트(present)한다고 합니다.
렌더링에 사용하는 두 번째 중요한 버퍼는 깊이 버퍼(depth buffer)이며 z 버퍼(z-buffer)라고도 합니다. 픽셀마다 색 대신 깊이 값을 저장합니다. 정확히 어떤 값을 넣는지는 여러 변형이 있지만, 기본적으로 카메라까지의 거리와 관련된 값입니다. 흔히 클립 공간의 z 좌표를 깊이 값으로 사용하므로 z 버퍼라고 부릅니다.
깊이 버퍼는 어떤 물체가 다른 물체를 가리는지 다음과 같이 판정합니다. 삼각형을 래스터화하면서 각 픽셀의 깊이 값을 보간합니다. 픽셀을 그리기 전에 이 값을 그 픽셀 위치의 깊이 버퍼에 이미 저장된 값과 비교합니다. 새 깊이가 기존 값보다 카메라에서 더 멀다면 그 픽셀을 버립니다. 그렇지 않으면 픽셀 색을 프레임 버퍼에 쓰고 깊이 버퍼를 더 가까운 새 깊이 값으로 갱신합니다.
이미지 렌더링을 시작하기 전에 깊이 버퍼를 ‘카메라에서 아주 멀다’를 뜻하는 값으로 초기화해야 합니다. 클립 공간에서는 이 값이 1.0입니다. 그러면 맨 처음 렌더링하는 픽셀은 반드시 깊이 검사를 통과합니다. 깊이 버퍼는 보통 프레임 버퍼처럼 더블 버퍼링할 필요가 없습니다.
데이터가 처리되는 단위
flowchart TD A["물체 단위: 가시성 판정·재질 설정"] --> B["정점 단위: 변환·스키닝"] B --> C["삼각형 단위: 클리핑·뒷면 컬링"] C --> D["픽셀 단위: 보간·조명·텍스처 조회"] D --> E["깊이·알파 검사와 버퍼 쓰기"]
Mermaid
복사
이 흐름은 원문이 명시한 2010년 무렵의 개념적 파이프라인입니다. 깊이 검사를 셰이딩보다 먼저 수행할 수 있는 조건처럼, 실제 실행 순서는 하드웨어와 셰이더 동작에 따라 달라집니다.
파이프라인의 좌표와 형식
클립 공간의 동차 좌표 z와 원근 나눗셈 뒤의 z/w는 구분해야 합니다. 본문의 ‘클립 공간 z를 깊이 버퍼에 저장’, ‘1.0으로 초기화’는 전통적인 깊이 범위 설정을 설명하는 단순화입니다. 변환 관례, 깊이 매핑 및 비교 함수에 따라 저장값과 초기화값이 달라질 수 있습니다.
10.10.2 기하 데이터 전달
렌더링할 물체를 정했으면 실제로 그려야 합니다. 실제 과정은 두 단계입니다. 먼저 렌더 문맥(render context)을 설정해야 합니다. 사용할 정점·픽셀 셰이더와 텍스처를 렌더러에 알려 주고, 셰이더가 필요로 하는 변환 행렬, 광원 위치, 색, 안개 설정 등 다른 상수들을 설정합니다.
구체적인 방법은 상위 수준의 렌더링 전략과 대상 플랫폼에 크게 의존하므로 여기서 더 구체적으로 설명하기는 어렵지만 10.11절에서 몇 가지 예를 제시합니다. 여기서는 두 번째 단계에 집중하겠습니다. 그림 10.37의 맨 위 상자에 해당하며, 렌더링할 정점 데이터를 API에 전달하는 단계입니다.
당시 프로그래머에게는 무엇을 보낼지, 각 데이터를 어떻게 패킹하고 표현할지, 메모리의 비트를 어떻게 배치해야 가장 효율적인지에 관해 상당한 자유가 있습니다.
정점마다 어떤 값을 제공해야 할까요? 기본적인 답은 ‘삼각형 렌더링에 사용하려는 어떤 속성이든’입니다. 결국 정점 셰이더와 픽셀 셰이더의 필수 출력은 두 가지뿐입니다. 첫째, 정점 셰이더는 하드웨어가 래스터화할 수 있도록 각 정점의 위치를 출력해야 합니다. 보통 클립 공간으로 지정하므로 하드웨어가 원근 나눗셈과 화면 공간 좌표 변환을 대신 수행합니다(10.3.5절).
둘째, 픽셀 셰이더의 유일한 필수 출력은 보통 알파 채널을 포함한 색 값입니다. 필수적인 것은 이 두 출력뿐입니다. 물론 올바른 클립 공간 좌표를 구하려면 모델 공간에서 클립 공간으로 가는 행렬이 필요할 것입니다. 한 묶음의 삼각형에 있는 모든 정점이나 픽셀에 적용되는 이런 매개변수는 셰이더 상수(shader constant)로 전달할 수 있습니다.
개념적으로는 렌더 문맥에 속하며 필요에 따라 사용하는 커다란 벡터 값 테이블입니다. 실제로는 정점 셰이더용 레지스터 집합과 픽셀 셰이더가 접근하는 별도 레지스터 집합이 있는 경우가 보통입니다.
정점 단위로 흔히 저장하는 정보는 다음과 같습니다.
•
위치. 정점의 위치를 나타냅니다. 3차원 벡터, 2차원 화면 공간 위치, 또는 이미 클립 공간으로 변환되어 정점 셰이더를 그대로 통과하는 위치일 수 있습니다. 3차원 벡터라면 현재 모델·뷰·투영 변환을 거쳐 클립 공간으로 변환해야 합니다. 정규화되지 않고 화면 해상도에 따라 범위가 정해지는 2차원 창 좌표라면 정점 셰이더에서 클립 공간으로 되돌려야 합니다. 일부 하드웨어는 셰이더가 이미 화면 공간으로 투영된 좌표를 출력하도록 허용합니다. 스키닝된 모델이라면(10.8절) 그 정점에 영향을 주는 본의 인덱스와 가중치도 위치 데이터에 포함해야 합니다. 애니메이션 행렬은 여러 방식으로 전달할 수 있습니다. 표준적인 방법은 정점 셰이더 상수로 전달하는 것입니다. 일부 하드웨어에서 동작하는 더 새로운 방법은 별도 정점 스트림으로 전달하는 것입니다. 이 경우 순차적인 스트리밍이 아닌 임의 접근이므로 특수 명령으로 접근해야 합니다.
•
텍스처 매핑 좌표. 텍스처를 입힌 삼각형을 사용한다면 각 정점에 매핑 좌표 집합을 지정해야 합니다. 가장 단순한 경우에는 텍스처 맵 안의 2차원 위치이며 보통 (u,v)로 표기합니다. 멀티텍스처링에서는 텍스처 맵마다 별도의 좌표 집합이 필요할 수도 있습니다. 선택적으로 하나 이상의 좌표 집합을 절차적으로 생성할 수도 있습니다. 예를 들어 표면에 고보를 투영하는 경우입니다.
•
표면 법선. 대부분의 조명 계산에는 표면 법선이 필요합니다. 픽셀별 조명 계산에서 노멀 맵으로 표면 법선을 정하더라도, 접선 공간의 기저를 세우기 위해 정점 법선을 저장하는 경우가 많습니다.
•
색. 각 정점에 색 입력을 지정하면 유용할 때가 있습니다. 예를 들어 파티클을 렌더링할 때 파티클 색이 시간에 따라 바뀔 수 있습니다. 알파 같은 한 채널로 두 텍스처 층의 혼합 비율을 제어할 수도 있습니다. 아티스트가 정점 알파를 편집하여 혼합을 제어합니다. 오프라인에서 미리 계산한 정점별 조명 값을 넣을 수도 있습니다.
•
기저 벡터. 10.9절에서 설명했듯이 접선 공간 노멀 맵과 비슷한 몇몇 기법은 국소 접선 공간을 정의하기 위한 기저 벡터가 필요합니다. 기저 벡터와 표면 법선이 각 정점의 좌표 공간을 정의합니다. 래스터화 중 삼각형 전체에 이 벡터들을 보간하여 픽셀마다 근사적인 접선 공간을 제공합니다.
이를 염두에 두고 실제로 만날 법한 여러 상황에서 정점 데이터를 전달할 수 있는 C 구조체의 예를 보겠습니다.
가장 기본적인 정점 형식 중 하나는 3차원 위치, 표면 법선, 매핑 좌표를 담습니다. 단순한 난반사 맵을 쓰는 기본 삼각형 메시를 이 형식으로 저장합니다. 기저 벡터가 없으므로 이 형식으로 접선 공간 노멀 맵을 사용할 수는 없습니다.
// 변환하지 않았고 조명도 계산하지 않은 정점
struct RenderVertex {
Vector3 p; // 위치
float u, v; // 텍스처 매핑 좌표
Vector3 n; // 법선
};
C++
복사
접선 공간 노멀 맵을 사용하려면 기저 벡터를 포함해야 합니다.
// 기저 벡터가 있으며 변환과 조명 계산을 하지 않은 정점
struct RenderVertexBasis {
Vector3 p; // 위치
Vector3 n; // 법선
Vector3 tangent; // 첫 번째 기저 벡터
float det; // 접선 공간 변환의 행렬식(거울 대칭 플래그)
float u, v; // 텍스처 매핑 좌표
};
C++
복사
HUD, 텍스트 렌더링 및 기타 2차원 요소에 흔히 사용하는 형식은 화면 공간 좌표와 미리 조명이 반영된 색을 가진 정점입니다. 조명 계산을 하지 않으므로 법선을 제공할 필요가 없습니다.
// 조명이 미리 반영된 2차원 화면 공간 정점
struct RenderVertex2D {
float x, y; // 2차원 화면 공간 위치
unsigned argb; // 미리 조명이 반영된 색(0xAARRGGBB)
float u, v; // 텍스처 매핑 좌표
};
C++
복사
다음 정점은 3차원으로 표현되지만 그래픽스 API의 조명 엔진이 조명을 계산할 필요가 없습니다. 폭발, 불꽃, 자체 발광 물체 같은 파티클 효과와 경계 상자, 경유 지점, 표식 같은 디버깅 물체를 그릴 때 유용합니다.
// 변환하지 않았으며 조명이 반영된 정점
struct RenderVertexL {
Vector3 p; // 3차원 위치
unsigned argb; // 미리 조명이 반영된 색(0xAARRGGBB)
float u, v; // 텍스처 매핑 좌표
};
C++
복사
다음 예는 라이트맵과 범프 맵을 사용하는 기하 형상의 정점입니다. 라이트 매핑을 위한 기저 벡터와 두 벌의 UV를 가집니다. 하나는 일반적인 난반사 텍스처용이고, 다른 하나는 오프라인에서 계산하여 구워 넣은 조명을 저장하는 라이트맵용입니다.
// 라이트맵과 범프 맵을 사용하는 정점
struct RenderVertexLtMapBump {
Vector3 p; // 위치
Vector3 n; // 법선
Vector3 tangent; // 첫 번째 기저 벡터
float det; // 접선 공간 변환의 행렬식(거울 대칭 플래그)
float u, v; // 확산 맵과 범프 맵을 위한 일반 좌표
float lmu, lmv; // 라이트맵 텍스처 좌표
};
C++
복사
마지막은 골격 렌더링에 사용할 수 있는 정점입니다. 본 인덱스를 네 개의 8비트 값으로 저장하고, 가중치는 네 개의 float로 저장합니다.
// 라이트맵과 범프 맵을 사용하는 정점
struct RenderVertexSkinned {
Vector3 p; // 위치
Vector3 n; // 법선
Vector3 tangent; // 첫 번째 기저 벡터
float det; // 접선 공간 변환의 행렬식(거울 대칭 플래그)
float u, v; // 확산 맵과 범프 맵을 위한 일반 좌표
unsigned boneIndices; // 최대 네 본의 인덱스(8비트 값들)
Vector4 boneWeights; // 최대 네 본의 가중치
};
C++
복사
앞의 예는 모두 구조체로 선언했습니다. 보듯이 조합의 수는 빠르게 늘어날 수 있습니다. 이것을 단순하면서 효율적으로 처리하기란 어려운 일입니다. 한 가지 방법은 구조체의 배열(array of structures, AOS) 대신 배열의 구조체(structure of arrays, SOA) 형태로 필드를 할당하는 것입니다.
struct VertexListSOA {
Vector3 *p; // 위치들
Vector3 *n; // 법선들
Vector4 *tangentDet; // xyz에는 접선, w에는 det
Vector2 *uv0; // 첫 번째 채널의 매핑 좌표
Vector2 *uv1; // 두 번째 채널의 매핑 좌표
Vector2 *ltMap; // 라이트맵 좌표
unsigned *boneIndices; // 최대 네 본의 인덱스(8비트 값들)
Vector4 *boneWeights; // 최대 네 본의 가중치
unsigned *argb; // 정점 색
};
C++
복사
이 경우 어떤 값이 없으면 그 배열 포인터를 NULL로 두면 됩니다.
다른 방법은 가공되지 않은 메모리 블록을 쓰되, 정점 형식 클래스를 선언하고 접근 함수를 제공하는 것입니다. 접근 함수는 가변적인 스트라이드를 바탕으로 인덱스에 해당하는 정점 주소를 계산하고, 구조체 안의 가변적인 오프셋을 바탕으로 멤버에 접근합니다.
파이프라인의 좌표와 형식
정점 형식 설명에서 라이트맵·범프 맵 메시의 ‘라이트 매핑을 위한 기저 벡터’라고 한 부분은 일반적으로 접선 공간 범프 매핑용 기저를 뜻합니다. RenderVertexSkinned 위의 원문 주석도 ‘라이트맵·범프 맵 정점’으로 되어 있지만 그 구조체에는 라이트맵 전용 UV가 없습니다. 본문은 원형을 남기고 여기서 차이를 밝힙니다.
10.10.3 정점 단위 연산
메시 데이터를 API에 제출하면 다양한 정점 단위 계산이 수행됩니다. 고정 기능 파이프라인과 달리 셰이더 기반 렌더러에서는 이 계산이 정점 셰이더에서 이루어집니다. 정점 셰이더의 입력은 본질적으로 앞 절에서 설명한 구조체 중 하나입니다. 앞서 말했듯이 출력 형식은 여러 가지가 가능하지만, 두 가지 기본 책임을 충족해야 합니다.
첫째, 최소한 클립 공간 위치, 경우에 따라서는 화면 공간 위치를 출력해야 합니다. 둘째, 픽셀 셰이더가 셰이딩 계산에 필요로 하는 입력을 제공해야 합니다. 입력 스트림으로 받은 정점 값을 그대로 전달할 수 있는 경우도 많습니다. 그러나 원시 정점 값을 모델링 공간에서 조명을 계산하는 다른 좌표 공간으로 변환하거나 텍스처 좌표를 생성하는 계산이 필요한 경우도 있습니다.
정점 셰이더에서 흔히 수행하는 연산은 다음과 같습니다.
•
모델 공간의 정점 위치를 클립 공간으로 변환합니다.
•
골격 모델을 스키닝합니다.
•
법선과 기저 벡터를 조명 계산에 적절한 공간으로 변환합니다.
•
조명에 필요한 벡터 를 계산하고 적절한 좌표 공간으로 변환합니다.
•
정점 위치로부터 안개 밀도를 계산합니다.
•
텍스처 매핑 좌표를 절차적으로 생성합니다. 투영 스포트라이트, Doom 방식 체적 광원, 환경 매핑을 위해 시선 벡터를 법선에 대해 반사하기, 각종 가짜 반사 기법, 스크롤하거나 달리 움직이는 텍스처 등이 그 예입니다.
•
입력 정점 값이 이미 올바른 형식과 좌표 공간으로 되어 있다면 수정 없이 그대로 전달합니다.
구로 셰이딩을 사용한다면 이 단계에서 실제 조명 계산을 수행하고 그 결과를 보간할 수 있습니다. 이 장의 뒤에서 몇 가지 예를 보일 것입니다.
모델링 공간에서 클립 공간으로의 변환은 가장 흔한 연산이므로 과정을 복습하겠습니다. 행렬 곱셈을 사용합니다. 개념적으로 정점은 다음 변환들을 차례로 거칩니다.
•
모델 변환은 모델링 공간을 월드 공간으로 바꿉니다.
•
뷰 변환은 월드 공간을 카메라 공간으로 바꿉니다.
•
클립 행렬은 카메라 공간을 클립 공간으로 바꿉니다.
행렬 계산을 개념적으로 쓰면 다음과 같습니다.
실제로는 세 번의 행렬 곱셈을 따로 수행하지 않습니다. 물체 공간에서 클립 공간으로 변환하는 행렬 하나를 준비하고, 정점 셰이더 안에서는 이 행렬을 사용하여 한 번만 곱합니다.
10.10.4 클리핑
정점을 클립 공간으로 변환한 뒤 삼각형에 두 가지 중요한 검사, 클리핑과 컬링을 수행합니다. 대개 렌더링 API가 처리하므로 직접 구현할 일은 많지 않지만 동작 원리를 아는 것은 중요합니다. 여기서 설명하는 검사 순서가 특정 하드웨어의 실행 순서와 반드시 같지는 않습니다. 대부분의 하드웨어는 화면 공간에서 컬링하지만, 예전 소프트웨어 렌더러는 클리핑할 삼각형 수를 줄이려고 더 이른 단계인 3차원 공간에서 컬링했습니다.
정점을 화면 공간에 투영하기 전에 뷰 프러스텀 안에 완전히 들어 있는지 확인해야 합니다. 이 과정을 클리핑이라고 합니다. 보통 하드웨어가 수행하므로 여기서는 개요만 설명합니다.
다각형 클리핑의 표준 알고리즘은 서덜랜드–호지먼(Sutherland–Hodgman) 알고리즘입니다. 어려운 다각형 클리핑 문제를 쉬운 문제의 연속으로 나누어 해결합니다. 입력 다각형을 한 번에 한 평면씩 클리핑합니다.
한 평면에 대해 다각형을 클리핑하려면 다각형 둘레를 따라가며 각 변을 순서대로 평면에 대해 클리핑합니다. 변의 두 정점은 각각 평면 안쪽 또는 바깥쪽에 있을 수 있으므로 경우는 네 가지입니다. 그림 10.39처럼 각 경우는 출력 정점을 0개, 1개 또는 2개 만듭니다.
그림 10.39 — 변 하나를 클리핑하는 네 가지 경우
그림 10.39. 변 하나를 클리핑하는 네 가지 경우.
그림 10.40은 이 규칙을 적용하여 오른쪽 클립 평면에 대해 다각형을 자르는 예입니다. 클리퍼가 출력하는 것은 변이 아니라 정점임을 기억하겠습니다. 그림의 변은 설명을 돕기 위해 그려 놓은 것뿐입니다. 특히 마지막 클립 단계는 변 두 개를 출력하는 것처럼 보이지만 실제로는 정점 하나만 출력합니다. 마지막 변은 다각형을 닫기 위해 암묵적으로 연결됩니다.
각 단계가 끝날 때 남은 정점이 세 개 미만이면 보이지 않는 다각형으로 판정하여 버립니다. 정점이 한 개나 두 개만 출력되는 것은 불가능하다는 점에 주목하겠습니다. 한 번의 처리에서 출력되는 정점 수는 0이거나 적어도 3입니다.
일부 그래픽스 하드웨어는 3차원 또는 4차원에서 여섯 평면을 모두 클리핑하지 않습니다. 대신 근평면만 클리핑한 뒤 2차원에서 시저링(scissoring)하여 창에 맞게 자릅니다. 어떤 하드웨어에서는 클리핑이 느리므로 이 방법으로 성능을 높일 수 있습니다. 변형된 방법으로 가드 밴드(guard band)를 사용하기도 합니다.
화면 밖에 완전히 놓인 다각형은 버리고, 가드 밴드 안에 완전히 놓인 다각형은 3차원 클리핑 대신 시저링하며, 일부가 화면에 걸치면서 가드 밴드 밖으로 나간 다각형은 3차원으로 클리핑합니다.
그림 10.40 — 오른쪽 클립 평면에 대한 다각형 클리핑
그림 10.40. 오른쪽 클립 평면에 대한 다각형 클리핑.
10.10.5 뒷면 컬링
숨은 표면을 제거하는 두 번째 검사는 뒷면 컬링(backface culling)이라고 합니다. 카메라를 향하지 않는 삼각형을 제거하는 것이 목적입니다. 일반적인 닫힌 메시에서는 내부로 들어가는 것을 허용하지 않는 한 삼각형의 뒷면을 볼 일이 없어야 합니다. 불투명 메시에서 뒷면 삼각형 제거는 정확한 이미지를 만드는 데 꼭 필요한 것은 아닙니다.
그려도 더 가까운 앞면 삼각형이 덮기 때문입니다. 하지만 보이지 않는 것을 그리는 데 시간을 낭비하고 싶지 않으므로 보통 뒷면을 제거합니다. 이론적으로 삼각형의 절반 정도가 뒷면입니다. 실제로는 절반보다 적게 제거할 수 있습니다. 특히 정적인 배경은 애초에 뒷면 없이 제작하는 경우가 많습니다.
지형이 명백한 예입니다. 언덕 뒤편처럼 뒷면 삼각형 일부를 제거할 수는 있지만, 보통 카메라가 지면 위에 있으므로 대부분의 삼각형은 앞면입니다. 그러나 월드에서 자유롭게 움직이는 동적인 물체는 대략 절반이 뒷면입니다.
뒷면 삼각형은 투영 전 3차원에서도, 투영 후 2차원에서도 판별할 수 있습니다. 당시 그래픽스 하드웨어는 화면 공간의 정점 나열이 시계 방향인지 반시계 방향인지에 따라 2차원에서 뒷면을 제거합니다. 이 책에서 사용하는 왼손 좌표계에서는 앞면에서 보았을 때 정점을 시계 방향으로 나열하는 관례를 따릅니다. 따라서 그림 10.41처럼 화면에서 정점이 반시계 방향인 삼각형을 보통 제거합니다. 오른손 좌표계에서는 대개 반대 관례를 씁니다.
그림 10.41 — 화면 공간에서 정점이 반시계 방향인 삼각형의 뒷면 컬링
그림 10.41. 화면 공간에서 정점을 반시계 방향으로 나열한 삼각형의 뒷면 컬링.
API에서는 뒷면 컬링을 제어할 수 있습니다. 어떤 형상을 렌더링할 때는 끄고 싶을 수 있습니다. 형상을 반사시켰다면 반사가 면의 정점 순서를 뒤집으므로 컬링 방향도 뒤집어야 할 수 있습니다. 스텐실 그림자를 사용할 때는 한 패스에서 앞면을, 다른 패스에서 뒷면을 렌더링해야 합니다.
그림 10.42 — 3차원에서의 뒷면 컬링
그림 10.42. 3차원에서의 뒷면 컬링.
소프트웨어 렌더링과 하드웨어 렌더링은 병목이 다릅니다. 특히 하드웨어로 원시 데이터를 전달하는 데 필요한 대역폭이 중요한 차이입니다. 소프트웨어에서는 보통 3차원에서 뒷면 컬링을 합니다. 기본 아이디어는 카메라 위치가 삼각형 평면의 앞쪽에 있는지 판별하는 것입니다. 빠르게 검사하기 위해 미리 계산한 삼각형 법선을 저장합니다.
그림 10.42에서는 제거할 수 있는 뒷면 삼각형을 회색으로 표시했습니다. 뒷면 컬링은 삼각형이 뷰 프러스텀 안에 있는지 밖에 있는지에 의존하지 않습니다. 사실 카메라가 바라보는 방향에도 의존하지 않습니다. 삼각형에 대한 카메라의 상대 위치만 중요합니다.
3차원에서 뒷면을 판별하려면 삼각형 평면의 법선과, 눈에서 삼각형으로 향하는 벡터가 필요합니다. 삼각형 위의 어떤 점이든 쓸 수 있으며, 보통 정점 하나를 임의로 고릅니다. 이 두 벡터가 대체로 같은 방향이면, 즉 내적이 0보다 크면 삼각형은 뒷면입니다. 변형된 방법으로 평면 방정식의 d 값도 미리 계산하여 저장할 수 있습니다(9.5.1절).
그러면 내적 한 번과 스칼라 비교 한 번으로 검사합니다. 그럴듯하지만 동작하지 않는 최적화 요령도 짚고 넘어가겠습니다. 카메라 공간 또는 클립 공간에서 삼각형 법선의 z 성분만 사용하고 싶을 수 있습니다. z가 양수라면 카메라 반대쪽을 향하므로 제거해도 될 것처럼 보입니다. 그러나 그림 10.42에서 원으로 표시한 사례는 이 생각이 틀리는 예입니다.
10.10.6 래스터화, 셰이딩, 출력
클리핑 뒤에는 식 (10.8)~(10.9)에 따라 정점을 투영하고 출력 창의 화면 좌표로 매핑합니다. 물론 이 좌표는 ‘연속적인’ 부동소수점 좌표입니다(1.1절). 하지만 보통 그리는 픽셀은 이산적입니다. 그렇다면 실제로 어떤 픽셀을 그릴지 어떻게 정할까요? 이 질문에 답하는 알고리즘은 의외로 복잡합니다.
잘못 답하면 삼각형 사이에 틈이 생깁니다. 알파 블렌딩을 사용한다면 같은 픽셀을 두 번 이상 그려도 문제가 될 수 있습니다. 즉 삼각형들로 표현한 표면을 그릴 때 각 픽셀이 정확히 한 번씩 그려져야 합니다. 다행히 그래픽스 하드웨어가 처리하므로 세부 사항으로 애쓸 필요는 없습니다.
래스터화 중 렌더링 시스템은 렌더링 창 밖 픽셀을 버리는 시저링을 수행할 수 있습니다. 다각형이 화면 가장자리에 맞게 클리핑되었다면 이런 픽셀이 생길 수 없지만, 성능상 그 클리핑 단계를 생략하는 편이 유리할 수도 있습니다. 가드 밴드는 클리핑과 시저링 사이의 성능 절충을 조정하는 기법입니다(10.10.4절).
하드웨어가 주어진 삼각형의 어떤 픽셀을 그릴지 정확히 어떻게 결정하는지까지 이해할 필요는 없더라도, 각 픽셀을 어떻게 처리하는지는 이해해야 합니다. 개념적으로 다섯 가지 기본 단계를 수행합니다.
1.
보간. 텍스처 좌표, 색, 법선처럼 정점에서 계산한 값들을 면 전체에 걸쳐 보간합니다. 셰이딩 전에 각 값의 해당 픽셀 보간값을 계산해야 합니다.
2.
깊이 검사. 셰이딩하려는 픽셀이 더 가까운 픽셀에 가려진다면 깊이 버퍼를 사용하여 버립니다(10.10.1절). 경우에 따라 픽셀 셰이더가 깊이를 바꿀 수 있으므로, 그때는 셰이딩 뒤로 검사를 미루어야 합니다.
3.
셰이딩. 픽셀 셰이딩은 픽셀의 색을 계산하는 과정입니다. 셰이더 기반 하드웨어에서는 여기서 픽셀 셰이더가 실행됩니다. 그림자 맵을 쓰거나 다른 조명 패스를 수행하는 대신 실제로 프레임 버퍼에 물체를 그리는 기본적인 포워드 렌더링 패스라면, 보통 먼저 조명을 계산한 뒤 안개를 사용하는 경우 안개 효과를 적용합니다. 픽셀 셰이더는 RGB 색뿐 아니라 알파도 출력합니다. 알파는 보통 픽셀의 ‘불투명도’로 해석하며 블렌딩에 사용합니다. 다음 절에서 픽셀 셰이더의 여러 예를 보입니다.
4.
알파 검사. 픽셀의 알파 값에 따라 픽셀을 버립니다. 여러 검사가 가능하지만 가장 흔한 것은 ‘너무 투명한’ 픽셀을 버리는 것입니다. 이런 보이지 않는 픽셀을 써도 프레임 버퍼는 변하지 않을 수 있지만, 깊이 버퍼까지 갱신하지 않도록 버려야 합니다.
5.
쓰기. 깊이와 알파 검사를 통과하면 프레임 버퍼와 깊이 버퍼를 갱신합니다. 깊이 버퍼는 기존 깊이를 새 값으로 바꾸기만 하면 됩니다. 프레임 버퍼 갱신은 더 복잡합니다. 블렌딩을 사용하지 않으면 새 색이 기존 색을 대체합니다. 블렌딩을 사용하면 새 색과 기존 색을 섞고, 알파 값이 두 색의 상대적인 기여도를 제어합니다. 하드웨어에 따라 덧셈, 뺄셈, 곱셈 같은 다른 수학 연산도 흔히 제공합니다.











