반응형

1. 방향광 하나에 섀도우 맵 한 장을 붙이고, PCF로 가장가지를 부드럽게 만들기

2. 섀도우 맵 한장의 한계를 CSM(Cascade Shadow Map)으로 해결하기

[프레임 순서]
Shadow Pass (빛 시점 깊이만 기록)  →  Scene Pass (깊이 비교로 그림자 판정)  →  Sky  →  Tonemap  →  ImGui

1. 섀도우 맵 기본

원리

  1. 빛의 시점에서 씬을 렌더링해 깊이만 기록합니다. 이것이 섀도우 맵이고, 빛이 닿는 가장 가까운 표면의 깊이를 담고 있습니다.
  2. 본 렌더링에서 각 픽셀의 월드 좌표를 빛 공간으로 변환합니다.
  3. 섀도우 맵에 기록된 깊이보다 이 픽셀이 더 멀면, 빛과 픽셀 사이에 무언가 있다는 뜻이므로 그림자입니다.

방향광은 평행광이라서 원근 투영이 아니라 직교 투영(Orthographic)을 씁니다.

 

part1. ShadowMap 리소스

깊이 텍스처를 쓰기(DSV)와 읽기(SRV) 두 용도로 써야 해서, 포맷을 R32_TYPELESS로 만들고 뷰마다 포맷을 따로 지정해야 합니다.

용도 뷰 포맷
그림자 패스에서 깊이 쓰기 DSV D32_FLOAT
씬 패스에서 깊이 읽기 SRV R32_FLOAT
void ShadowMap::Init(ID3D12Device* device, DescriptorHeap* dsvHeap, DescriptorHeap* srvHeap, UINT size)
{
    mDsvHeap = dsvHeap; mSrvHeap = srvHeap; mSize = size;

    auto desc = CD3DX12_RESOURCE_DESC::Tex2D(DXGI_FORMAT_R32_TYPELESS, size, size, 1, 1, 1, 0,
                                             D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL);
    D3D12_CLEAR_VALUE clear{};
    clear.Format = DsvFormat;               // D32_FLOAT
    clear.DepthStencil.Depth = 1.0f;

    CD3DX12_HEAP_PROPERTIES heap(D3D12_HEAP_TYPE_DEFAULT);
    ThrowIfFailed(device->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &desc,
        D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE, &clear, IID_PPV_ARGS(&mTex)));

    D3D12_DEPTH_STENCIL_VIEW_DESC dsv{};
    dsv.Format = DsvFormat;
    dsv.ViewDimension = D3D12_DSV_DIMENSION_TEXTURE2D;
    mDsv = mDsvHeap->Allocate();
    device->CreateDepthStencilView(mTex.Get(), &dsv, mDsv.cpu);

    D3D12_SHADER_RESOURCE_VIEW_DESC srv{};
    srv.Format = DXGI_FORMAT_R32_FLOAT;
    srv.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
    srv.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
    srv.Texture2D.MipLevels = 1;
    mSrv = mSrvHeap->Allocate();
    device->CreateShaderResourceView(mTex.Get(), &srv, mSrv.cpu);
}

Begin은 상태를 PIXEL_SHADER_RESOURCE -> DEPTH_WRTIE 로 바꾸고, Clear하고, 렌더 타킷 없이 DSV만 바인딩합니다. End는 상태를 되돌립니다.

 

part2. 깊이 전용 PSO

기존 PSO를 복사해서 픽셀 셰이더와 렌더 타깃을 없애면 깊이만 기록하는 PSO가 됩니다.

auto shadowPso = pso;
shadowPso.PS = {};                                       // 픽셀 셰이더 없음 = 깊이만 기록
shadowPso.NumRenderTargets = 0;
shadowPso.RTVFormats[0] = DXGI_FORMAT_UNKNOWN;
shadowPso.DSVFormat = ShadowMap::DsvFormat;
shadowPso.RasterizerState.DepthBias = 1000;              // 고정 바이어스
shadowPso.RasterizerState.SlopeScaledDepthBias = 2.0f;   // 비스듬한 면일수록 더 밀어냄
shadowPso.RasterizerState.DepthBiasClamp = 0.0f;

하드웨어 Depth Bias는 기록하는 기핑 자체를 살짝 뒤로 밀어서 섀도우 여드름(shadow acne)을 줄입니다.

기울기 바이어스(slope-scaled bias)는 빛에 비스듬한 면일수록 더 많이 밀어냅니다.

 

part3. 빛 시점 행렬

처음에는 단순하게 원전 중심, 반경 R의 고정 박스로 시작했습니다.

const float R = scene.light.shadowRadius;
const XMVECTOR dir = XMVector3Normalize(XMLoadFloat3(&scene.light.direction));
const XMVECTOR center = XMVectorZero();
const XMVECTOR eye = center - dir * (2.0f * R);

const XMMATRIX lightView = XMMatrixLookAtLH(eye, center, XMVectorSet(0, 1, 0, 0));
const XMMATRIX lightProj = XMMatrixOrthographicLH(2.0f * R, 2.0f * R, 0.1f, 4.0f * R);
XMStoreFloat4x4(&mLightViewProj, lightView * lightProj);

center = 원점 중심, R = shadowRadius

 

part4. 비교 샘플러와 PCF

그림자 판정에는 일반 샘플러 대신 비교 샘플러(SamplerComparisonState)를 씁니다.

SampleCmp는 기록된 깊이와 비교한 결과(0 또는 1)를 하드웨어가 주변 4텍셀에서 보간해서 돌려줍니다.

// s2: 그림자 비교용, 범위 밖은 흰색(= 그림자 없음)
CD3DX12_STATIC_SAMPLER_DESC(2, D3D12_FILTER_COMPARISON_MIN_MAG_LINEAR_MIP_POINT,
    D3D12_TEXTURE_ADDRESS_MODE_BORDER, D3D12_TEXTURE_ADDRESS_MODE_BORDER, D3D12_TEXTURE_ADDRESS_MODE_BORDER,
    0.0f, 16, D3D12_COMPARISON_FUNC_LESS_EQUAL, D3D12_STATIC_BORDER_COLOR_OPAQUE_WHITE),

여기서 3x3으로 9번 샘플링해서 평균을 내면(PCF, Percentage Closer Filtering) 그림자 가장자리가 부드러워집니다.

float ShadowFactor(float3 worldPos, float3 Ng)
{
    float3 p  = worldPos + Ng * gShadowParams.z;            // normal offset
    float4 lp = mul(float4(p, 1.0f), gLightViewProj);
    float3 ndc = lp.xyz / lp.w;

    float2 uv = ndc.xy * float2(0.5f, -0.5f) + 0.5f;        // NDC -> UV (Y 반전)
    if (any(uv < 0.0f) || any(uv > 1.0f) || ndc.z > 1.0f)
        return 1.0f;

    float depth = ndc.z - gShadowParams.x;                  // bias
    float texel = gShadowParams.y;

    float sum = 0.0f;
    [unroll] for (int y = -1; y <= 1; ++y)
        [unroll] for (int x = -1; x <= 1; ++x)
            sum += gShadowMap.SampleCmpLevelZero(gShadowSampler, uv + float2(x, y) * texel, depth);
    return sum / 9.0f;
}

그림자는 직사광에만 곱합니다. IBL(환경광)까지 어두워지면 그림자 속이 새까맣게 됩니다.

float3 direct = (diffuse + specular) * gLightColor * NdotL * shadow;
float3 color  = direct + ambient + emissive;

여드름(acne)을 잡는 세 가지 방법

 

방법 위치 효과 부작용
Depth Bias / Slope Bias 그림자 패스 PSO 기록되는 깊이를 뒤로 밀기 과하면 그림자가 물체에서 떨어짐(피터패닝)
상수 Bias 셰이더 비교 시  비교 깊이를 앞으로 당기기 마찬가지로 피터패닝
Normal Offset 셰이더 샘플 위치 샘플 위치를 표면 법선 방향으로 띄우기 과하면 그림자 모양이 틀어짐

노멀 오프셋에는 노멀맵이 적용된 N이 아니라 지오메트리 법선 Ng를 써야합니다.

노멀맵 법선은 픽셀마다 흔들려서 그림자도 같이 흔들립니다.

 

한계

반경 R하나로 씬 전체를 덮으면, 넓게 잡으면 흐려지고 좁게 잡으면 먼 곳에 그림자가 없습니다. 2048² 섀도우 맵으로 반경 30m를 덮으면 텍셀 하나가 약 3cm인데, 이건 카메라 바로 앞에서는 계단이 보이고 먼곳에서는 낭비되는 해상도이다.


2. Cascaded Shadow Maps

구상

카메라 시야(절두체)를 거리별로 4구간으로 나누고, 구간마다 섀도우 맵을 따로 만듭니다.

카메라 ──┬── 구간 0 : 좁은 범위 → 아주 선명
         ├── 구간 1
         ├── 구간 2
         └── 구간 3 : 넓은 범위 → 거칠지만 화면에서 작게 보임
항목 Basic Shadow Map Cascaded Shadow Maps
섀도우 맵  Texture2D 1장 Texture2DArray 4장
빛 시점 박스 원점 중심 고정 카메라 절두체의 각 구간에 맞춰 매프레임 계산
안정성 고정이라 문제 없음 경계 구 + 텍셀 스냅으로 흔들림 방지
셰이더 맵 하나 샘플링 픽셀 거리로 구간을 선택해서 샘플링

 

part1. Shadow Map -> Texture2DArray

리소스 하나에 배열 4장을 만들고, 쓰기는 장마다 DSV 하나, 배열 전체를 SRV 하나로 만듭니다.

auto desc = CD3DX12_RESOURCE_DESC::Tex2D(DXGI_FORMAT_R32_TYPELESS, size, size, kCascades, 1, 1, 0,
                                         D3D12_RESOURCE_FLAG_ALLOW_DEPTH_STENCIL);
// ... CreateCommittedResource 동일 ...

// 쓰기: 장마다 DSV
for (UINT c = 0; c < kCascades; ++c)
{
    D3D12_DEPTH_STENCIL_VIEW_DESC dsv{};
    dsv.Format = DsvFormat;
    dsv.ViewDimension = D3D12_DSV_DIMENSION_TEXTURE2DARRAY;
    dsv.Texture2DArray.FirstArraySlice = c;
    dsv.Texture2DArray.ArraySize = 1;
    mDsv[c] = mDsvHeap->Allocate();
    device->CreateDepthStencilView(mTex.Get(), &dsv, mDsv[c].cpu);
}

// 읽기: 배열 전체를 SRV 하나로
D3D12_SHADER_RESOURCE_VIEW_DESC srv{};
srv.Format = DXGI_FORMAT_R32_FLOAT;
srv.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2DARRAY;
srv.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srv.Texture2DArray.MipLevels = 1;
srv.Texture2DArray.ArraySize = kCascades;
mSrv = mSrvHeap->Allocate();
device->CreateShaderResourceView(mTex.Get(), &srv, mSrv.cpu);

배리어는 리소스 전체에 한 번만 걸고, 구간마다 DSV만 바꿔서 Clear하고 그립니다.

void ShadowMap::BeginCascade(ID3D12GraphicsCommandList* cmd, UINT cascade)
{
    cmd->OMSetRenderTargets(0, nullptr, FALSE, &mDsv[cascade].cpu);
    cmd->ClearDepthStencilView(mDsv[cascade].cpu, D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr);
}

 

part2. 구간 분할(Partical Split Scheme)

방식 공식 특징
균등 분할 n + (f - n) * i / N 가까운 곳의 해상도가 부족
로그 분할 n * (f / n)^(i / N) 가까운 곳에 너무 몰림
혼합 λ·log + (1-λ)·uniform λ로 조절 (보통 0.5~0.8)
float splits[N + 1];
splits[0] = nearZ;
for (UINT i = 1; i <= N; ++i)
{
    const float p = (float)i / N;
    const float logSplit = nearZ * powf(farZ / nearZ, p);
    const float uniSplit = nearZ + (farZ - nearZ) * p;
    splits[i] = lambda * logSplit + (1.0f - lambda) * uniSplit;
}

farZ는 카메라의 far(1000)가 아니라 그림자 거리(shadow Distance, 50m)입니다. 1000m를 4장으로 나누면 의미가 없습니다.

 

part3. 구간마다 빛 시점 박스 맞추기

  1. 카메라 절두체를 해당 구간 거리로 잘라서 8개 꼭짓점(월드 공간)을 구합니다.
  2. 꼭짓점을 감싸는 경계 구(bounding sphere)를 구합니다.
  3. 구 중심을 바라보는 빛 시점 직교 행렬을 만듭니다.
  4. 텍셀 스냅을 적용합니다.
for (UINT c = 0; c < N; ++c)
{
    // 1) 이 구간의 절두체 꼭짓점: NDC 큐브의 8점을 역변환
    const XMMATRIX proj  = XMMatrixPerspectiveFovLH(camera.FovY(), camera.Aspect(), splits[c], splits[c + 1]);
    const XMMATRIX invVP = XMMatrixInverse(nullptr, view * proj);

    XMVECTOR corners[8];
    int k = 0;
    for (float x : { -1.0f, 1.0f })
        for (float y : { -1.0f, 1.0f })
            for (float z : { 0.0f, 1.0f })
                corners[k++] = XMVector3TransformCoord(XMVectorSet(x, y, z, 1.0f), invVP);

    // 2) 경계 구
    XMVECTOR center = XMVectorZero();
    for (const XMVECTOR& p : corners) center += p;
    center /= 8.0f;

    float radius = 0.0f;
    for (const XMVECTOR& p : corners)
        radius = std::max(radius, XMVectorGetX(XMVector3Length(p - center)));
    radius = ceilf(radius * 16.0f) / 16.0f;      // 미세한 값 변화로 크기가 흔들리지 않게 양자화

    // 3) 빛 시점 행렬: 구간 밖에서 그림자를 드리우는 물체도 포함하도록 extra만큼 뒤로
    const float extra = 50.0f;
    const XMVECTOR eye = center - dir * (radius + extra);
    const XMMATRIX lightView = XMMatrixLookAtLH(eye, center, up);
    XMMATRIX lightProj = XMMatrixOrthographicOffCenterLH(-radius, radius, -radius, radius,
                                                         0.0f, 2.0f * radius + extra);

    // 4) 텍셀 스냅: 월드 원점이 항상 텍셀 경계에 오도록 박스를 미세 이동
    const XMVECTOR origin = XMVector3TransformCoord(XMVectorZero(), lightView * lightProj);
    const float half = size * 0.5f;
    const float ox = XMVectorGetX(origin) * half, oy = XMVectorGetY(origin) * half;
    lightProj.r[3] += XMVectorSet((roundf(ox) - ox) / half, (roundf(oy) - oy) / half, 0.0f, 0.0f);

    XMStoreFloat4x4(&mCascadeVP[c], lightView * lightProj);
    (&mCascadeSplits.x)[c] = splits[c + 1];
    (&mCascadeTexel.x)[c]  = 2.0f * radius / size;   // 이 구간의 텍셀 하나 = 월드 몇 m
}

왜 박스(AABB)가 아니라 구인가?
꼭짓점을 빛 공간 AABB로 감싸면 카메라가 회전할 때 박스 크기가 계속 바뀝니다. 그러면 텍셀 크기도 매 프레임 바뀌어서 그림자가 출렁입니다. 구는 회전해도 반지름이 같아서 텍셀 크기가 고정됩니다. 공간은 조금 낭비되지만 안정성이 더 중요합니다.

텍셀 스냅이 왜 필요한가?
카메라가 이동하면 박스도 따라 움직입니다. 박스가 텍셀보다 작은 단위로 움직이면, 같은 물체가 매 프레임 다른 텍셀 경계에 걸려 그림자 가장자리가 지글거립니다(shimmering). 박스를 항상 텍셀 크기의 정수배로만 움직이게 하면 해결됩니다.

투영 행렬의 r[3] = (x 이동, y 이동, z 이동, 1)
                                          ↑ w = 1 은 절대 건드리면 안 됨
→ 기존 값에 "더하기(+=)"만 한다

 

part4. 깊이 패스: 오브젝트 상수 재사용

같은 물체를 4번 그리니까 월드 행렬 상수는 한 번만 업로드하고, 구간마다 PerFrame(빛 VP)만 바꿉니다.

std::vector<std::pair<const Entity*, D3D12_GPU_VIRTUAL_ADDRESS>> draws;
for (const Entity& e : scene.Entities())
{
    if (!e.visible || !e.mesh) continue;
    PerObjectCB po{};
    XMStoreFloat4x4(&po.world, XMMatrixTranspose(e.transform.Matrix()));
    draws.push_back({ &e, cb.Alloc(&po, sizeof(po)) });
}

shadow.Begin(cmd);
cmd->SetGraphicsRootSignature(mRootSig.Get());
cmd->SetPipelineState(mShadowPso.Get());
for (UINT c = 0; c < N; ++c)
{
    shadow.BeginCascade(cmd, c);

    PerFrameCB pf{};
    XMStoreFloat4x4(&pf.viewProj, XMMatrixTranspose(XMLoadFloat4x4(&mCascadeVP[c])));
    cmd->SetGraphicsRootConstantBufferView(0, cb.Alloc(&pf, sizeof(pf)));

    for (const auto& [e, addr] : draws)
    {
        cmd->SetGraphicsRootConstantBufferView(1, addr);
        e->mesh->Draw(cmd);
    }
}
shadow.End(cmd);

 

part5. 상수 버퍼 레이아웃

c++과 HLSL의 필드 순서가 정확히 같아야 합니다. float3 뒤에는 16바이트 정렬용 패딩을 둡니다.

struct PerFrameCB
{
    XMFLOAT4X4 viewProj;
    XMFLOAT3   cameraPos;     float pad0;
    XMFLOAT3   lightDir;      float pad1;
    XMFLOAT3   lightColor;    float ambient;
    XMFLOAT3   cameraForward; float pad2;
    XMFLOAT4X4 cascadeViewProj[ShadowMap::kCascades];
    XMFLOAT4   cascadeSplits;   // 구간별 끝 거리
    XMFLOAT4   cascadeTexel;    // 구간별 텍셀의 월드 크기
    XMFLOAT4   shadowParams;    // x = bias, y = 1/size, z = normal offset(텍셀 단위), w = 디버그
};

UI에는 Distance, Split Lambda, Bias, Normal Offset, Show Cascades를 노출했습니다.

 

part6. 셰이더: 구간 선택과 샘플링

Texture2DArray gShadowMap : register(t7);

float ShadowFactor(float3 worldPos, float3 Ng, out uint cascade)
{
    // 카메라 전방축 기준 거리로 구간 선택
    float viewDepth = dot(worldPos - gCameraPos, gCameraForward);
    cascade = 0;
    [unroll] for (uint c = 0; c < 3; ++c)
        if (viewDepth > gCascadeSplits[c]) cascade = c + 1;

    if (viewDepth > gCascadeSplits[3]) { cascade = 4; return 1.0f; }   // 그림자 거리 밖

    // 노멀 오프셋을 구간의 텍셀 크기에 맞춰 환산
    float3 p  = worldPos + Ng * (gShadowParams.z * gCascadeTexel[cascade]);
    float4 lp = mul(float4(p, 1.0f), gCascadeViewProj[cascade]);
    float3 ndc = lp.xyz / lp.w;

    float2 uv = ndc.xy * float2(0.5f, -0.5f) + 0.5f;
    if (any(uv < 0.0f) || any(uv > 1.0f) || ndc.z > 1.0f)
        return 1.0f;

    float depth = ndc.z - gShadowParams.x;
    float texel = gShadowParams.y;

    float sum = 0.0f;
    [unroll] for (int y = -1; y <= 1; ++y)
        [unroll] for (int x = -1; x <= 1; ++x)
            sum += gShadowMap.SampleCmpLevelZero(gShadowSampler,
                                                 float3(uv + float2(x, y) * texel, cascade), depth);
    return sum / 9.0f;
}
  1. 구간 선택: 카메라와의 직선 거리가 아니라 카메라 전방축으로 투영한 거리(view-space z)를 씁니다. 분할도 이 기준으로 했기 때문입니다.
  2. 배열 샘플링: float3(uv, sliceIndex)의 z에 구간 번호를 넣습니다.
  3. 노멀 오프셋 단위: 먼 구간일수록 텍셀 하나가 덮는 면적이 커서 여드림이 더 잘 생깁니다. 그래서 오프셋을 텍셀 단위로 받아서 구간마다 월드 크기로 환산합니다.

part7. 디버그: 구간 색 표시

static const float3 kCascadeColors[5] =
{
    float3(1.0f, 0.4f, 0.4f), float3(0.4f, 1.0f, 0.4f),
    float3(0.4f, 0.4f, 1.0f), float3(1.0f, 1.0f, 0.4f),
    float3(1.0f, 1.0f, 1.0f),
};
...
if (gShadowParams.w > 0.5f)
    color *= kCascadeColors[cascade];

 

 

반응형
반응형

이전 글까지는 방향광 하나로만 물체를 비췄습니다. 그래서 금속은 하이라이트 주변 말고는 어둡고, 빛을 등진 면은 임시 ambient 상수로만 밝혔습니다. 이번에는 주변 환경 전체를 광원으로 쓰는 IBL을 넣어서 PBR 머티리얼이 원래 의도대로 보이게 됩니다.

단계 내용 결과
1 HDR 환경맵 로딩, 스카이 박스 배경에 하늘
2 컴퓨트 셰이더로 큐브맵 변환, Irradiance 그림자 쪽이 환경색으로 은은하게 밝아짐(난반사 IBL)
3 Prefiltered맵, BRDF LUT 금속과 유리에 환경이 비침 (정반사 IBL)
더보기

 

파일 역할
Renderer/SkyPass.h/.cpp, Shaders/Sky.hlsl 스카이박스
Renderer/IBL.h/.cpp IBL 리소스 생성과 사전 계산
Shaders/IBLCommon.hlsli 큐브 면 → 방향 변환, 공용 cbuffer
Shaders/IBLSampling.hlsli Hammersley 수열, GGX 중요도 샘플링
Shaders/EquirectToCube.hlsl 파노라마 → 큐브맵
Shaders/Irradiance.hlsl 난반사용 Irradiance 맵
Shaders/Prefilter.hlsl 정반사용 Prefiltered 맵
Shaders/BrdfLut.hlsl BRDF LUT

 


1. HDR 환경맵과 스카이 박스

HDR 이미지 로딩

.hdr 파일은 0~1로 제한되지 않은 선형 float값을 담고 있습니다. 태양 부분은 수십에서 수천까지 올라갑니다.

stb_image의 stbi_loadf로 읽어서 R32G32B32A32_FLOAT 텍스처로 올립니다.

이를 위해 기존 텍스처 업로드 코드를 픽셀 포맷과 무관한 공통 함수로 분리했습니다.

// RGBA8, RGBA32F 등 포맷과 픽셀당 바이트 수만 받아서 업로드
void CreateInternal(Renderer& renderer, DXGI_FORMAT format, UINT bytesPerPixel,
                    const std::vector<MipData>& mips);

void Texture::LoadHDR(Renderer& renderer, const std::string& path)
{
    int w = 0, h = 0, comp = 0;
    float* pixels = stbi_loadf(path.c_str(), &w, &h, &comp, 4);   // 선형 float RGBA
    if (!pixels)
        throw std::runtime_error("HDR load failed: " + path);

    CreateInternal(renderer, DXGI_FORMAT_R32G32B32A32_FLOAT, 16,
                   { { (UINT)w, (UINT)h, reinterpret_cast<const uint8_t*>(pixels) } });
    stbi_image_free(pixels);
}

 

스카이박스 그리기

하늘은 무한히 먼 곳에 있으므로 카메라가 이동해도 따라오지 않고, 회전만 반영해야 합니다.

XMMATRIX view = camera.View();
view.r[3] = XMVectorSet(0, 0, 0, 1);                              // 이동 성분 제거
const XMMATRIX invVP = XMMatrixInverse(nullptr, view * camera.Proj());
// 풀스크린 삼각형을 깊이 1.0(가장 먼 곳)에 배치
VSOut VSMain(uint id : SV_VertexID)
{
    float2 uv = float2((id << 1) & 2, id & 2);
    VSOut o;
    o.ndc = uv * float2(2.0f, -2.0f) + float2(-1.0f, 1.0f);
    o.pos = float4(o.ndc, 1.0f, 1.0f);
    return o;
}

float4 PSMain(VSOut i) : SV_Target
{
    // 화면 픽셀 -> 그 픽셀이 바라보는 월드 방향
    float4 world = mul(float4(i.ndc, 1.0f, 1.0f), gInvViewProj);
    float3 dir   = normalize(world.xyz / world.w);
    return float4(gEnvMap.SampleLevel(gSampler, dir, 0).rgb * gSkyIntensity, 1.0f);
}

PSO의 깊이 설정이 핵심입니다.

pso.DepthStencilState.DepthWriteMask = D3D12_DEPTH_WRITE_MASK_ZERO;        // 깊이는 쓰지 않음
pso.DepthStencilState.DepthFunc      = D3D12_COMPARISON_FUNC_LESS_EQUAL;   // 1.0 == 1.0 통과

씬을 먼저 그리고 하늘을 나중에 그립니다. 물체가 그려진 픽셀은 깊이가 1.0보다 작아서 하늘이 탈락하고, 빈 픽셀(깊이1.0)에만 하늘이 칠해집니다.

 

방향 -> 파노라마UV

float2 DirToEquirect(float3 d)
{
    float u = 0.5f - atan2(d.z, d.x) / (2.0f * PI);   // 수평 각도
    float v = acos(clamp(d.y, -1.0f, 1.0f)) / PI;     // 수직 각도 (0 = 위, 1 = 아래)
    return float2(u, v);
}

1과 2의 과정이 다름.


2. 컴퓨트 셰이더로 큐브맵 만들기

왜 큐브맵인가

  • 파노라마는 극점(위아래)에서 픽셀이 한 점으로 모여 해상도가 낭비되고, 좌우 끝에 이음매가 생깁니다.
  • 큐브맵은 방향 벡터 하나로 바로 샘플링할 수 있고, 하드웨어가 면 사이 보간을 처리합니다.
  • 이후 IBL 계산이 전부 방향->값 형태라서 큐브맵이 자연스럽다.

하나의 리소스, 두가지 뷰

컴퓨트 셰이더는 텍스처에 쓰고, 렌더링 셰이더는 읽습니다. 

같은 리소스에 뷰를 두개 만듭니다.

// 6장짜리 텍스처 배열 = 큐브맵. 컴퓨트에서 쓸 수 있게 UAV 허용
auto desc = CD3DX12_RESOURCE_DESC::Tex2D(format, size, size, 6, (UINT16)mips, 1, 0,
                                         D3D12_RESOURCE_FLAG_ALLOW_UNORDERED_ACCESS);

// 읽기: 큐브맵 (방향 벡터로 샘플링)
srv.ViewDimension = D3D12_SRV_DIMENSION_TEXTURECUBE;
srv.TextureCube.MipLevels = mips;

// 쓰기: 2D 배열 (면 번호 = 배열 인덱스). 밉마다 하나씩
uav.ViewDimension = D3D12_UAV_DIMENSION_TEXTURE2DARRAY;
uav.Texture2DArray.MipSlice  = m;
uav.Texture2DArray.ArraySize = 6;

 

큐브 면의 텍셀 -> 방향

컴퓨트 스레드 하나가 텍셀 하나를 맡습니다.

Dispatch(x, y, 6)의 z가 면 번호입니다. D3D 큐브맵 규약(+X, -X, +Y,-Y, +Z, -Z)대로 방향을 만들어야 나중에 하드웨어 샘플링과 일치합니다.

float3 CubeDir(uint3 id)
{
    float2 uv = ((float2(id.xy) + 0.5f) / gSize) * 2.0f - 1.0f;   // -1..1, v는 아래로 증가
    float3 d;
    switch (id.z)
    {
    case 0:  d = float3( 1.0f, -uv.y, -uv.x); break;   // +X
    case 1:  d = float3(-1.0f, -uv.y,  uv.x); break;   // -X
    case 2:  d = float3( uv.x,  1.0f,  uv.y); break;   // +Y
    case 3:  d = float3( uv.x, -1.0f, -uv.y); break;   // -Y
    case 4:  d = float3( uv.x, -uv.y,  1.0f); break;   // +Z
    default: d = float3(-uv.x, -uv.y, -1.0f); break;   // -Z
    }
    return normalize(d);
}
// EquirectToCube.hlsl
[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
    if (id.x >= gSize || id.y >= gSize) return;
    float3 dir = CubeDir(id);
    gOut[id] = float4(gEquirect.SampleLevel(gSampler, DirToEquirect(dir), 0).rgb, 1.0f);
}

 

컴퓨트 파이프라인

// 루트 시그니처 (모든 IBL 패스 공용): b0 = 파라미터, t0 = 입력, u0 = 출력
params[0].InitAsConstants(4, 0);
params[1].InitAsDescriptorTable(1, &srvRange);
params[2].InitAsDescriptorTable(1, &uavRange);

// PSO
D3D12_COMPUTE_PIPELINE_STATE_DESC desc{};
desc.pRootSignature = rs;
desc.CS = CD3DX12_SHADER_BYTECODE(cs.Get());
device->CreateComputePipelineState(&desc, IID_PPV_ARGS(&pso));

그래픽스와 달리 SetComputeRootSignature, SetComputeRoot32BitConstants, SetComputeRootDescriptorTable, Dispatch 를 씁니다.

 

리소스 상태

리소스 상태 변화
큐브맵들 UNORDERED_ACCESS로 생성 -> 계산 후 PIXEL
일반 텍스처 컴퓨트에서도 읽을 수 있게 최종 상태를 PIXEL

읽기 전용 상태끼리는 OR로 합칠 수 있습니다. 픽셀 셰이더 전용 상태인 리소스를 컴퓨트 셰이더에서 읽으면 디버그 레이어 에러가 납니다.


4. 정반사 IBL: Split-sum 근사

정반사는 거칠기와 시야 방향에 따라 적분 범위가 달라서, 매 픽셀 계산하기엔 너무 무겁습니다. UE4가 도입한 Split-sum 근사로 두 덩어리로 나눠 미리 계산합니다.

정반사 ≈ [Prefiltered 환경(반사 방향, 거칠기)] × [F0 × LUT.x + LUT.y]
            ↑ 환경에만 의존                          ↑ 재질에만 의존

 

중요도 샘플링

반구를 균일하게 훑는 대신, GGX분포가 강한 방향(반사 방향 근처)에 샘플을 몰아줍니다.

// 저불일치 수열: 샘플이 고르게 퍼짐
float2 Hammersley(uint i, uint n)
{
    return float2(float(i) / float(n), float(reversebits(i)) * 2.3283064365386963e-10f);
}

// GGX 분포에 따라 하프 벡터 H를 선택
float3 ImportanceSampleGGX(float2 xi, float3 N, float roughness)
{
    float a = roughness * roughness;
    float phi      = 2.0f * PI * xi.x;
    float cosTheta = sqrt((1.0f - xi.y) / (1.0f + (a * a - 1.0f) * xi.y));
    float sinTheta = sqrt(1.0f - cosTheta * cosTheta);
    float3 H = float3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta);
    // 접선 공간 -> 월드 (생략)
}

 

Prefiltered 환경맵

거칠수록 반사가 흐려지고, 흐린 이미지는 해상도가 낮아도 됩니다. 그래서 밉 레벨 = 거칠기로 저장합니다(128x128, 밉 단계) 

for (UINT m = 0; m < mips; ++m)
{
    const UINT  size      = std::max(1u, baseSize >> m);
    const float roughness = (float)m / (float)(mips - 1);   // 0, 0.25, 0.5, 0.75, 1
    run(mPrefilterPso.Get(), mEnv.srv.gpu, mPrefiltered.uavs[m].gpu, size, roughness, 6);
}
float3 V = N;   // 근사: 시야 방향 = 반사 방향 = N
for (uint i = 0; i < SAMPLES; ++i)
{
    float3 H = ImportanceSampleGGX(Hammersley(i, SAMPLES), N, gRoughness);
    float3 L = normalize(2.0f * dot(V, H) * H - V);
    float NdotL = dot(N, L);
    if (NdotL > 0.0f)
    {
        sum    += gEnvCube.SampleLevel(gSampler, L, 0).rgb * NdotL;
        weight += NdotL;
    }
}
gOut[id] = float4(sum / weight, 1.0f);

 

BRDF LUT

시야 각도(NdotV)와 거칠기를 축으로, 프레넬의 스케일(A)과 바이어스(B)를 2D 텍스처(256x256, R16G16_FLOAT)에 저장합니다.

float G_Vis = (G * VdotH) / (NdotH * NdotV);
float Fc    = pow(1.0f - VdotH, 5.0f);
A += (1.0f - Fc) * G_Vis;   // F0에 곱해지는 부분
B += Fc * G_Vis;            // 더해지는 부분

이 LUT는 환경과 무관해서, 환경맵을 바꿔도 다시 계산할 필요가 없습니다.

 

계산 순서 (시작 시 한번)

파노라마 ──EquirectToCube──▶ 환경 큐브맵 (512)
                                 ├──Irradiance──▶ Irradiance 큐브맵 (32)
                                 └──Prefilter───▶ Prefiltered 큐브맵 (128, 밉 5)
                  BrdfLut ──────────────────────▶ BRDF LUT (256×256)

5. 셰이더 연결

씬 루트 시그니처에 t4~t6 슬롯과 LUT용 clamp샘플러(s1)를 추가했습니다. 기존 샘플러는 WRAP이라서, LUT 가장자리에서 거칠기 0과 1의 값이 섞이는 문제가 생기기 때문입니다.

// 프레넬: 거친 표면은 비스듬한 각도에서도 반사가 덜 강해짐
float3 F_SchlickRoughness(float NdotV, float3 F0, float roughness)
{
    return F0 + (max((1.0f - roughness).xxx, F0) - F0) * pow(saturate(1.0f - NdotV), 5.0f);
}

float3 kS_amb = F_SchlickRoughness(NdotV, F0, roughness);
float3 kD_amb = (1.0f - kS_amb) * (1.0f - metallic);

// 난반사: 노멀 방향의 irradiance
float3 irradiance = gIrradianceMap.SampleLevel(gSampler, N, 0).rgb;
float3 diffuseIBL = kD_amb * irradiance * albedo;

// 정반사: 반사 방향의 prefiltered 환경 × LUT
float3 R           = reflect(-V, N);
float3 prefiltered = gPrefilterMap.SampleLevel(gSampler, R, roughness * kPrefilterMaxLod).rgb;
float2 brdf        = gBrdfLut.SampleLevel(gClampSampler, float2(NdotV, roughness), 0).rg;
float3 specularIBL = prefiltered * (kS_amb * brdf.x + brdf.y);

float3 ambient = (diffuseIBL + specularIBL) * gIblIntensity;

roughness x 4 를 LOD로 넣으면 밉 사이를 보간해서 중간 거칠기도 표현됩니다.


결과

반응형
반응형

이번에는 표준 포맷인 glTF 2.0 모델을 불러와서, 지오메트리와 PBR 머티리얼, 텍스처를 엔진 구조로 옮깁니다. 테스트 모델은 Khronose 공식 샘플인 DamagedHelemt입니다.

 

  • 왜 glTF인가
  • PBR metallic-rouoghness 머티리얼이 표준 사양에 들어 있습니다.
  • .gltf(JSON과 외부 파일)와 .glb(바이너리 하나로 묶음) 두 형태가 있습니다.
  • 파서로는 단일 헤더 라이브러리인 cgltf를 썼습니다.

전체흐름

cgltf_parse_file + cgltf_load_buffers
 ├─ 1) 텍스처: 실제로 쓰이는 이미지만, (이미지, sRGB 여부) 단위로 캐시
 ├─ 2) 머티리얼: glTF material → Material (Scene 소유)
 ├─ 3) 메시: 프리미티브마다 Mesh 생성 + 머티리얼과 짝지음
 └─ 4) 노드: 메시가 달린 노드마다 Entity 생성 (월드 행렬 → Transform)

Model 클래스는 메시와 텍스처를 소유하고, 씬에는 머티리얼과 엔티티를 만들어 넣었습니다. 씬이 모델의 메시를 참조하므로 씬을 쓰는 동안 Model이 살아 있어야 합니다.


1.에셋 경로 정리

경로가 하드코딩 되어 있어서 소스 포러 기준으로 절대 경로를 쓰도록 바꿨습니다.

inline std::string AssetPath(const std::string& relative)
{
    return std::string(BAEK_ROOT_DIR "Assets/") + relative;
}

2. 지오메트리

glTF의 구조

  • mesh 하나는 primitive 여러 개로 이루어집니다. 프리미티브는 머티리얼 하나를 쓰는 조각입니다.
  • node는 변환과 계층을 가지고 메시를 참조합니다. 같은 메시를 여러 노드가 공유할 수 있습니다.
  • 정점 데이터는 accessor를 통해 읽습니다. cgltf_accessor_read_float를 쓰면 저장 타입(float, 정규화된 정수 등)과 무관하게 float로 받을 수 있습니다.

 

32비트 인덱스

16비트 인덱스는 정점 65,536개까지만 가리킬 수 있습니다 실제 모델에는 부족해서 Mesh의 인덱스를 전부 32비트(DXGI_FORMAT_R32_UINT)로 통일했습니다.

 

오른손->왼손 좌표계 변환

glTF는 오른손 좌표계, 왼손은 왼손 좌표계입니다. 그대로 쓰면 모델이 거울상으로 나옵니다.

대상 변환 이유
위치, 노멀, 탄젠트 Z 성분 부호 반전 좌표계 뒤집기
탄젠트 w 부호 반전 거울 반전하면 외적 방향이 뒤집힘
인덱스 삼각형마다 두개 교환 Z를 뒤집으면 도는 방향이 반대가 됨
노드 행렬 S x N x S (S=Z 반전) 정점과 같은 기준으로 맞춤

노드 행렬 변환은 이렇게 유도됩니다. 원래 w = v·M 이고 변환 후 정점은 v' = v·S입니다.

변환된 월드 좌표 w' = w·S = v'·(S·M·S)가 되므로, 새 행렬은 S·M·S입니다.

 

행렬 -> Transform 분해

glTF노드는 변환을 행렬로 줍니다. 엔티티는 위치/회전/스케일을 들고 있으므로 분해가 필요합니다.

  • 위치: 마지막 행
  • 스케일: 앞 세 행 각각의 길이
  • 회전: 스케일을 나눈 행렬에서 오일러 각을 추출합니다. 이때 Transform::Matrix()와 같은 회전 순서 (X->Y->Z)로 분해해야 합니다.

3. 머티리얼과 텍스처

내장 이미지

.glb는 이미지가 파일 안에 PNG/JPG 바이트 그대로 들어 있습니다.

stbi_load_from_memory로 디코딩하는 경로를 추가했습니다.

.gltf는 옆에 있는 이미지 파일을 URI로 참조하므로, %20같은 인코딩을 풀고 모델 폴더 기준으로 경로를 만듭니다.

 

색 공간

데이터 glTF에서의 색 공간 처리
baseColor, emissive 텍스처 sRGB _SRGB 포맷으로 업로드
normal, metallic-roughness 텍스처 선형 일반 포맷
baseColorFactor 선형 엔진 Material은 sRGB 기준이라 로딩 시 변환
emissiveFactor 선형 그대로 사용

같은 이미지가 용도에 따라 다른 포맷이 될 수 있어서, 텍스처 캐시 키를 (이미지, sRGB 여부) 쌍으로 잡았습니다.

 

탄젠트 생성

DamagedHelment은 파일에 탄젠트가 없습니다. 노멀 맵을 쓰려면 직접 계산해야 합니다.

  1. 삼각형마다 두 변과 UV변화량으로 U방향(T)과 V방향(B)을 구해 세 정점에 누적합니다.
  2. 정점마다 T를 노멀에 수직이 되게 다듬고(그람-슈미트), 정규화합니다.
  3. cross(N, T)와 누적된 B를 비교해 비탄젠트 부호(w)를 정합니다.

주의할 점: glTF 노멀 맵은 초록 채널이 이미지 위쪽입니다. 즉, V가 줄어는 방향입니다. 엔진의 절차적 테스트 텍스처는 반대 규약(V가 늘어나는 방향)을 썼습니다. 두 규약이 공존할 수 있는 건 셰이더가 B = cross(N, T) x w로 계산하고, w로 방향을 정하기 때문입니다.

 

Emissive

자체 발광 맵(t3)을 추가했습니다.

emissiveFactor x emissiveTexture를 조명 결과에 더합니다. 기본 factor가 0이라서, 맵이 없을 때 흰색 텍스처를 바인딩해도 발광하지 않습니다.

반응형
반응형

cgltf는 단일 헤더 라이브러리입니다. 구현은 .cpp 파일 딱 하나에서만 포함해야 합니다. 두 군데서 CGLTF_IMPLEMENTATION을 정의하면 링크 단계에서 중복 정의 에러가 납니다.

#define CGLTF_IMPLEMENTATION   // 한 파일에서만
#include "cgltf.h"

1.파일 열기와 해제

함수 역할
cgltf_parse_file(options, path, &data) .gltf(JSON) 또는 .glb를 파싱해 cgltf_datta 구조를 만듭니다. 외부 .bin 버퍼는 아직 읽지 않습니다.
cgltf_load_buffers(options, data, path) 정점과 인덱스가 담긴 버퍼를 메모리에 올립니다. 외부 .bin, data URL, .glb의 BIN 청크를 처리합니다. 세 번째 인자는 상대 경로의 기준이 되는 원본 파일 경로입니다.
cgltf_validate(data) (선택) 인덱스 범위 등 데이터 무결성을 검사합니다. 이번에는 사용하지 않았습니다.
cgltf_free(data) 파싱한 데이터와 버퍼를 모두 삭제(메모리 해제)합니다.

 

모든 함수는 cgltf_result를 반환하고, 성공하면 cgltf_result_success입니다.

cgltf_optioins를 0으로 초기화하면 파일 형식(.gltf/.glb)을 자동으로 판별합니다.

cgltf_options options{};
cgltf_data* data = nullptr;

if (cgltf_parse_file(&options, path.c_str(), &data) != cgltf_result_success)
    throw std::runtime_error("glTF parse failed");
if (cgltf_load_buffers(&options, data, path.c_str()) != cgltf_result_success)
{
    cgltf_free(data);   // 파싱은 성공했으므로 실패해도 해제는 필요
    throw std::runtime_error("glTF buffer load failed");
}

// ... 데이터 사용 ...

cgltf_free(data);

cgltf_data에서 꺼낸 포인터들은모두 cgltf_free 이후에는 무효입니다. 필요한  값은 그 전에 엔진 구조로 복사해 둬야 합니다.


2. 전체 구조(cgltf_data)

필드 내용
meshes, meshes_count 메시 목록, 개수
materials, materials_count 머티리얼 목록, 개수
nodes, nodes_count 노드 목록, 개수. 계층과 변환을 가짐
images, images_count 이미지 목룍, 개수
textures, textures_count 텍스처(이미지+샘플러) 목록, 개수
scene, scenes 기본 씬과 씬 목록

 

glTF안에서는 서로를 포인터로 참조합니다. 배열에서의 인덱스가 필요하면 포인터 뺄셈으로 구합니다.

size_t meshIndex     = node.mesh - data->meshes;
size_t materialIndex = prim.material - data->materials;

3. 메시와 정점 데이터

 

cgltf_mesh

필드 내용
name 메시 이름 (없으면 nullptr)
primitives, primitives_count 프리미티브 목록, 개수. 머티리얼 하나를 쓰는 조각 단위

 

cgltf_primitive

필드 내용
type 그리기 방식, 삼각형 목록은
cgltf_primitive_type_triangles
attributes, attributes_count 정점 속성 목록(위치, 노멀 등), 개수
indices 인덱스 accessor. 없으면 nullptr(정점 순서대로 그림)
material 사용하는 머티리얼. 없으면 nullptr

 

cgltf_attribute

필드 내용
type 속성 종류: cgltf_attribute_type_position, _normal, _tangent, _texcoord, _color, _joints, _weights
index 같은 종류의 몇 번째 세트인지. TEXCOORD_0이면 0, TEXCOORD_1이면 1
data 실제 값을 읽을 accessor
const cgltf_accessor* posAcc = nullptr;
for (cgltf_size a = 0; a < prim.attributes_count; ++a)
{
    const cgltf_attribute& attr = prim.attributes[a];
    if (attr.type == cgltf_attribute_type_position) posAcc = attr.data;
    if (attr.type == cgltf_attribute_type_texcoord && attr.index == 0) uvAcc = attr.data;
}

 

accessor 읽기

함수 역할
cgltf_accessor_read_float(acc, i, out, n) i번째 요소를 float n개로 읽습니다. 저장 타입이 정규화된 정수
(예: UV를 16비트로 압축)여도 float로 변환해 줍니다. sparse accessor도 처리합니다.
cgltf_accessor_read_index(acc, i) i번째 인덱스를 정수로 읽습니다. 저장 타입이 8/16/32비트 중 무엇이든 같은 함수로 읽습니다.
acc-->count 요소 개수. 정점 수나 인덱스 수가 됩니다.
for (cgltf_size v = 0; v < posAcc->count; ++v)
{
    float p[3];
    cgltf_accessor_read_float(posAcc, v, p, 3);
}

for (cgltf_size i = 0; i < prim.indices->count; ++i)
    idx[i] = (uint32_t)cgltf_accessor_read_index(prim.indices, i);

버퍼의 바이트 오프셋, stride, 컴포넌트 타입을 직접 계산하지 않아도 된다는 게 이 두 함수의 장점입니다.


4. 노드와 변환

cgltf_node

필드 내용
name 노드 이름
mesh 이 노드가 그리는 메시. 없으면 nullptr(빈 노드, 카메라, 라이트 등)
parent, children, children_count 계층 구조
has_matrix, matrix[16] 행렬로 지정된 로컬 변환
has_translation, translation[3] 위치
has_rotation, rotation[4] 회전 쿼터니언(x, y, z, w 순서)
has_scale, scale[3] 스케일

 

변환 계산 함수

함수 역할
cgltf_node_transform_local(node, out16) 이 노드 자신의 로컬 행렬, TRS로 지정돼 있어도 행렬로 합쳐줍니다.
cgltf_node_transform_world(node, out16) 부모까지 모두 곱한 월드 행렬

 

결과는 열 우선(column-major) float[16]입니다. 메모리 배치가 DirectXMath의 XMFLOAT4X4(행 우선, 행 벡터 규약)와 같아서 그대로 쓸 수 있습니다. 이동 성분이 [12], [13], [14]에 있습니다.

cgltf_float wm[16];
cgltf_node_transform_world(&node, wm);

XMFLOAT4X4 f;
memcpy(&f, wm, sizeof(f));

5. 머티리얼

cgltf_material

필드 내용
name 머티리얼 이름
has_pbr_metallic_roughness 아래 PBR 블록이 유효한지
pbr_metallic_roughness PBR 값과 텍스처 (아래 표)
normal_texture 노멀 맵(scale 필드에 세기)
occlusion_texture AO 맵(scale 필드에 세기)
emissive_texture 발광 맵
emissive_factor[3] 발광 색(선형)
alpha_mode, alpha_cutoff 불투면 / 마스크 / 블렌드
double_sided 양면 렌더링 여부

 

pbr_metallic_roughness

필드 내용
base_color_factor[4] 기본 색RGBA (선형)
base_color_texture 기본 색 텍스처 (sRGB)
metallic_factor 금속성 계수
roughness_factor 거칠기 계수
metallic_roughness_texture G채널 = 거칠기, B채널 = 금속성 (선형)

계수(factor)와 텍스처는 곱해서 씁니다. 텍스처가 있는 모델은 보통 계수가 1.0이고, 실제 값은 텍스처가 픽셀 단위로 정합니다.


6. 텍스처와 이미지

참조는 세 단계로 이어집니다.

cgltf_texture_view  →  cgltf_texture  →  cgltf_image
  (머티리얼의 슬롯)      (이미지 + 샘플러)    (실제 픽셀 데이터 위치)
구조체 주요 필드
cgltf_texture_view texture: 참조하는 텍스처(없으면 nullptr)
texcoord: 사용할 UV 세트 번호
scale: 노멀/AO 맵의 세기
cgltf_texture image: 참조하는 이미지
sampler: 필터와 래핑 설정
cgltf_image uri: 외부 파일 경로 또는 data: URI
buffer_view: .glb 내장 이미지의 위치
mime_type: "image/png" 등
cgltf_buffer_view buffer, offset, size
cgltf_buffer data: cgltf_load_buffers 이후 메모리에 올라온 바이트

 

이미지 바이트를 얻는 두 경로

const cgltf_image* img = view.texture->image;

if (img->buffer_view)   // .glb: 파일 안에 PNG/JPG 바이트가 그대로 들어 있음
{
    const cgltf_buffer_view* bv = img->buffer_view;
    const uint8_t* bytes = (const uint8_t*)bv->buffer->data + bv->offset;
    // bytes, bv->size 를 stbi_load_from_memory에 전달
}
else if (img->uri)      // .gltf: 옆에 있는 이미지 파일
{
    std::string uri = img->uri;
    cgltf_decode_uri(uri.data());          // "%20" 같은 URL 인코딩을 제자리에서 해제
    uri.resize(strlen(uri.c_str()));
    // 모델 폴더 경로 + uri 로 파일 로딩
}
함수 역할
cgltf_decode_uri(char* uri) URI의 퍼센트 인코딩을 문자열 안에서 바로 풀고, 새 길이를 반환합니다.
cgltf_buffer_view_data(bv) 최신 버전에 있는 헬퍼입니다. buffer->data + offset 계산을 대신해 줍니다.

7. 한눈에 보는 로딩 순서

cgltf_parse_file          파일 구조 파싱
cgltf_load_buffers        정점/인덱스/내장 이미지 바이트 로드
  │
  ├─ materials[i]          → pbr_metallic_roughness, normal/emissive_texture
  │     └─ texture_view → texture → image → (buffer_view | uri)
  │
  ├─ meshes[m].primitives[p]
  │     ├─ attributes[a]   → cgltf_accessor_read_float
  │     ├─ indices         → cgltf_accessor_read_index
  │     └─ material        → 포인터 뺄셈으로 인덱스
  │
  └─ nodes[n]
        ├─ mesh            → 포인터 뺄셈으로 인덱스
        └─ cgltf_node_transform_world
cgltf_free                전부 해제

 

반응형
반응형
  1. 노멀과 방향광 (Blinn-Phong)
  2. HDR 렌더 타깃, 톤매핑, 감마 보정
  3. PBR (Cook-Torrance)
  4. 텍스처 로딩과 밉맵
  5. 머티리얼, 탄젠트, 노멀 맵

1. 노멀과 방향광

정점과 상수 버퍼

조명을 계산하려면 표면이 어느 쪽을 향하는지 알아야 합니다. 정점에 노멀을 추가했고, 큐브는 면마다 노멀이 달라야 해서 정점이 8개에서 24개로 늘었습니다.

보내 데이터가 늘면서 루트 상수 16개로는 부족해졌습니다. 상수 버퍼를 두 개로 나눴습니다.

버퍼 갱신 주기 내용
PerFrame(b0) 프레임당 1회 ViewProj, 카메라 위치, 라이트 방향/색, amblient
PerObject(b1) 드로우당 1회 World, WorldInvTranspose, 베이스 컬러, 머티리얼 값

 

UploadRing: 프레임별 상수 버퍼 할당기

CPU가 다음 프레임의 상수를 쓰는 동안 GPU는 아직 이전 프레임을 그리고 있을 수 있습니다. 같은 버퍼를 쓰면 GPU가 읽는 도중에 값이 바뀝니다. 그래서 프레임 인덱스별로 업로드 버퍼를 따로 두고, 256바이트 단위로 잘라 순서대로 나눠 씁니다. 자기 차례가 돌아오면(GPU가 이미 다 쓴 뒤) 처음부터 다시 씁니다.

 

WorldInvTranspose

노멀을 월드 행렬로 그대로 변환하면 비균등 스케일에서 방향이 틀어집니다. 노멀에는 월드 행렬의 역전치 행렬을 써야 표면에 수직인 상태가 유지됩니다.


2. HDR과 톤매핑

문제

조명 계산 결과는 1.0을 쉽게 넘습니다. 8비트 렌더 타켓에 바로 그리면 쓰는 순간 1.0에서 잘려서, 밝은 면이 색 구분 없이 하얗게 뭉개집니다.

구조

씬 렌더링 → [sceneRT: R16G16B16A16_FLOAT] → 톤매핑 패스 → [viewportRT: R8G8B8A8_UNORM] → ImGui
  sceneRT viewportRT
담는 값 실제 빛의 양 (1.0초과 가능) 모니터로 내보낼 최종 색
깊이 버퍼 있음 없음

같은 텍스처를 읽으면서 동시에 쓸 수느 없어서 입력과 출력이 분리돼야 합니다.

블룸이나 안티앨리어싱 같은 포스트프로세스도 이 사이에 단계가 하나씩 끼어드는 구조로 늘어납니다.

 

톤 매핑 패스

  • 풀 스크린 삼각형: 버텍스 버퍼 없이 SV_VertexID(0,1,2)만으로 화면보다 큰 삼각형 하나를 만들어 덮습니다.
  • ACES: 밝은 값을 잘라 내지 않고 곡선으로 눌러서 계조를 남깁니다.
  • 감마 보정: 모니터는 입력을 약2.2제곱해서 표시합니다. 계산은 선형 공간에서 하고, 마지막에 1/2.2제곱을 해서 내보냅니다. 반대로 색상 피커에서 고른 색은 셰이더에서 선형으로 바꾼 뒤 계산합니다.

3. PBR (Cook-Torrance)

표면을 아주 작은 거울 조각(미세면)이 모인 것으로 보는 모델입니다.

항 묻는것 화면에서 보이는 효과
D (GGX) 미세면 중 몇 개가 반사 방향을 향하는가 거칠기가 낮으면 작고 강한 하이라이트, 높으면 넓고 흐린 하이라이트
G (Smith) 미세면끼리 서로 가리는 정도 비스듬한 각도에서 과하게 밝아지는 걸 억제
F (Schlick) 보는 각도에 따른 반사율 변화 가장자리로 갈수록 반사가 강해짐
float3 F0       = lerp(0.04, albedo, metallic);              // 비금속 4%, 금속은 자기 색
float3 specular = (D * G * F) / max(4 * NdotV * NdotL, 1e-4);
float3 diffuse  = (1 - F) * (1 - metallic) * albedo / PI;    // 금속은 난반사 없음
  • 금속성은 두 가지를 바꿉니다. 금속은 난반사가 없고, 반사광에 자기 색이 묻어납니다.
  • 난반사는 π로 나누는 것은 에너지 보존을 위한 것입니다. Blinn-Phong 때보다 약 3배 어두워져서 기본 라이트 세기를 3.0으로 올렸습니다.
  • 현재 한계: 금속은 주변 환경을 반사해서 보이는 재질인데, 지금은 방향과 하나뿐이라 하이라이트 주변 말고는 어둡습니다. 환경 조명(IBL)이 들어가야 제대로 보입니다.

4. 텍스처와 밉맵

업로드 경로

CPU는 UPLOAD힙에만 쓸 수 있고, GPU는 DEFAULT힙에서 읽는게 빠릅니다. 그래서 두 단계를 거칩니다.

픽셀 데이터 → 업로드 버퍼(UPLOAD 힙) → CopyTextureRegion → 텍스처(DEFAULT 힙) → SRV
  • Renderer::Immediate: 초기화 시점에는 열린 커맨드 리스트가 없어서, 람다를 받아 즉시 실행하고 완료까지 기다리는 함수를 만들었습니다.
  • RowPitch: GPU가 요구하는 행 간격이 width x 4 보다 클 수 있습니다.
  • sRGB포맷: 색상 텍스처는 _SRGB포맷으로 만들면 샘플링할 때 GPU가 선형으로 바꿔 줍니다. 노멀이나 러프니스 같은 데이터 텍스처는 일반 포맷을 씁니다.
  • 기본 텍스처: 텍스처가 없는 슬롯에는 1x1흰색을 바인딩해서 셰이더를 한가지 경로로 유지합니다.

 

이미지 포맷

  일반 이미지(PNG, JPG, TGA) GPU전용(DDS, KTX2)
로딩 CPU에서 압축을 풀어 RGBA로 변환 거의 그대로 업로드
VRAM 큼 BC압축으로 4분의 1에서 8분의 1
밉맵 없음, 직접 생성 파일에 포함
용도 제작과 교환 런타임

지금은 stb_image로 일반 이미지를 읽습니다. 나중에 에셋 파이프라인에서 BC압축 DDS로 변환해 캐시하는 구조로 갈 계획입니다.

 

밉맵

멀리 있는 표면은 화면의 한 픽셀이 텍스처의 여러 칸을 덮습니다. 그중 한 점만 읽으면 자글자글하게 깨지고, 움직일 때 깜빡입니다. 절반씩 줄인 사본을 미리 만들어 두고 거리에 맞는 사본을 읽게 합니다.

  • DX12에는 밉맵 자동 생성 함수가 없어서 CPU에서 2x2평균으로 만들었습니다.
  • 밉 레벨 하나하나가 서브리소스이고, 복사 명령은 서브리소스 단위로 내립니다.
  • sRGB는 선형으로 바꾼 뒤 평균해야 합니다. 검정과 흰색을 바이트 그대로 평균하면 128이 되는데, 실제 밝기로는 약 22%입니다. 올바른 값은 약 188입니다.
  • 메모리는 원본의 약 1.33배가 됩니다.

5. 머티리얼과 노멀 맵

Material 구조

엔티티가 낱개로 들고 있던 값을 묶었습니다. Scene이 머티리얼을 소유하고, 엔티티는 포인터로 참조합니다.

여러 엔티티가 같은 머티리얼을 공유할 수 있습니다.

슬롯 내용 기본값
t0 Albedo 색 (sRGB) 흰색
t1 Normal 표면 요철 방향(선형) (128,128,255)
t2 Metallic-Roughness G = 거칠기, B = 금속성 (glTF 규약) 흰색

텍스처마다 SRV가 힙의 다른 위치에 있어서, 디스크립터 테이블을 슬롯별로 하나씩 따로 뒀습니다.

 

탄젠트 공간

노멀 맵의 RGB는 방향 벡터인데, 월드 기준이 아니라 표면에 붙은 좌표계 기준입니다.

  • T: 텍스처의 U가 증가하는 방향(정점 속성으로 저장)
  • B: V가 증가하는 방향. cross(N, T) x w로 계산
  • N: 표면 노멀
float3 nTS = gNormalMap.Sample(gSampler, uv).xyz * 2 - 1;
N = normalize(nTS.x * T + nTS.y * B + nTS.z * N);

이 좌표계를 쓰기 때문에 같은 노멀 맵을 큐브의 어느 면에든, 구에든 그대로 쓸 수 있습니다.

실제 지오메트리는 바뀌지 않고 조명 계산에 쓰는 노멀만 바뀌므로, 실루엣은 여전히 매끈합니다.


결과

반응형

'DirectX12 > 엔진 만들어보기' 카테고리의 다른 글

glTF 모델 로딩  (0) 2026.10.09
cgltf API 정리 (사용한것 기준)  (0) 2026.10.09
그리드, Scene/Entity, Hierarchy·Inspector, 기즈모  (0) 2026.10.05
서브모듈 정리, 카메라  (0) 2026.10.03
Viewport 만들기  (0) 2026.10.02
반응형

바닥 그리드로 공간 기준을 잡습니다.

하드코딩된 큐브를 Scene/Entity 구조로 바꿉니다.

Hierarchy/Inspector 패널에서 선택하고 값을 편집합니다.

InGuizmo로 뷰포트 안에서 직접 이동/회전/스케일을 조정합니다.


그리드 바닥

DX12의 PSO에는 PrimitiveTopologyType이 들어갑니다. TRIANGLE로 만든 PSO로는 선을 그릴 수 없습니다. 그래서 같은 셰이더와 루트 시그니처를 쓰더라도 PSO는 하나 더 만들어야 합니다.

auto linePso = pso;   // 삼각형 PSO 설정을 복사
linePso.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_LINE;
device->CreateGraphicsPipelineState(&linePso, IID_PPV_ARGS(&mLinePso));

드로우할 때는 IASetPrimitiveTopology(LINELIST)로 맞춥니다. PSO의 타입과 커맨드 리스트의 토폴로지(LIST/STRIP 등)는 별개 설정이고, 둘이 서로 호환돼야 합니다.

 


Scene / Entity 구조

라이트, 카메라, 스크립트처럼 컴포넌트가 늘어나는 시점에 옮길 계획입니다.

struct Entity
{
    std::string name;
    Transform   transform;
    const Mesh* mesh = nullptr;   // 소유하지 않음
    bool        visible = true;
};

모듈구성

모듈 역할
Mesh VB/IB와 뷰를 소유하고 Draw(cmd)를 제공. CreateBue()는 1x1x1 단위 큐브
Transform position, rotation(오일러, 도 단위), scale을 들고 Matrix()를 계산
Scene std::vector<Entity>와 Create(name)
SceneRenderer 그리드를 그린 뒤, 씬의 엔티티를 순회하며 mesh->Draw 호출

핵심은 메시와 인스턴스의 분리입니다. 큐브 버퍼는 하나인데, 엔티티 세 개가 각자 다른 Transform으로 그 메시를 그립니다.


Transform 행렬 순서

S * Rx * Ry * Rz * T
  • S->R->T 순서여야 물체가 제자리에서 돌고 커집니다(DX12에서는 SRT순으로 곱해짐). 순서가 바뀌면 원점을 중심으로 공전합니다.
  • Transform은 항상 메시 원점(피벗) 기준으로 적용됩니다. 큐브는 중심 피벗이라 scale을 키우면 위 아래로 같이 커집니다. 바닥에 붙이려면 position.y = 0.5 x scale.y로 맞춰야 합니다.
  • 피벗 위치는 Transform이 아니라 메시 정점을 어떻게 만들었느냐로 결정됩니다.

주의: vector 재할당과 참조

Scene::Create가 Entity&를 반환하는데, 다음 Create에서 vector가 재할당되면 이전 참조는 무효가 됩니다. 그래서 선택 상태는 포인터가 아니라 인덱스로 저장했습니다. 엔티티 삭제 기능이 들어가면 안정적인 ID방식으로 바꿔야 합니다.


Hierarchy / Inspector 패널

Hierarchy: 엔티티 목록을 ImGui::Selectable로 표시합니다. PushID(i)를 써서 이름이 같은 엔티티가 있어도 ID가 충돌하지 않게 했습니다. 빈 공간을 클릭하면 선택이 해제 됩니다.

Inspector: 선택한 엔티티의 이름(InputText), 표시 여부(Checkbox), Transform(DragFloat3 세 개)을 편집합니다.

 

DragFloat3에는 &transform.position.x를 넘기는데, 이유는 float* 구조체이기에 다음 참조로 넘어가면 x->y->z 순으로 넘어가기 때문에 x 원소만 넘겨주면 된다.


도킹 레이아웃

DockBuilder로 4분할 기본 배치를 만들었습니다. Hierarchy는 왼쪽, Viewport는 가운데, Inspector는 오른쪽 위, Stats는 오른쪽 아래입니다.

 


ImGuizmo

연결방식

ImGuizmo::SetDrawlist();
ImGuizmo::SetRect(min.x, min.y, size.x, size.y);   // Viewport 이미지 영역

if (ImGuizmo::Manipulate(view, proj, op, mode, world))
    ImGuizmo::DecomposeMatrixToComponents(world, &t.position.x, &t.rotation.x, &t.scale.x);
  • DirectXMath의 XMFLOAT4X4 메모리 배치는 ImGuizmo가 기대하는 float[16]과 같습니다. 그래서 변환 없이 포인터만 넘깁니다.
  • Mainpulate는 조작이 있었던 프레임에만 true를 반환합니다. 수정된 월드 행렬을 위치/회전/스케일로 분해해서 Transform으로 되돌립니다.
  • 회전 순서 일치: ImGuizmo는 X->Y->Z 순서로 오일러 각을 분해합니다. Transform::Matrix도 같은 순서로 맞춰야 기즈모를 놓을 때 물체가 튀지 않습니다.
  • 스케일은 로컬 축에서만 의미가 있어서 LOCAL 모드를 쓰고, 이동 회전은 WORLD 모드를 씁니다.

단축키

W(이동), E(회전), R(회전)로 전환합니다.

우클릭 중(카메라 플라이)이거나 텍스트 입력 중에는 무시해서, 카메라의 WASD나 이름 입력과 충돌하지 않습니다.


에디터 설정 파일 위치

imgui.ini는 기본적으로 작업 디렉터리에 저장됩니다. launch.vs.json의 currentDir이 Assets로 잡혀 있어서 콘텐츠 폴더에 설정 파일이 섞였습니다. 언리얼의 Saved/처럼 분리했습니다.

std::filesystem::create_directories(BAEK_ROOT_DIR "Saved");   // ImGui는 폴더를 만들어주지 않음
io.IniFilename = BAEK_ROOT_DIR "Saved/imgui.ini";

결과

 

반응형

'DirectX12 > 엔진 만들어보기' 카테고리의 다른 글

cgltf API 정리 (사용한것 기준)  (0) 2026.10.09
라이팅에서 PBR 머티리얼까지  (0) 2026.10.06
서브모듈 정리, 카메라  (0) 2026.10.03
Viewport 만들기  (0) 2026.10.02
엔진 구상  (0) 2026.10.01

+ Recent posts