개발하는 리프터 꽃게맨입니다.

[물방울책 공부록] Chapter 11. Geometry Shader 본문

카테고리 없음

[물방울책 공부록] Chapter 11. Geometry Shader

파워꽃게맨 2024. 10. 6. 20:29

테셀레이션 단계를 사용하지 않는다고 가정하면, 지오메트리 쉐이더 단계는 정점 쉐이더와 픽셀 쉐이더 단계 사이에 위치한 선택적 단계이다.

 

정점 쉐이더가 정점을 입력으로 받는 반면, 지오메트리 쉐이더는 전체 프리미티브를 입력으로 받는다. 예를 들어, 삼각형 목록을 그리는 경우, 지오메트리 쉐이더 프로그램은 목록의 각 삼각형 T에 대해 실행된다.

for(UINT i = 0; i < numTriangles; ++i)
   OutputPrimitiveList = GeometryShader(T[i].vertexList);

 

각 삼각형의 세 정점이 지오메트리 쉐이더에 입력되며, 지오메트리 쉐이더는 프리미티브 목록을 출력한다.

 

정점 쉐이더가 정점을 생성하거나 삭제할 수 없는 것과 달리, 지오메트리 쉐이더의 주요 이점은 기하학을 생성하거나 삭제할 수 있다는 것이다. 이는 GPU에서 흥미로운 효과를 구현할 수 있게 해준다.

 

예를 들어, 입력된 프리미티브는 하나 이상의 다른 프리미티브로 확장될 수 있으며, 지오메트리 쉐이더는 조건에 따라 프리미티브를 출력하지 않을 수도 있다.

 

출력된는 프리미티브는 입력된 프리미티브와 동일한 유형일 필요는 없다.

예를 들어, 지오메트리 쉐이더의 일반적인 응용은 한 점을 쿼드(두 개의 삼각형)로 확장하는 것이다.

 

지오메트리 쉐이더에서 출력되는 프리미티브는 정점 목록으로 정의된다. 지오메트리 쉐이더를 더나는 정점의 위치는 동차 클립 공간으로 변환되어야 한다.

 

지오메트리 쉐이더 단계 후에는 동차 클립 공간에서 프리미티브를 정의하는 정점 목록이 제공된다. 이 정점들은 이후에 일반적인 방식으로 래스터화된다.

 

학습 목표:

1. 지오메트리 쉐이더를 프로그래밍하는 방법을 배우기

2. 지오메트리 쉐이더를 사용하여 빌보드를 효율적으로 구현하는 방법을 알아보기

3. 자동 생성된 프리미티브 ID와 그 응용 사례를 인식하기

4. 텍스처 배열을 생성하고 사용하는 방법을 알아보고, 그것이 왜 유용한지 이해하기

5. 알파 투 커버리지가 알파 컷아웃의 앨리어싱 문제를 해결하는 데 어떻게 도움이 되는지 이해하기.

 

Geometry Shader 프로그래밍

 

지오메트리 쉐이더를 프로그래밍하는 것은 정점 쉐이더나 픽셀 쉐이더를 프로그래밍하는 것과 유사하지만, 몇 가지 차이점이 있다.

[maxvertexcount(N)]
void ShaderName (
    PrimitiveType InputVertexType InputName[NumElements],
    inout StreamOutputObject<OutputVertexType> OutputName)
{
    // Geometry shader body…
}

 

먼저, 지오메트리 쉐이더가 한 번 호출될 때 출력할 최대 정점 수를 지정해야 한다.

(지오메트리 쉐이더는 프리미티브마다 호출된다.)

 

이는 쉐이더 정의 전에 다음 속성 구문을 사용하려 최대 정점 수를 설정하여 수행된다.

[maxvertexcount(N)]

 

여기서 N은 지오메트리 쉐이더가 한 번 호출될 떄 출력할 최대 정점 수이다.

지오메트리 쉐이더가 호출될 때 출력할 수 있는 정점 수는 가변적이지만, 정의된 최대값을 초과할 수는 없다.

 

성능을 위해서 maxvertexcount는 가능한 작게 설정해야 한다. [NVIDIA08]에 따르면 GS(Geometry shader)는 1~20개의 스칼라를 출력할 떄 최적 성능을 발휘하며, 27~40개의 스칼라를 출력할 때 성능이 50%까지 떨어진다고 한다.

 

호출당 출력되는 스칼라 수는 maxvertexcount와 출력 정점 탕비 구조체의 스칼라 수의 곱이다.

 

이러한 제약을 실무에서 준수하는 것은 어렵기 때문에, 최적 성능보다 낮은 성능을 수용하거나 지오메트리 쉐이더를 사용하지 않는 대안을 선택할 수 있다. 하지만 대안 구현은 다른 단점을 가질 수 있으므로, 지오메트리 쉐이더 구현이 여전히 더 나은 선택일 수 있다.

 

게다가 [NVIDIA08]의 권장 사항은 2008년 (1세대 지오메트리 쉐이더 기준)이므로, 그 이후로는 성능이 개선되었을 가능성이 있다.

 

지오메트리 쉐이더는 두 개의 매개변수를 받는다.

입력 매개변수와 출력 매개변수이다. (사실 더 많은 매개변수를 받을 수 있지만, 이는 나중에 다루는 특별한 주제이다.)

 

입력 매개변수는 항상 프리미티브를 정의하는 정점 배열이다.

점은 하나의 정점, 선은 두 개의 정점, 삼각형은 세 개의 정점, 인접성이 있는 선은 네 개의 정점, 인접성이 있는 삼각형은 여섯 개의 정점으로 정의된다.

 

입력된 정점의 정점 타입은 정점 쉐이더가 반환하는 정점 타입이다.

입력 매개변수는 지오메트리 쉐이더로 입력되는 프리미티브의 타입을 설명하는 프리미티브 타입으로 접두어를 붙여야 한다.

 

프리미티브 타입은 다음 중 하나일 수 있다.

1. point

2. line

3. triangle

4. lineadj

5. triangleadj

 

지오메트리 쉐이더로 입력되는 프리미티브는 항상 완전한 프리미티브이다.

따라서 지오메트리 쉐이더는 list와 strip을 구분할 필요가 없다.

 

예를 들어, 삼각형 스트립을 그리는 경우에도 지오메트리 쉐이더는 스트립 내의 각 삼각형에 대해 실행되며, 각 삼각형의 세 정점이 지오메트리 쉐이더에 입력으로 전달된다. 이때 여러 프리미티브에 의해 공유되는 정점이 지오메트리 쉐이더에서 여러 번 처리되기 때문에 추가적인 오버헤드가 발생한다.

 

출력 매개변수는 항상 inout 수정자를 가진다. 또한, 출력 매개변수는 항상 스트림 타입이다.

스트림 타입은 지오메트리 쉐이더가 출력하는 기하학을 정의하는 정점 목록을 저장한다.

 

지오메트리 쉐이더는 내장된 Append 메서드를 사용하여 출력 스트림 목록에 정점을 추가한다.

void StreamOutputObject<OutputVertexType>::Append(OutputVertexType v);

 

스트림 타입은 템플릿 타입으로, 템플릿 인수는 출력되는 정점의 타입을 지정하는 데 사용된다.

가능한 스트림 타입에는 세 가지가 있다.

 

PointStream<OutputVertexType>

점 목록을 정의하는 정점 목록

 

LineStream<OutputVertexType>

선 스트립을 정의하는 정점 목록

 

TriangleStream<OutputVertexType>

삼각형 스트립을 정의하는 정점 목록

 

지오메트리 쉐이더에서 출력된 정점은 프리미티브를 형성하며, 출력되는 프리미티브의 타입은 스트림 타입으로 표시된다. 선과 삼각형의 경우, 출력 프리미티브는 항상 스트립이다. 그러나 리스트 형태의 선과 삼각형은 내장된 RestartStrip 메서드를 사용하여 시뮬레이션할 수 있다.

void StreamOutputObject<OutputVertexType>::RestartStrip()

 

예를 들어, 삼각형 리스트를 출력하려면 세 개의 정점이 출력 스트림에 추가될 떄마다 RestartStrip을 호출하면 된다.

 

예시 1) GS는 최대 4개의 정점을 출력, 입력 프리미티브는 선이며, 출력은 삼각형 스트립

[maxvertexcount(4)]
void GS(line VertexOut gin[2],
        inout TriangleStream<GeoOut> triStream)
{
    // Geometry shader body...
}

 

예시 2) GS는 최대 32개의 정점을 출력, 입력 프리미티브는 삼각형이며, 출력은 삼각형 스트립

[maxvertexcount(32)]
void GS(triangle VertexOut gin[3],
        inout TriangleStream<GeoOut> triStream)
{
    // Geometry shader body...
}

 

예시 3) GS는 최대 4개의 정점을 출력, 입력 프리미티브는 점이며, 출력은 삼각형 스트립

[maxvertexcount(4)]
void GS(point VertexOut gin[1],
        inout TriangleStream<GeoOut> triStream)
{
    // Geometry shader body...
}

 

다음 지오메트리 쉐이더는 Append와 RestartStrip 메서드를 사용한다.

이 쉐이더는 삼각형을 입력으로 받아 그것을 세분화하고, 네 개의 세분화된 삼각형을 출력한다.

struct VertexOut
{
    float3 PosL    : POSITION;
    float3 NormalL : NORMAL;
    float2 Tex     : TEXCOORD;
};

struct GeoOut
{
    float4 PosH    : SV_POSITION;
    float3 PosW    : POSITION;
    float3 NormalW : NORMAL;
    float2 Tex     : TEXCOORD;
    float FogLerp  : FOG;
};

void Subdivide(VertexOut inVerts[3], out VertexOut outVerts[6])
{
    VertexOut m[3];

    // Compute edge midpoints.
    m[0].PosL = 0.5f * (inVerts[0].PosL + inVerts[1].PosL);
    m[1].PosL = 0.5f * (inVerts[1].PosL + inVerts[2].PosL);
    m[2].PosL = 0.5f * (inVerts[2].PosL + inVerts[0].PosL);

    // Project onto unit sphere
    m[0].PosL = normalize(m[0].PosL);
    m[1].PosL = normalize(m[1].PosL);
    m[2].PosL = normalize(m[2].PosL);

    // Derive normals.
    m[0].NormalL = m[0].PosL;
    m[1].NormalL = m[1].PosL;
    m[2].NormalL = m[2].PosL;

    // Interpolate texture coordinates.
    m[0].Tex = 0.5f * (inVerts[0].Tex + inVerts[1].Tex);
    m[1].Tex = 0.5f * (inVerts[1].Tex + inVerts[2].Tex);
    m[2].Tex = 0.5f * (inVerts[2].Tex + inVerts[0].Tex);

    outVerts[0] = inVerts[0];
    outVerts[1] = m[0];
    outVerts[2] = m[2];
    outVerts[3] = m[1];
    outVerts[4] = inVerts[2];
    outVerts[5] = inVerts[1];
}

void OutputSubdivision(VertexOut v[6],
                       inout TriangleStream<GeoOut> triStream)
{
    GeoOut gout[6];

    [unroll]
    for(int i = 0; i < 6; ++i)
    {
        // Transform to world space.
        gout[i].PosW = mul(float4(v[i].PosL, 1.0f), gWorld).xyz;
        gout[i].NormalW = mul(v[i].NormalL, (float3x3)gWorldInvTranspose);

        // Transform to homogeneous clip space.
        gout[i].PosH = mul(float4(v[i].PosL, 1.0f), gWorldViewProj);
        gout[i].Tex = v[i].Tex;
    }

    // We can draw the subdivision in two strips:
    // Strip 1: bottom three triangles
    // Strip 2: top triangle
    [unroll]
    for(int j = 0; j < 5; ++j)
    {
        triStream.Append(gout[j]);
    }

    triStream.RestartStrip();
    triStream.Append(gout[1]);
    triStream.Append(gout[5]);
    triStream.Append(gout[3]);
}

[maxvertexcount(8)]
void GS(triangle VertexOut gin[3], inout TriangleStream<GeoOut> triStream)
{
    VertexOut v[6];
    Subdivide(gin, v);
    OutputSubdivision(v, triStream);
}

 

코드를 분석해보면

하나의 큰 삼각형을 동일한 크기의 4개의 삼각형으로 세분화하는 모습이다.

더하여 세 개의 새로운 정점은 원래 삼각형의 변의 중점이다.

 

지오메트리 쉐이더는 입력된 프리미티브에 대해 조건에 따라 출력하지 않을 수 있다.

이렇게 하면, 지오메트리 쉐이더가 기하학을 "파괴"할 수 있으며, 이는 일부 알고르짐에 유용할 수 있다.

 

지오메트리 쉐이더에서 프리미티브를 완료할 만큼 충분한 정점을 출력하지 않으면, 해당 부분적인 프리미티브는 버려진다.

 

나무 빌보드 데모

나무가 멀리 있을 떄는 효율성을 위해 빌보딩 기법을 사용한다.

즉, 완전한 3D 나무 기하학을 렌더링하는 대신, 3D 나무 이미지를 사각형에 그려 넣는다.

 

멀리서 보면 빌보드가 사용되고 있다는 것을 알 수 없다.

하지만 중요한 것은 빌보드가 항상 카메라를 향하도록 하는 것이다. (그렇지 않으면 착사 효과가 깨진다.)

 

y축이 위쪽이고 xz 평면이 지면이라고 한다면, 나무 빌보드는 일반적으로 y축을 기준으로 정렬되며, xz 평면에서 카메라를 바라본다.

월드 공간에서 빌보드의 중심 위치 C = (Cx, Cy, Cz) 와 카메라의 위치 E = (Ex, Ey, Ez) 가 주어지면, 월드 공간에 대한 빌보드의 로컬 좌표계를 설명할 수 있는 충분한 정보를 얻을 수 있다.

 

월드 공간에 대한 빌보드의 로컬 좌표계와 빌보드의 크기를 알면, 빌보드 쿼드의 정점은 다음과 같이 구할 수 있다.

 

이 데모에서는 지면 위에 약간 떠 있는 점 프리미티브 목록을 작성할 것이다.  D3D11_PRIMITIVE_TOPOLOGY_POINTLIST

이 점들은 우리가 그리고자 하는 빌보드의 중심을 나타낸다.

 

지오메트리 쉐이더에서 이러한 점들을 빌보드 쿼드로 활장할 것이다. 또한, 지오메트리 쉐이더에서 빌보드의 월드 행렬을 계산할 것이다.

 

빌보드를 구현하는 일반적인 CPU 방식은 동적 정점 버퍼에서 빌보드마다 4개의 정점을 사용하는 것이다.

그런 다음 카메라가 움직일 때마다. ID3D11DeviceContext::Map 메서드를 사용하여 정점을 CPU에서 업데이트하여 빌보드가 카메라를 향하도록 한다.

 

이 방식은 빌보드마다 IA 단계에 4개의 정점을 제출해야 하며, 동적 정점 버퍼를 업데이트해야 하므로 오버헤드가 발생한다.

 

지오메트리 쉐이더 방식을 사용하면 지오메트리 쉐이더가 빌보드를 확장하고 카메라를 향하게 하기 때문에 정적 정점 버퍼를 사용할 수 있다. 게다가 빌보드의 메모리 사용량은 매우 적으며, 빌보드당 하나의 정점만 IA 단계에 제출하면 된다.

 

빌보드의 버텍스 구조

struct TreePointSprite
{
    XMFLOAT3 Pos;
    XMFLOAT2 Size;
};

 

정점은 월드 공간에서 빌보드의 중심 위치를 나타내는 점을 저장한다. 또한, 크기를 저장하는 멤버도 포함되어 있는데, 이 멤버는 빌보드의 너비/높이를 나타내며 (월드 공간 단위로 스케일링됨), 이를 통해 지오메트리 쉐이더는 빌보드를 확장할 때 얼마나 크게 만들어야 하는지 알 수 있다.

 

정점마다 크기를 다르게 설정함으로써 다양한 크기의 빌보드를 쉽게 구현할 수 있다.

const D3D11_INPUT_ELEMENT_DESC InputLayoutDesc::TreePointSprite[2] =
{
    {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"SIZE", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0}
};

 

이 구조는 입력 어셈블러에서 사용할 정점 버퍼의 레이아웃을 정의한다. "POSITION"은 빌보드 중심의 3D 좌표를 저장하고, "SIZE"는 너비와 높이 값을 저장한다.

 

텍스처 배열을 제외하면, "Tree Billboard" 데모의 나머지 C++ 코드는 이미 익숙한 Direct3D 코드일 것이다.

정점 버퍼 생성, 이펙트 처리, 드로우 메서드 호출 등..

 

헤더 파일

 cbuffer cbFixed
 {
 //
 // Compute texture coordinates to stretch texture over quad.
 //
 float2 gTexC[4] =
 {
 float2(0.0f, 1.0f),
 float2(0.0f, 0.0f),
 float2(1.0f, 1.0f),
 float2(1.0f, 0.0f)
 };
 };
Texture2DArray gTreeMapArray;
 SamplerState samLinear
 {
 Filter   = MIN_MAG_MIP_LINEAR;
 AddressU = WRAP;
 AddressV = WRAP;
 };
struct VertexIn
 {
 float3 PosW  : POSITION;
 float2 SizeW : SIZE;
 };
 struct VertexOut
 {
 float3 CenterW : POSITION;
 float2 SizeW   : SIZE;
 };
 struct GeoOut
 {
 float4 PosH    : SV_POSITION;
 float3 PosW    : POSITION;
 float3 NormalW : NORMAL;
 float2 Tex     : TEXCOORD;
 uint PrimID    : SV_PrimitiveID;
 };

버텍스 쉐이더

VertexOut VS(VertexIn vin)
{
    VertexOut vout;
    vout.CenterW = vin.PosW;
    vout.SizeW = vin.SizeW;
    return vout;
}

지오메트리 쉐이더

[maxvertexcount(4)]
void GS(point VertexOut gin[1],
        uint primID : SV_PrimitiveID,
        inout TriangleStream<GeoOut> triStream)
{
    float3 up = float3(0.0f, 1.0f, 0.0f);
    float3 look = gEyePosW - gin[0].CenterW;
    look.y = 0.0f; // y축 기준으로 정렬
    look = normalize(look);
    float3 right = cross(up, look);

    float halfWidth = 0.5f * gin[0].SizeW.x;
    float halfHeight = 0.5f * gin[0].SizeW.y;
    
    float4 v[4];
    v[0] = float4(gin[0].CenterW + halfWidth*right - halfHeight*up, 1.0f);
    v[1] = float4(gin[0].CenterW + halfWidth*right + halfHeight*up, 1.0f);
    v[2] = float4(gin[0].CenterW - halfWidth*right - halfHeight*up, 1.0f);
    v[3] = float4(gin[0].CenterW - halfWidth*right + halfHeight*up, 1.0f);
    
    // 정점 데이터를 triangle strip 형태로 출력
    GeoOut gout;
    for (int i = 0; i < 4; ++i)
    {
        gout.PosH = mul(v[i], gViewProj);
        gout.PosW = v[i].xyz;
        gout.NormalW = look;
        gout.Tex = gTexC[i];
        gout.PrimID = primID;
        triStream.Append(gout);
    }
}

 

픽셀 쉐이더

float4 PS(GeoOut pin, uniform int gLightCount, uniform bool gUseTexure, uniform bool gAlphaClip, uniform bool gFogEnabled) : SV_Target
{
    pin.NormalW = normalize(pin.NormalW);
    float3 toEye = gEyePosW - pin.PosW;
    float distToEye = length(toEye);
    toEye /= distToEye;

    float4 texColor = float4(1, 1, 1, 1);
    if (gUseTexure)
    {
        float3 uvw = float3(pin.Tex, pin.PrimID % 4);
        texColor = gTreeMapArray.Sample(samLinear, uvw);
        if (gAlphaClip)
        {
            clip(texColor.a - 0.05f);
        }
    }

    // 조명, 안개 등의 계산
    ...
}

 

SV_PrimitiveID

 struct GeoOut
 {
 	float4 PosH    : SV_POSITION;
 	float3 PosW    : POSITION;
 	float3 NormalW : NORMAL;
 	float2 Tex     : TEXCOORD;
 	uint PrimID    : SV_PrimitiveID;
 };

 

이 예제에서 지오메트리 쉐이더는 SV_PrimitiveID 시맨틱을 가진 특수한 부호 없는 정수 매개변수를 사용한다.

이 시멘틱이 지정되면, IA 단계에서 각 프리미티브에 대해 자동으로 프리미티브 ID를 생성한다. n개의 프리미티브를 그리는 드로우 콜이 실행되면, 첫 번째 프리미티브는 0으로 레이블링되고, 두 번째 프리미티브는 1, 마지막 프리미티브는 n-1로 레이블링된다. 프리미티브 ID는 각 드로우 콜마다 고유하다.

 

빌보드 예제에서는 지오메트리 쉐이더가 이 ID를 사용하지 않지만, 프리미티브 ID를 출력 정점에 기록하여 픽셀 쉐이더로 전달한다. 픽셀 쉐이더는 이 프리미티브 ID를 사용해 텍스처 배열에 접근하며, 이는 다음 섹션에서 설명한다.

 

지오메트리 쉐이더가 없는 경우 프리미티브ID 매개변수는 픽셀 쉐이더의 매개변수 목록에 추가될 수 있다.

float4 PS(VertexOut pin, uint primID : SV_PrimitiveID) : SV_Target
{
    // 픽셀 셰이더 본문…
}

 

그러나 지오메트리 쉐이더가 있는 경우, 프리미티브 ID 매개변수는 지오메트리 쉐이더 시그니처에 먼저 포함되어야 한다.

그런 다음, 지오메트리 쉐이더는 이 프리미티브 ID를 적절하게 사용하고, 픽셀 쉐이더로 전달할 수 있다.

 

VertexOut VS(VertexIn vin, uint vertID : SV_VertexID)
{
}

입력 어셈블러가 정점 ID를 생성하도록 할 수도 있다. 이를 위에 정점 쉐이더 시그니처에 uint 유형의 추가 매개변수 SV_VertexID 시맨틱으로 추가한다.

 

Draw 호출에서는 드로우 콜에서 사용된 정점들이 0, 1, ... , n-1로 레이블링된다. 여기서 n은 해당 드로우 콜에 있는 정점의 수이다. DrawIndexed 호출에서는 정점 ID가 정점 인덱스 값에 대응된다.

 

텍스처 배열

텍스처 배열은 텍스처들의 배열을 저장한다. C++ 코드에서 텍스처 배열은 ID3D11Texture2D 인터페이스로 표현된다. (단일 텍스처와 동일한 인터페이스 사용) ID3D11Texture2D 객체를 생성할 때 ArraySize라는 속성을 설정하여 저장할 텍스처 요소의 수를 지정할 수 있다. 하지만 우리는 텍스처를 생성할 때 D3DX에 의존했기 때문에 이 데이터를 명시적으로 설정하지 않았다. effect 파일에서 텍스처 배열은 Texture2DArray 타입으로 표현된다.

Texture2DArray gTreeMapArray;

 

 

여기서 궁금할 수 있는 점은 왜 텍스처 배열이 필요한가이다. 

Texture2D TexArray[4];
...
float4 PS(GeoOut pin) : SV_Target
{
    float4 c = TexArray[pin.PrimID % 4].Sample(samLinear, pin.Tex);
}

 

왜 그냥 이렇게 하지 않는지 궁금할 수 있다.

이 코드는 "샘플러 배열 인데스는 리터럴 표현이어야 합니다."라는 오류를 발생시킨다.

즉, 배열 인덱스가 픽셀마다 달라지는 것을 허용하지 않는다.

 

만약 리터럴 배열 인덱스를 지정하면 이 코드는 작동할 것이다.

float4 c = TexArray[2].Sample(samLinear, pin.Tex);

 

 

빌보드 데모에서 텍스처 배열을 샘플링하는 코드는 다음과 같다.

float3 uvw = float3(pin.Tex, pin.PrimID % 4);
texColor = gTreeMapArray.Sample(samLinear, uvw);

 

텍스처 배열을 사용할 때는 세 개의 텍스처 좌표가 필요하다. 첫 번째와 두 번째는 일반적인 2D 텍스처 좌표이며, 세 번째 좌표는 텍스처 배열 내에서의 인덱스이다. 예를 들어 , 0은 배열의 첫 번째 텍스처를, 1은 배열의 두 번째 텍스처를, 2는 세 번째 텍스처를 참조한다.

 

"빌보드" 데모에서는 서로 다른 나무 텍스처로 구성된 4개의 텍스처 요소를 가진 텍스처 배열을 사용한다. 하지만 하나의 드로우 콜에서 4개 이상의 나무를 그리기 때문에 프리미티 ID는 3을 넘게 된다. 따라서, 프리미티브 ID를 4로 나눈 나머지를 사용하여 유효한 배열 인덱스인 0, 1, 2, 3 으로 매핑한다.

 

텍스처 배열의 장점 중 하나는 서로 다른 텍스처를 사용하는 여러 프리미티브를 하나의 드로우 콜에서 그릴 수 있다는 점이다. 보통은 다음과 같식으로 해야한다.

SetTextureA();
DrawPrimitivesWithTextureA();
SetTextureB();
DrawPrimitivesWithTextureB();
...
SetTextureZ();
DrawPrimitivesWithTextureZ();

 

각 텍스처 설정과 드로우 콜은 오버헤드가 발생한다.

하지만 텍스처 배열을 사용하면 이를 하나의 설정과 하나의 드로우 콜로 줄일 수 있다.

 

SetTextureArray();
DrawPrimitivesWithTextureArray();

 

텍스처 배열 로딩

텍스처 배열을 로드하는 기능을 직접 수행해야 한다.

그 과정은 다음과 같이 요약된다.

 

1. 각 텍스처를 파일에서 하나씩 개별적으로 시스템 메모리에 생성한다.

2. 텍스처 배열을 생성한다.

3. 각 개별 텍스처를 텍스처 배열의 요소로 복사한다.

4. 텍스처 배열에 대해 쉐이더 리소스 뷰를 생성한다.

 

// 이하 생략 //

 

텍스처 서브리소스

텍스처 배열에 대해 다뤘으니 이제 서브리소스에 대해 이야기할 수 있다.

 

위 그림은 여러 텍스처로 구성된 텍스처 배열의 예시를 보여준다.

각 텍스처는 고유한 mipmap 체인을 가지고 있다. Direct3D API는 텍스처의 요소와 해당 요소의 전체 mipmap 체인을 가리킬 때 array slice 라는 용어를 사용한다. Direct3D API는 특정 레벨의 모든 mipmap을 가리킬 때 mip slice 라는 용어를 사용하며, 서브리소스는 텍스처 배열의 요소에서 단일 mipmap 레벨을 나타낸다.

 

텍스처 배열 인덱스와 mipmap 레벨이 주어지면, 우리는 텍스처 배열 내의 서브리소스에 접근할 수 있다.

그러나 서브리소스는 선형 인덱스로도 레이블될 수 있으며, Driect3D는 선형 인덱스를 사용한다.

 

inline UINT D3D11CalcSubresource(UINT MipSlice, UINT ArraySlice, UINT MipLevels);

 

해당 함수는 mip 레벨, 배열 인데긋, 그리고 mipmap 레벨의 수를 기반으로 선형 서브리소스 인덱스를 계산하는 데 사용된다.

 

알파-투-커버리지

 

"트리 빌보드" 데모를 실행할 떄, 특정 거리에서 나무 빌보드 컷아웃의 가장자리가 블록 모양으로 보일 수 있다. 이는 텍스처에서 나무가 아닌 부분의 픽셀을 마스킹하는 데 사용하는 clip 함수 때문에 발생한다. clip 함수는 픽셀을 유지하거나 제거하는 이분법적인 동작을 하며, 부드러운 전환이 없다.

 

빌보드와 카메라 사이의 거리가 영향을 미치는데, 가까운 거리에서는 이미지가 확대되어 블록 아티팩트가 커지고, 먼 거리에서는 저해상도의 mipmap 레벨이 사용된다.

 

이 문제를 해결하는 한 가지 방법은 알파 테스트 대신 투명 블렌딩을 사용하는 것이다. 선형 텍스처 필터링 덕분에 가장자리 픽셀들이 약간 흐려지며, 불투명한 픽셀에서 마스킹된 픽셀로 부드러운 전환이 이루어진다. 투명 블렌딩은 결과적으로 불투명한 픽셀에서 마스크된 픽셀로의 부드러운 페이드 아웃 효과를 유도한다. 그러나 투명 블렌딩은 정렬과 뒤에서 앞으로의 렌더링을 요구한다. 소수의 나무 빌보드를 정렬하는 오버헤드는 크지 않지만, 숲이나 넓은 초원을 렌더링할 때는 매 프레임마다 정렬이 필요하므로 비용이 커질 수 있다. 게다가 뒤에서 앞으로의 렌더링은 massive overdraw 를 발생시켜 성능에 치명적인 영향을 줄 수 있다.

 

MASS가 도움이 될 수 있다고 생각할 수도 있는데, MSAA는 픽셀 중심에서 픽셀 쉐이더를 한 번 실행한 후, 가시성과 커버리지 정보를 서브픽셀들과 공유한다. 여기서 중요한 점은 커버리지가 폴리곤 레벨에서 결정된다는 것이다. 따라서 MSAA는 알파 채널로 정의된 나무 빌보드 컷아웃의 가장자리를 감지하지 못하고, 텍스처가 매핑된 사각형의 가장자리만 처리할 수 있다. 그렇다면 Direct3D가 커버리지를 계산할 때, 알파 채널을 고려하도록 할 수 있을까? 

그것이 바로 알파-투-커버리지 라는 기법이다.

 

MSAA가 활성화되고 알파-투-커버리지 (ID3D11BlendState의 멤버)가 활성화되면, 하드웨어는 픽셀 쉐이더에서 반환된 알파 값을 참고하여 커버리지를 결정한다.

 

예를 들어, 4x MSAA에서 픽셀 쉐이더의 알파 값이 0.5인 경우, 4개의 서브픽셀 중 2개가 커버된다고 가정할 수 있으며, 이는 부드러운 가장자리를 만들어낸다.

 

D3D11_BLEND_DESC a2CDesc = {0};
a2CDesc.AlphaToCoverageEnable = true;
a2CDesc.IndependentBlendEnable = false;
a2CDesc.RenderTarget[0].BlendEnable = false;
a2CDesc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
ID3D11BlendState* AlphaToCoverageBS;
HR(device->CreateBlendState(&a2CDesc, &AlphaToCoverageBS));

 

알파-투-커버리지 블렌드 상태는 위와같이 생성할 수 있다.

 

일반적인 조언은 알파 마스킹된 컷아웃 텍스처에 대해 항상 알파-투-커버리지를 사용하라는 것이다.

하지만 MSAA가 활성화되어 있어야 한다.