1. 개요
DOF라고 불리는 피사계 심도는 사진학에서 사용하는 용어입니다.
한마디로 정의하자면 사진이 선명하게 보이는 범위가 바로 DOF입니다.
카메라에서 물체를 볼때 초점이 맞는 한 점이 있습니다.
그 점보다 가까워지거나 멀어지게 되면 물체는 점점 흐려지기 시작합니다.
피사계 심도가 깊다는 말의 의미는 초점이 맞는 범위가 넓다는 것이고 얕다는 것은 그 반대입니다.
실제 카메라의 이러한 광학적 현상을 디지털 환경에서 재현하는 것이 바로 컴퓨터 그래픽스의 DOF입니다.
실제 카메라는 렌즈의 물리적 특성으로 자연스럽게 DOF가 발생하지만, 컴퓨터 그래픽스에서는 이를 시뮬레이션해야 합니다.
오늘은 이러한 특성을 D3D11을 통해 구현하는 과정에 대해 설명할 것입니다.
아래는 실제 적용한 결과입니다.
2. DOF에 영향을 주는 요소
우선 실제 카메라에서 DOF에 영향을 주는 요소들에 대해 알아보도록 하겠습니다.
2.1 조리개 크기 (Aperture)
조리개는 렌즈로 들어오는 빛의 양을 조절하는 구멍입니다. F값(F-stop)으로 표현되며, 숫자가 작을수록 조리개가 크게 열립니다.
사람의 신체로 비유하자면 동공과 같습니다. (동공은 눈으로 들어오는 빛의 양을 조절합니다.)
- 큰 조리개 (작은 F값, 예: f/1.4): 얕은 피사계 심도 → 배경이 많이 흐려짐
- 작은 조리개 (큰 F값, 예: f/16): 깊은 피사계 심도 → 전체적으로 선명함
작은 조리개 (f/16) → 구멍이 작음 → 빛이 직선에 가깝게 들어옴 → 넓은 범위에서 빛이 모임 → 전체적으로 선명 (깊은 DOF)

2.2 초점 거리 (Focal Distance)
카메라에서 초점을 맞춘 피사체까지의 거리가 초점거리입니다. 이 지점에서 멀어질수록 흐림 정도가 증가합니다.
- 가까운 초점: 전경은 선명, 배경은 흐림
- 먼 초점: 배경은 선명, 전경은 흐림
2.3 렌즈의 초점 길이 (Focal Length)
렌즈의 광학적 중심(렌즈를 통과하는 빛이 굴절되지 않고 직진하는 지점)에서 센서까지의 거리입니다.
쉽게 말해 렌즈가 얼마나 확대하는가를 나타냅니다.
초점거리가 짧은 광각렌즈는 넓은 화각을 가지며 초점거리가 긴 망원렌즈는 좁은 화각을 가집니다.
- 광각 렌즈 (예: 24mm): 깊은 피사계 심도
- 망원 렌즈 (예: 200mm): 얕은 피사계 심도
https://www.canon.ge/pro/infobank/understanding-focal-length/
2.4 피사체와의 거리
카메라와 피사체 사이의 물리적 거리도 중요한 요소입니다.
- 가까운 거리: 얕은 피사계 심도
- 먼 거리: 깊은 피사계 심도
가까운 피사체는 피사체를 통해 나온 빛이 렌즈로 들어오는 각도가 크기 때문에 앞뒤로 조금만 움직여도 흐려지는 정도가 큽니다.
반대로 먼 피사체는 렌즈로 들어오는 빛의 각도가 작기 때문에 움직임에 따른 흐려지는 정도가 미미합니다.
3. COC(Circle Of Confusion) - 착란원
착란원은 점광원을 촬영할 때 렌즈를 통과한 빛이 완벽한 초점을 맺지 못해 생기는 광학적 원형 반점입니다.
위의 사진을 위에서부터 1,2,3이라고 했을때 1번은 초점거리보다 물체가 가까워서 착란원이 크게 생긴 결과이고
3번은 멀어서 생긴결과 2번은 초점이 맞을때 가장 작은 착란원이 생긴 결과입니다.
즉 착란원은 크면 클수록 DOF는 작아지고 작을수록 DOF는 커집니다.
COC크기에 영향을 주는 요소
3.1 조리개 크기의 영향(Aperture)
조리개가 크면 클수록(F stop이 작으면 작을수록) COC는 커집니다.
빛이 들어오는 각도가 다양하기에 퍼지는 정도가 커지는 것입니다.
조리개가 작으면 작을수록(F-stop이 크면 클수록) COC는 작아집니다.
빛이 들어오는 각도가 작기 때문에 퍼지는 정도가 작은 것입니다.
3.2 Focal Length (센서-렌즈거리)
Focal Length가 크다는 것은 위에서 설명했듯 망원렌즈이고 이는 COC를 크게 만들고 얕은 DOF를 만들어냅니다.
반대로 Focal Length가 작다는 것은 광각렌즈이고 COC를 작게 만들고 깊은 DOF를 만들어냅니다.
4. 구현 과정
void FDepthOfFieldRenderPass::Render(const std::shared_ptr<FEditorViewportClient>& Viewport)
{
PrepareRender(Viewport);
PrepareDownSample(Viewport);
Graphics->DeviceContext->Draw(6, 0);
CleanSample(Viewport);
PrepareUpSample(Viewport);
Graphics->DeviceContext->Draw(6, 0);
CleanSample(Viewport);
CleanUpRender(Viewport);
}
이러한 순서로 렌더링이 됩니다.
여기서 주목할 점은 DownSampling을 한뒤에 다시 UpSampling을 한다는 것입니다.
그 이유는 바로 최적화 때문입니다.
만약 원본 해상도가 1000x1000이라고 가정하게 된다면 현재 제 hlsl코드에서는 픽셀당 25번씩 샘플링을 진행합니다.
그렇게 되면 연산수는 총 1000x1000x25 = 25,000,000회 입니다.
그러나 만약 down sampling으로 500x500으로 만들게 된다면 연산수가 500x500x25 =6,250,000회로 줄어들게 됩니다.
void FDepthOfFieldRenderPass::PrepareDownSample(const std::shared_ptr<FEditorViewportClient>& Viewport)
{
FViewportResource* ViewportResource = Viewport->GetViewportResource();
if (!ViewportResource)
{
return;
}
FRenderTargetRHI* RenderTargetRHI_ScenePure = ViewportResource->GetRenderTarget(EResourceType::ERT_Scene);
FRenderTargetRHI* RenderTargetRHI_DownSample2x = ViewportResource->GetRenderTarget(EResourceType::ERT_DownSample2x, 2);
const FRect ViewportRect = Viewport->GetViewport()->GetRect();
const float DownSampledWidth = static_cast<float>(FMath::FloorToInt(ViewportRect.Width / 2));
const float DownSampledHeight = static_cast<float>(FMath::FloorToInt(ViewportRect.Height / 2));
D3D11_VIEWPORT Viewport_DownSample2x;
Viewport_DownSample2x.Width = DownSampledWidth;
Viewport_DownSample2x.Height = DownSampledHeight;
Viewport_DownSample2x.MinDepth = 0.0f;
Viewport_DownSample2x.MaxDepth = 1.0f;
Viewport_DownSample2x.TopLeftX = 0.f;
Viewport_DownSample2x.TopLeftY = 0.f;
Graphics->DeviceContext->RSSetViewports(1, &Viewport_DownSample2x);
Graphics->DeviceContext->OMSetRenderTargets(1, &RenderTargetRHI_DownSample2x->RTV, nullptr);
Graphics->DeviceContext->PSSetShaderResources(static_cast<UINT>(EShaderSRVSlot::SRV_Scene), 1, &RenderTargetRHI_ScenePure->SRV);
Graphics->DeviceContext->PSSetShaderResources(static_cast<UINT>(EShaderSRVSlot::SRV_SceneDepth), 1,
&ViewportResource->GetDepthStencil(EResourceType::ERT_Scene)->SRV); // 깊이 추가!
UpdateDOFConstant(DownSampledWidth, DownSampledHeight);
ID3D11VertexShader* VertexShader = ShaderManager->GetVertexShaderByKey(L"DownSampleVertexShader");
ID3D11PixelShader* PixelShader = ShaderManager->GetPixelShaderByKey(L"DownSamplePixelShader");
Graphics->DeviceContext->VSSetShader(VertexShader, nullptr, 0);
Graphics->DeviceContext->PSSetShader(PixelShader, nullptr, 0);
Graphics->DeviceContext->IASetInputLayout(nullptr);
Graphics->DeviceContext->PSSetSamplers(0, 1, &LinearSampler);
}
아래는 hlsl 코드입니다.
float LinearizeDepth(float z)
{
float ndc = z * 2.0 - 1.0; // Depth buffer [0,1] → NDC [-1,1]
return (2.0 * NearPlane * FarPlane) / (FarPlane + NearPlane - ndc * (FarPlane - NearPlane));
}
float GetCoC(float d) // d: linearized depth
{
float f = FocalLength * 0.001f; // mm → m로 단위 변환
float a = Aperture; // f-stop
float s = FocusDistance; // 초점 거리 (world units)
// 0으로 나누기 방지
if (abs(s - f) < 0.001f)
return 0.0f;
if (d <= 0.001f)
return 0.0f;
float coc = abs(f * f * (d - s) / (d * (s - f))) * (a / f);
return coc;
}
float4 mainPS(PS_Input Input) : SV_TARGET
{
float2 uv = Input.UV;
float depth = DepthTexture.Sample(SceneSampler, uv).r;
float linearDepth = LinearizeDepth(depth);
float4 color = SceneTexture.Sample(SceneSampler, uv);
float coc = GetCoC(linearDepth);
float blurRadius = saturate(coc / MaxBlurRadius) * MaxBlurRadius;
if (blurRadius > 0.01)
{
float4 blurColor = float4(0, 0, 0, 0);
float totalWeight = 0.0;
int kernel = 2;
for (int x = -kernel; x <= kernel; x++)
{
for (int y = -kernel; y <= kernel; y++)
{
float2 offset = float2(x, y) * InvTextureSize * blurRadius * BlurStrength;
float weight = 1.0;
blurColor += SceneTexture.Sample(SceneSampler, uv + offset) * weight;
totalWeight += weight;
}
}
if (totalWeight > 0)
color = blurColor / totalWeight;
}
return color;
}
위의 개념에서 설명했던 다양한 요소들을 Constant Buffer로 넘겨서 COC계산을 수행해주고
그를 바탕으로 Blur정도를 계산합니다.
현재는 kernel값을 2로 설정해주었기에 한 픽셀당 25회의 연산이 들어갑니다.
이렇게 연산된 결과는 DownSample이라는 별도의 텍스처(RTV)에 먼저 담깁니다.
그리고 그 다음 단계인 업샘플링에서 이 텍스처를 '입력(SRV)'으로 사용하게 됩니다.
DownSampling을 준비하는 코드에서 PSSetShaderResource하는 부분을 참고하시길 바랍니다.
이렇게 한뒤에 UpSampling을 해주어야 정상적인 크기로 Render하는 것이 가능합니다.
void FDepthOfFieldRenderPass::PrepareUpSample(const std::shared_ptr<FEditorViewportClient>& Viewport)
{
// 0) 리소스 가져오기
FViewportResource* VR = Viewport->GetViewportResource();
if (!VR) return;
FRenderTargetRHI* RT_Down = VR->GetRenderTarget(EResourceType::ERT_DownSample2x, 2);
FRenderTargetRHI* RT_Full = VR->GetRenderTarget(EResourceType::ERT_DOF); // 스케일 1 명시
Graphics->DeviceContext->RSSetViewports(1, &Viewport->GetD3DViewport());
// 3) 풀 해상도 렌더 타겟 바인딩
Graphics->DeviceContext->OMSetRenderTargets(1, &RT_Full->RTV, nullptr);
// 4) 다운샘플 결과를 입력으로 바인딩
Graphics->DeviceContext->PSSetShaderResources(
(UINT)EShaderSRVSlot::SRV_Scene, 1, &RT_Down->SRV);
// 5) 셰이더와 IA 셋업
ID3D11VertexShader* VS = ShaderManager->GetVertexShaderByKey(L"UpSampleVertexShader");
ID3D11PixelShader* PS = ShaderManager->GetPixelShaderByKey(L"UpSamplePixelShader");
Graphics->DeviceContext->VSSetShader(VS, nullptr, 0);
Graphics->DeviceContext->PSSetShader(PS, nullptr, 0);
Graphics->DeviceContext->IASetInputLayout(nullptr);
Graphics->DeviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// 6) 샘플러 설정
Graphics->DeviceContext->PSSetSamplers(0, 1, &LinearSampler);
}
EResourceType::ERT_DOF란 Enum으로 RT_Full을 뽑아냅니다.
두번째 매개변수를 쓰지 않을 경우 기본적으로 1로 설정되어서 기본크기의 RTV를 뽑아냅니다.
그렇게 RTV를 이것으로 해주고 SRV로는 우리가 DownSampling을 통해 뽑아낸 결과를 넣어줍니다.
이렇게 하면 UpSampling이 되고 이것을 최종적으로 Rendering하면 됩니다.
Show Flag
Focus Distance 조정
'DirectX11' 카테고리의 다른 글
[DirectX11] 게임테크랩 자체 엔진 게임잼 - 토끼굴의 비밀 (0) | 2025.06.13 |
---|---|
[DirectX11] Payload & Particle (0) | 2025.05.24 |
[DirextX11] FBX Animation, Unreal Engine의 Animation 구조, Notify (0) | 2025.05.16 |
[DirectX11] CPU Skinning, GPU Skinning 비교 (0) | 2025.05.16 |
[DirectX11] FBX, CPU Skinning (0) | 2025.05.09 |