본문으로 건너뛰기
뒤로가기

[Emberlit] 가우시안 블러 이해하기: 수식에서 GPU Compute까지

TL;DR — 가우시안 블러는 G(x)=ex2/2σ2G(x) = e^{-x^2/2\sigma^2} 함수를 커널로 사용하며, separable 특성 덕분에 2D 연산을 1D 두 번으로 분리하여 GPU에서 효율적으로 처리할 수 있습니다. Vulkan compute shader로 구현한 뒤, 클릭한 픽셀의 GPU 결과를 CPU 계산으로 재현하여 정확성을 검증했습니다.

Table of contents

Open Table of contents

들어가며

이미지 블러는 그래픽스의 기본 연산입니다. 블룸(빛번짐), DOF(피사계 심도), UI 배경 흐림 등 후처리 효과의 기반이 되고, 블러 자체가 이미지 처리의 핵심 도구이기도 합니다.

그중 가우시안 블러는 가장 널리 쓰이는 블러 방식입니다. 단순 평균(박스 블러)보다 원본을 잘 보존하면서 부드럽게 흐리고, separable 특성 덕분에 GPU에서 효율적으로 실행할 수 있습니다.

이 글에서는 가우시안 함수의 수식을 분해하고, separable convolution이 왜 가능한지 증명한 뒤, Vulkan compute shader로 구현하고, GPU 결과를 수학으로 검증하는 과정을 다룹니다. 수식이 등장하지만, 코드와 1:1로 대응시키며 설명하므로 수학 배경 없이도 따라갈 수 있습니다.

이 글에서 다루는 내용:

사전 지식: Vulkan의 기본적인 compute shader, dispatch, 이미지 읽기/쓰기 개념을 전제로 합니다.


1. 컨볼루션 — 커널이 이미지 위를 슬라이딩하는 연산

이미지 블러의 핵심은 컨볼루션(Convolution) 입니다. 커널(Kernel)이라는 작은 숫자 행렬을 이미지 위에 슬라이딩하면서, 각 픽셀의 새 값을 계산하는 연산입니다.

박스 블러 vs 가우시안 블러


2. 가우시안 함수 — 수식을 코드로

수식

G(x)=ex22σ2G(x) = e^{-\frac{x^2}{2\sigma^2}}

중심에서 멀어질수록 값이 부드럽게 감소하는 함수입니다. 좌우 대칭이라 블러에 적합합니다.

수식 분해

밑: ee (자연상수, ≈ 2.718)

부드러운 감쇠 곡선을 만드는 수학 상수입니다. 코드에서는 exp() 함수가 exe^x를 계산합니다.

지수: x22σ2-\frac{x^2}{2\sigma^2}

x2x^2σ2\sigma^2인 이유:

σ\sigma에 따른 가중치 변화

σ=1\sigma = 1일 때, 중심에서 3칸만 벗어나도 가중치가 0.011까지 떨어집니다. σ=3\sigma = 3일 때, 3칸 벗어나도 가중치가 0.607로 여전히 높습니다.

σ\sigma가 크면 같은 거리에서도 가중치가 높게 유지되어, 멀리 있는 픽셀도 많이 반영됩니다.

σ=1, σ=3, σ=5 동심원 블러 비교

커널 크기와 σ\sigma의 관계

커널 생성 + 정규화

  1. σ\sigma를 결정합니다
  2. 커널 크기를 결정합니다 (6σ+16\sigma + 1)
  3. 각 위치 xx에 대해 G(x)G(x)를 계산합니다
  4. 전체 합이 1이 되도록 정규화합니다 — 밝기를 보존하기 위해서입니다. 합이 1보다 크면 밝아지고, 1보다 작으면 어두워집니다. 블러는 색을 섞는 연산이지 밝기를 바꾸는 연산이 아닙니다
σ=1, 커널 크기 7:
G(x):      0.011   0.135   0.607   1.000   0.607   0.135   0.011   (합 = 2.506)
정규화:     0.004   0.054   0.242   0.399   0.242   0.054   0.004   (합 = 1.000)

코드로는 한 줄입니다:

float weight = exp(-(x * x) / (2.0f * sigma * sigma));

3. Separable Convolution — n2n^22n2n으로

왜 빠른가

n×nn \times n 커널의 2D 컨볼루션은 픽셀당 n2n^2번 연산이 필요합니다. Separable 방식은 1D를 가로 한 번 + 세로 한 번, 픽셀당 2n2n번 연산으로 줄입니다.

n=7n = 7이면 49 vs 14. 3.5배 빠릅니다.

어떤 커널이 separable한가

2D 커널이 두 1D 벡터의 외적(Outer Product) 으로 분해 가능할 때만 separable합니다.

        [v₁]
v ⊗ h = [v₂] × [h₁, h₂, h₃]
        [v₃]

      = [v₁h₁  v₁h₂  v₁h₃]
        [v₂h₁  v₂h₂  v₂h₃]
        [v₃h₁  v₃h₂  v₃h₃]

가우시안 함수는 G(x,y)=G(x)×G(y)G(x, y) = G(x) \times G(y)로 분리되므로 separable합니다. 모든 커널이 이렇게 분리 가능한 것은 아닙니다 (예: 일부 edge detection 커널은 불가능합니다).

핵심: 가로 전체를 먼저, 세로 전체를 나중에

두 패스는 절대 섞이지 않습니다.

  1. 가로 패스: 모든 픽셀 에 가로 1D 커널을 적용하여 중간 이미지를 생성합니다
  2. 세로 패스: 중간 이미지 에 세로 1D 커널을 적용하여 최종 이미지를 생성합니다

처음에는 “한 픽셀씩 가로 → 세로 순차로 하는 것”이라고 오해하기 쉽습니다. 가로 패스가 전체에 먼저 적용되어야 세로 패스에서 올바른 중간 결과를 읽을 수 있습니다.

원본과 결과 이미지를 분리해야 하는 이유:

수식 증명: 2D = 1D + 1D

이미지 픽셀 (중심 e 주변 3×3):

a b c
d e f
g h i

2D 컨볼루션 (한 번에):

e' = v₁h₁·a + v₁h₂·b + v₁h₃·c
   + v₂h₁·d + v₂h₂·e + v₂h₃·f
   + v₃h₁·g + v₃h₂·h + v₃h₃·i

Separable (가로 → 세로):

가로 패스:
  A = h₁a + h₂b + h₃c
  B = h₁d + h₂e + h₃f
  C = h₁g + h₂h + h₃i

세로 패스:
  e' = v₁·A + v₂·B + v₃·C
     = v₁h₁·a + v₁h₂·b + ... + v₃h₃·i

분배법칙으로 전개하면 2D 결과와 항이 하나하나 동일합니다.


4. GPU 구현 — Vulkan Compute Shader

CPU 블러를 건너뛴 이유

로드맵에는 “CPU 가우시안 블러 → GPU compute shader” 순서가 있었습니다. CPU 구현을 건너뛰고 바로 GPU로 간 이유는 다음과 같습니다:

왜 Compute Shader인가

가우시안 블러는 모든 픽셀에 대해 동일한 연산을 반복하는 작업입니다. GPU의 수만 개 스레드가 각 픽셀을 동시에 처리합니다. graphics pipeline의 버텍스/래스터화 과정이 필요 없는 순수 연산이라 compute shader가 적합합니다.

CPU: for문으로 640,000번 순차 실행
GPU: 640,000 스레드가 한꺼번에 병렬 실행

dispatch 2번이 필수인 이유

GPU의 workgroup 간에는 동기화가 불가능합니다. 셰이더 내부에서 “가로가 끝나면 세로 시작”을 할 수 없습니다.

dispatch 1 (가로): 모든 픽셀 가로 블러 → 중간 이미지
배리어: 가로 쓰기 완료 보장
dispatch 2 (세로): 모든 픽셀 세로 블러 → 최종 이미지

각 스레드는 자기 좌표의 픽셀만 처리합니다. 입력 이미지에서 읽고(readonly), 출력 이미지에 씁니다(writeonly). 병렬 스레드 간 오염이 구조적으로 불가능합니다.

셰이더 코드 — 1개로 가로/세로 모두 처리

처음에는 가로 셰이더, 세로 셰이더를 따로 만들려 했습니다. 하지만 push_constant로 방향만 전환하면 셰이더 1개, 파이프라인 1개로 충분합니다:

layout(push_constant) uniform Params {
    float sigma;
    int radius;
    int direction;  // 0: 가로, 1: 세로
};

void main() {
    ivec2 pos = ivec2(gl_GlobalInvocationID.xy);
    ivec2 size = imageSize(inputImage);
    if (pos.x >= size.x || pos.y >= size.y) return;

    float total_weight = 0.0;
    vec4 total_color = vec4(0.0);

    for (int i = -radius; i <= radius; ++i) {
        ivec2 offset = (direction == 0) ? ivec2(i, 0) : ivec2(0, i);
        ivec2 coord = clamp(pos + offset, ivec2(0), size - 1);

        float weight = exp(-(i * i) / (2.0 * sigma * sigma));
        total_weight += weight;
        total_color += weight * imageLoad(inputImage, coord);
    }

    imageStore(outputImage, pos, total_color / total_weight);
}

핵심 포인트:


5. 검증 — GPU 결과를 수학으로 확인

셰이더가 올바르게 동작하는지 확인하기 위해, 클릭한 픽셀의 블러 결과를 CPU에서 동일한 가우시안 계산으로 재현하여 비교했습니다.

검증 흐름:
1. 동심원 이미지에서 블러 적용
2. 마우스로 경계 부근 클릭
3. GPU 결과 (staging buffer로 readback)
4. CPU 예측값 (원본 픽셀 + 가우시안 가중치로 separable 계산)
5. 두 값 비교 → 일치하면 셰이더 구현이 정확

CPU 예측 계산도 separable 방식입니다:

실제 결과, GPU와 CPU 계산이 일치했습니다.

경계 부근 피킹 — GPU/CPU Match! 표시


정리하며

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[Vulkan] renderFinished 세마포어는 왜 프레임이 아니라 이미지에 묶어야 하는가
다음 글
[Emberlit] Vulkan Compute로 블룸 만들기: threshold, blur, composite, 그리고 이미지 전이