← 개념 · 데모 가이드 | 구현 허브 | 2장. 2D 그래픽스 | 이미지 처리 — 컨볼루션 필터
IMPLEMENTATION · CHAPTER 2

이미지 처리 — 컨볼루션 필터 — 한 줄 한 줄 직접 구현

지금까지는 빈 프레임버퍼에 그림을 그렸다면, 이번에는 반대로 이미 있는 이미지를 읽어서 픽셀 단위로 변환합니다. 파일 로딩은 헤더 온리 라이브러리 stb_image 한 방으로 끝내고, 흑백 변환 · 박스 블러 · 소벨 에지 검출을 전부 하나의 일반화된 3×3 컨볼루션 관점으로 이해합니다. 컨볼루션은 GPU 셰이더에서도, 딥러닝 CNN에서도 똑같이 나오는 가장 기본이 되는 이미지 연산입니다. 여기서 CPU로 성분 하나하나 손으로 계산해 보면, 나중에 Compute Shader로 옮길 때 "무엇이 무엇에 대응하는지"가 훤히 보입니다.

  1. stb_image로 이미지를 로드해 우리 프레임버퍼로 옮긴다.
  2. 사람 눈에 맞는 휘도 가중치로 그레이스케일 변환한다.
  3. 일반 3×3 커널 컨볼루션 함수를 하나 만들어 블러·소벨에 재사용한다.
  4. 경계 처리와, 블러의 분리 가능(separable) 최적화를 이해한다.

1. 이미지 로딩 — 디스크에서 프레임버퍼로

화면에 무언가를 필터링하려면 우선 메모리 위에 픽셀 배열이 있어야 합니다. PNG·JPG 같은 압축 파일을 직접 디코딩하는 것은 그 자체로 큰 프로젝트라, 실무에서도 검증된 라이브러리를 씁니다. 우리는 Sean Barrett의 stb_image를 쓰는데, 이건 헤더 파일 하나만 넣으면 되는 "헤더 온리(header-only)" 라이브러리입니다.

image_load.cpp (발췌)cpp
#define STB_IMAGE_IMPLEMENTATION   // 딱 한 번, .cpp 하나에서만 정의
#include "stb_image.h"
#include "framebuffer.h"

// 디스크 이미지 -> Framebuffer (실패 시 0x0 버퍼)
Framebuffer loadImage(const char* path) {
    int w, h, ch;
    // 마지막 인자 4 = 항상 RGBA로 강제 변환
    unsigned char* data = stbi_load(path, &w, &h, &ch, 4);
    if (!data) { std::fprintf(stderr, "load 실패: %s\n", path); return Framebuffer(0, 0); }

    Framebuffer fb(w, h);
    for (int y = 0; y < h; ++y)
        for (int x = 0; x < w; ++x) {
            const unsigned char* p = data + (static_cast<size_t>(y) * w + x) * 4;
            fb.setPixel(x, y, rgb(p[0], p[1], p[2], p[3]));
        }
    stbi_image_free(data);   // stb가 malloc한 메모리는 반드시 해제
    return fb;
}

1-1. #define STB_IMAGE_IMPLEMENTATION — 왜 이 한 줄이 특별한가

보통 #include는 함수의 선언(이름과 시그니처)만 가져오고, 실제 본문(구현)은 별도의 .cpp나 미리 컴파일된 라이브러리에 있습니다. 그런데 헤더 온리 라이브러리는 헤더 안에 구현까지 다 들어 있습니다. 만약 여러 .cpp가 그걸 그대로 #include하면, 같은 함수 본문이 여러 번 컴파일되어 링커가 "중복 정의(multiple definition)" 에러를 냅니다.

stb_image.h는 이 문제를 매크로로 해결합니다. 평소에는 선언만 노출하다가, STB_IMAGE_IMPLEMENTATION정의된 상태에서 include되는 딱 한 개의 파일 에서만 함수 본문을 펼칩니다. 그래서 이 #define은 프로젝트 전체에서 정확히 한 번, 그것도 헤더가 아니라 .cpp 파일 안에서만 써야 합니다.

함정 #define STB_IMAGE_IMPLEMENTATION헤더에 쓰거나, 두 개 이상의 .cpp에 쓰면 링크 단계에서 "이미 정의됨(LNK2005 / duplicate symbol)" 오류가 납니다. 반대로 어디에도 안 쓰면 "정의를 못 찾음(unresolved external symbol)"이 납니다. 정확히 한 곳이 규칙입니다.

1-2. 함수 시그니처 해부 — Framebuffer loadImage(const char* path)

반환 타입은 Framebuffer(값 반환)입니다. "값으로" 돌려주지만 C++17에서는 복사 생략(RVO, 이동) 덕분에 실제로 큰 픽셀 배열이 통째로 복사되지는 않으니 걱정하지 않아도 됩니다.

인자 const char* pathC 문자열(널 종료 문자 배열)로 파일 경로를 받습니다. const가 붙은 이유는 이 함수가 경로 문자열을 읽기만 하고 바꾸지 않겠다는 약속이며, "images/cat.png" 같은 문자열 리터럴을 그대로 넘길 수 있게 해 줍니다. (리터럴은 읽기 전용이라 const가 아니면 넘기기가 까다롭습니다.)

1-3. stbi_load 호출 — 출력 인자(out-parameter) 패턴

int w, h, ch;는 아직 값이 없는 빈 변수 셋입니다. 다음 줄이 이것들을 채웁니다.

unsigned char* data = stbi_load(path, &w, &h, &ch, 4);

stbi_load의 인자를 하나씩 보면:

반환값 dataunsigned char*, 즉 0~255 바이트들이 쭉 늘어선 배열의 시작 주소입니다. 메모리 배치는 [R,G,B,A, R,G,B,A, ...] 순서로, 왼쪽 위 픽셀부터 한 줄씩 아래로 (행 우선) 이어집니다.

1-4. 실패 처리 — 널 포인터 가드

if (!data) { std::fprintf(stderr, "load 실패: %s\n", path); return Framebuffer(0, 0); }

stbi_load는 실패하면 널 포인터(nullptr)를 돌려줍니다. !data는 "data가 널이면 참"이라는 뜻입니다(포인터는 널일 때 false로 평가되므로 !를 붙이면 참이 됩니다). 실패했다면 stderr(표준 에러 스트림)로 메시지를 찍고, 크기 0×0짜리 빈 프레임버퍼를 돌려줍니다. 여기서 곧바로 data를 읽으면 널 포인터 역참조로 프로그램이 뻗기 때문에, 이 가드가 반드시 먼저 와야 합니다.

1-5. 픽셀 복사 루프 — 1차원 바이트 배열을 2차원 좌표로 읽기

const unsigned char* p = data + (static_cast<size_t>(y) * w + x) * 4;
fb.setPixel(x, y, rgb(p[0], p[1], p[2], p[3]));

수학적 의미: 픽셀 (x, y)가 1차원 배열에서 몇 번째 바이트부터 시작하는지를 계산합니다. 한 줄에 w개 픽셀이 있으니 y번째 줄 앞에는 y * w개의 픽셀이 있고, 그 줄 안에서 x만큼 더 갑니다. 즉 픽셀 인덱스는 y * w + x입니다. 한 픽셀은 4바이트(RGBA)이므로, 여기에 * 4를 곱해야 바이트 오프셋이 됩니다.

기하 직관(그림글): 배열을 종이 위에 한 줄로 쭉 늘어놓았다고 상상하세요. 이미지는 원래 사각형이지만, 메모리에서는 "1행 전체 → 2행 전체 → …"로 붙어 있는 긴 띠입니다. (x, y)를 찾으려면 "아래로 y칸 내려가서(=y줄 건너뛰고) 오른쪽으로 x칸" 가면 됩니다. 그게 y*w + x입니다.

코드 매핑: data + (…) * 4는 포인터를 그 바이트 위치로 이동시킵니다(포인터 산술). 그러면 p[0]=R, p[1]=G, p[2]=B, p[3]=A가 됩니다. 이걸 우리 헬퍼 rgb(...)로 하나의 Color로 묶어 fb.setPixel로 씁니다.

함정 static_cast<size_t>(y)가 왜 필요할까요? yw가 모두 int 라면 y * wint 곱셈으로 계산됩니다. 4K·8K 대형 이미지에서는 y * w가 21억(int 최대치)을 넘겨 오버플로가 나고, 엉뚱한 음수 주소를 읽어 크래시합니다. 곱셈 전에 한쪽을 size_t(64비트 부호 없는 정수)로 올려두면 전체 계산이 넓은 타입으로 이뤄져 안전합니다.

1-6. 메모리 해제 — stbi_image_free

stbi_load는 내부에서 malloc으로 메모리를 잡습니다. C++의 new가 아니므로 delete로 지우면 안 되고, 반드시 짝이 되는 stbi_image_free(data)로 돌려줘야 합니다. 이걸 빼먹으면 메모리 누수가 됩니다. 우리는 이미 픽셀을 fb로 전부 복사했으니, 원본 data는 더 이상 필요 없어 여기서 해제합니다.

주의 · DirectX 11 대응 실무의 DirectX 11에서는 이 CPU 바이트 배열을 ID3D11Texture2D로 만들어 GPU에 올립니다. D3D11_TEXTURE2D_DESC에서 Format = DXGI_FORMAT_R8G8B8A8_UNORM(RGBA8), Width/Heightw/h로 채우고, D3D11_SUBRESOURCE_DATA.pSysMem = data, SysMemPitch = w * 4(한 줄의 바이트 수 = "행 피치")로 넘겨 CreateTexture2D를 호출하면 됩니다. 우리가 손으로 계산한 y * w * 4 + x * 4 오프셋이 곧 GPU가 말하는 row pitch 개념과 정확히 같습니다.

2. 그레이스케일 — 휘도 가중치

가장 단순한 흑백 변환은 세 채널 평균 (r+g+b)/3입니다. 그런데 이건 사람 눈과 안 맞습니다. 사람 눈의 원추세포는 초록빛에 압도적으로 민감하고 파랑엔 둔합니다. 그래서 순수한 초록과 순수한 파랑이 물리적으로 같은 세기여도, 초록이 훨씬 밝게 느껴집니다. 이 지각적 밝기를 반영한 표준 가중치가 Rec.601 휘도(luminance)입니다.

Y = 0.299·R + 0.587·G + 0.114·B

grayscale.hcpp
#pragma once
#include "framebuffer.h"

// 휘도 Y = 0.299R + 0.587G + 0.114B  (Rec.601)
inline void toGrayscale(Framebuffer& fb) {
    for (int y = 0; y < fb.height(); ++y)
        for (int x = 0; x < fb.width(); ++x) {
            Color c = fb.getPixel(x, y);
            uint8_t Y = toByte((0.299f * c.r + 0.587f * c.g + 0.114f * c.b) / 255.0f);
            fb.setPixel(x, y, rgb(Y, Y, Y, c.a));
        }
}

2-1. 시그니처 — inline void toGrayscale(Framebuffer& fb)

Framebuffer& fb참조(reference) 인자입니다. &가 붙었으니 복사본이 아니라 원본 그 자체를 받습니다. 그래서 함수 안에서 fb.setPixel로 바꾼 결과가 호출한 쪽의 프레임버퍼에 그대로 반영됩니다(이 함수는 제자리(in-place) 수정입니다). 만약 & 없이 Framebuffer fb였다면 수백만 픽셀이 통째로 복사되고, 수정도 그 복사본에만 남아 아무 효과가 없었을 겁니다.

const 붙은 이유도 여기 있습니다. 이 함수는 fb바꿀 것이므로 const Framebuffer&가 아니라 그냥 Framebuffer&입니다. (앞으로 나올 컨볼루션의 src는 읽기만 하므로 const가 붙는 것과 대조됩니다.) inline은 헤더에 함수 본문을 정의할 때 중복 정의를 피하려고 붙입니다.

2-2. 이중 for 루프 — 모든 픽셀 순회

바깥 y 루프가 행(세로), 안쪽 x 루프가 열(가로)입니다. y를 바깥에 두는 이유는 메모리 배치가 행 우선이라, 같은 행의 픽셀들이 메모리에서 서로 붙어 있기 때문입니다. 이렇게 안쪽에서 x를 빠르게 증가시키면 캐시 지역성(cache locality)이 좋아져 속도가 붙습니다. (x를 바깥, y를 안쪽에 두면 매 반복마다 멀리 떨어진 메모리로 점프해 캐시 미스가 잦아집니다.)

2-3. 핵심 한 줄 — 휘도 계산

uint8_t Y = toByte((0.299f * c.r + 0.587f * c.g + 0.114f * c.b) / 255.0f);

수학적 의미: 세 채널에 서로 다른 가중치를 곱해 더한 가중 평균입니다. 가중치 합이 0.299 + 0.587 + 0.114 = 1.000으로 정확히 1이라, 흰색(255,255,255)을 넣으면 결과도 255가 됩니다(밝기 보존). 초록 계수 0.587이 가장 크다는 게 "눈이 초록에 민감하다"를 수치로 옮긴 것입니다.

기하/지각 직관: RGB 3차원 색공간에서 한 점을, (0.299, 0.587, 0.114) 방향으로 사영(projection)1차원 밝기 축 하나로 눌러 내리는 것과 같습니다. 색은 사라지고 "얼마나 밝은가"만 남습니다.

코드 매핑 · 손계산 예제: 순수 초록 (0,255,0)을 넣어 보면 0.299·0 + 0.587·255 + 0.114·0 = 149.7 → 반올림 150. 순수 파랑 (0,0,255)0.114·255 = 29.1 → 29. 같은 세기의 색인데도 초록이 밝기 150, 파랑은 29로 5배 이상 차이 납니다. 단순 평균이었다면 둘 다 255/3 = 85로 똑같아, 눈에 맞지 않았을 겁니다.

f 접미사(0.299f)는 float 리터럴이라는 표시입니다. 안 붙이면 double로 계산되어 불필요하게 느려질 수 있어, 그래픽스에서는 관례적으로 f를 붙입니다. / 255.0f로 나누는 이유는, toByte0.0~1.0 정규화 값을 받아 다시 0~255 바이트로 바꾸고 범위를 clamp해 주기 때문입니다(뒤 컨볼루션에서 이 clamp가 중요해집니다).

2-4. 결과 쓰기 — 알파는 보존

fb.setPixel(x, y, rgb(Y, Y, Y, c.a));

회색은 R=G=B인 색이므로 세 채널에 같은 Y를 넣습니다. 알파는 밝기와 무관한 불투명도이므로 원본 c.a를 그대로 유지합니다. 여기서 알파까지 Y로 덮어쓰면 투명 영역이 이상해집니다.

주의 · HLSL 대응 같은 연산을 픽셀 셰이더로 옮기면 내적 하나로 끝납니다: float y = dot(color.rgb, float3(0.299, 0.587, 0.114));. dot이 정확히 "각 성분끼리 곱해서 더한다"이므로, 우리 C++의 0.299f*r + 0.587f*g + 0.114f*b같은 식입니다. GPU에서는 색이 이미 0~1 범위라 /255가 필요 없다는 점만 다릅니다. (더 정확한 결과가 필요하면 sRGB 감마를 선형으로 편 뒤 휘도를 구해야 하지만, 학습용으로는 위 식으로 충분합니다.)

3. 일반 3×3 컨볼루션 — 하나로 블러·에지 다 하기

컨볼루션(convolution)은 이미지 처리의 심장입니다. 아이디어는 이렇습니다: 출력의 한 픽셀은, 자기 자신과 주변 8개 이웃(합 9개)에 커널(kernel)이라 부르는 3×3 가중치를 각각 곱해서 전부 더한 값입니다. 커널의 숫자만 바꾸면 같은 함수 하나로 블러·샤픈·에지 검출이 전부 나옵니다.

수식으로 쓰면, 커널 k와 이미지 I에 대해 출력 픽셀은

out(x,y) = Σky=-1..1 Σkx=-1..1 k(kx,ky) · I(x+kx, y+ky)

두 개의 합(Σ)이 곧 아래 코드의 이중 for (ky) for (kx) 루프입니다. 원본을 읽으면서 제자리 수정하면 방금 고친 픽셀을 이웃으로 다시 읽어 결과가 오염되므로, 입력 src에서 읽고 출력 dst에 씁니다(둘은 서로 다른 버퍼여야 합니다).

convolve.hcpp
#pragma once
#include <array>
#include <algorithm>
#include "framebuffer.h"

// 경계에서 좌표를 이미지 안으로 고정(clamp-to-edge). 가장 무난한 처리.
inline int clampCoord(int v, int lo, int hi) {
    return std::max(lo, std::min(v, hi));
}

// 3x3 커널을 채널별로 적용. divisor로 정규화, offset으로 바이어스.
// 결과를 dst에 쓴다(src와 달라야 안전).
inline void convolve3x3(const Framebuffer& src, Framebuffer& dst,
                        const std::array<float, 9>& k,
                        float divisor = 1.0f, float offset = 0.0f) {
    const int W = src.width(), H = src.height();
    for (int y = 0; y < H; ++y)
        for (int x = 0; x < W; ++x) {
            float sr = 0, sg = 0, sb = 0;
            for (int ky = -1; ky <= 1; ++ky)
                for (int kx = -1; kx <= 1; ++kx) {
                    int sx = clampCoord(x + kx, 0, W - 1);
                    int sy = clampCoord(y + ky, 0, H - 1);
                    Color c = src.getPixel(sx, sy);
                    float w = k[(ky + 1) * 3 + (kx + 1)];
                    sr += w * c.r; sg += w * c.g; sb += w * c.b;
                }
            Color out;
            out.r = toByte((sr / divisor + offset) / 255.0f);
            out.g = toByte((sg / divisor + offset) / 255.0f);
            out.b = toByte((sb / divisor + offset) / 255.0f);
            out.a = src.getPixel(x, y).a;
            dst.setPixel(x, y, out);
        }
}

// ── 박스 블러: 모든 가중치 1, 합 9로 나눔 ──
inline void boxBlur(const Framebuffer& src, Framebuffer& dst) {
    convolve3x3(src, dst,
        { 1,1,1, 1,1,1, 1,1,1 }, /*divisor=*/9.0f);
}

// ── 소벨 에지: 수평/수직 그래디언트 크기 ──
// 흑백으로 먼저 바꾼 이미지에 쓰는 게 일반적이다.
inline void sobelEdges(const Framebuffer& src, Framebuffer& dst) {
    const std::array<float, 9> gx = { -1,0,1, -2,0,2, -1,0,1 };
    const std::array<float, 9> gy = { -1,-2,-1, 0,0,0, 1,2,1 };
    const int W = src.width(), H = src.height();
    for (int y = 0; y < H; ++y)
        for (int x = 0; x < W; ++x) {
            float dx = 0, dy = 0;
            for (int ky = -1; ky <= 1; ++ky)
                for (int kx = -1; kx <= 1; ++kx) {
                    int sx = clampCoord(x + kx, 0, W - 1);
                    int sy = clampCoord(y + ky, 0, H - 1);
                    // 밝기(그레이 가정: r 사용) 하나만 미분
                    float v = src.getPixel(sx, sy).r;
                    int i = (ky + 1) * 3 + (kx + 1);
                    dx += gx[i] * v;
                    dy += gy[i] * v;
                }
            float mag = std::sqrt(dx * dx + dy * dy); // 그래디언트 크기
            uint8_t m = toByte(mag / 255.0f);
            dst.setPixel(x, y, rgb(m, m, m));
        }
}

3-1. clampCoord — 경계 좌표를 이미지 안으로 접기

inline int clampCoord(int v, int lo, int hi) {
    return std::max(lo, std::min(v, hi));
}

무엇을 하나:v[lo, hi] 범위 안으로 가둡니다. std::min(v, hi)가 먼저 "hi보다 크면 hi로" 눌러 위쪽을 막고, 그 결과에 std::max(lo, …)가 "lo보다 작으면 lo로" 올려 아래쪽을 막습니다. 두 번 감싸서 양쪽을 다 막는 이 관용구가 바로 clamp입니다.

왜 필요한가: 컨볼루션은 이웃을 읽는데, 이미지 가장자리 픽셀은 왼쪽/위쪽 이웃이 이미지 밖(음수 좌표)입니다. 예컨대 x=0에서 kx=-1이면 x+kx = -1. 이 좌표로 getPixel(-1, …)을 부르면 배열 범위 밖 접근으로 크래시합니다. clampCoord-10으로 접어 가장자리 픽셀을 반복해서 읽게 합니다. 이 방식을 clamp-to-edge라 부릅니다.

손계산 예제:W=100인 이미지에서 오른쪽 끝 x=99일 때 kx=+1이면 x+kx = 100. clampCoord(100, 0, 99)는 먼저 min(100,99)=99, 그다음 max(0,99)=9999로 접힙니다. 밖으로 한 칸 삐져나간 좌표가 마지막 유효 픽셀로 안전하게 되돌아옵니다.

3-2. convolve3x3 시그니처 — 인자 하나하나

inline void convolve3x3(const Framebuffer& src, Framebuffer& dst,
                        const std::array<float, 9>& k,
                        float divisor = 1.0f, float offset = 0.0f)

3-3. 누적 변수와 이웃 순회

float sr = 0, sg = 0, sb = 0;
for (int ky = -1; ky <= 1; ++ky)
    for (int kx = -1; kx <= 1; ++kx) {

sr, sg, sbR/G/B 채널별 가중합 누적기입니다. 각 출력 픽셀마다 0으로 초기화하는 게 중요합니다(다음 픽셀로 넘어가기 전에 반드시 리셋). 여기서 정수가 아니라 float인 이유는, 가중치가 소수(예: 블러의 1/9)이거나 음수(에지)라서 중간 합이 정수 범위를 벗어나거나 소수가 되기 때문입니다.

ky, kx-1, 0, +1을 도는 것은 중심 픽셀을 (0,0)으로 두는 상대 좌표입니다. (kx,ky)=(-1,-1)은 왼쪽 위 이웃, (0,0)은 자기 자신, (+1,+1)은 오른쪽 아래 이웃. 3×3이므로 총 9번 돕니다.

3-4. 샘플 좌표 clamp와 커널 인덱싱

int sx = clampCoord(x + kx, 0, W - 1);
int sy = clampCoord(y + ky, 0, H - 1);
Color c = src.getPixel(sx, sy);
float w = k[(ky + 1) * 3 + (kx + 1)];
sr += w * c.r; sg += w * c.g; sb += w * c.b;

x + kx, y + ky가 실제로 읽을 이웃의 절대 좌표입니다. 앞서 만든 clampCoord[0, W-1]·[0, H-1] 범위에 가둔 뒤 getPixel로 그 이웃 색을 가져옵니다.

커널 인덱싱 (ky+1)*3 + (kx+1)이 핵심입니다. ky, kx-1..+1 범위인데, 배열 인덱스는 0..8이어야 합니다. 그래서 +1을 해서 0..2로 옮긴 뒤, "행 우선 펼치기" 공식 행*폭 + 열 = (ky+1)*3 + (kx+1)로 9개 배열의 위치를 계산합니다. 표로 보면:

kx=-1kx=0kx=+1
ky=-1k[0]k[1]k[2]
ky=0k[3]k[4] (중심)k[5]
ky=+1k[6]k[7]k[8]

마지막 세 줄 sr += w * c.r; …이 곧 수식의 Σ k·I입니다. 각 이웃 색에 해당 커널 가중치를 곱해 채널별로 계속 더해 나갑니다.

3-5. 결과 정규화 — divisor와 offset

out.r = toByte((sr / divisor + offset) / 255.0f);
// g, b 동일
out.a = src.getPixel(x, y).a;

왜 divisor로 나누나: 박스 블러 커널은 모든 가중치가 1이라, 9개 이웃을 더하면 밝기가 약 9배로 부풀어 전부 흰색(255 clamp)이 됩니다. divisor=9로 나눠 평균으로 되돌려야 원래 밝기가 유지됩니다. 일반화하면 divisor = 커널 가중치의 합으로 두면 밝기가 보존됩니다.

왜 offset을 더하나: 에지·엠보스 커널은 가중치 합이 0이라, 평평한 영역에서는 결과가 0 근처가 되고 음수도 나옵니다. 음수는 바이트로 못 담으니(0으로 clamp) 정보가 날아갑니다. offset=128을 더하면 "변화 없음 = 회색 128"을 기준으로 삼아, 밝아지는 쪽/어두워지는 쪽을 모두 보이게 만들 수 있습니다. (아래 소벨은 크기만 쓰므로 offset이 필요 없습니다.)

out.a = src.getPixel(x, y).a;는 알파를 컨볼루션하지 않고 원본의 알파를 그대로 가져옵니다. 알파까지 이웃과 섞으면 반투명 경계가 뭉개지므로, 색만 필터링하고 알파는 중심 픽셀 것을 유지하는 게 보통입니다.

마지막 toByte(... / 255.0f)는 앞의 그레이스케일과 같은 패턴입니다. toByte0~255 범위 밖 값을 clamp해 주기 때문에, 블러가 살짝 255를 넘거나 에지가 음수가 되어도 안전하게 유효한 바이트로 정리됩니다.

3-6. boxBlur — 커널 하나만 넣으면 끝

convolve3x3(src, dst, { 1,1,1, 1,1,1, 1,1,1 }, /*divisor=*/9.0f);

중괄호 { 1,1,1, 1,1,1, 1,1,1 }std::array<float,9>집합 초기화(aggregate initialization)입니다. 9개 값을 나열하면 배열 원소로 순서대로 채워집니다. 모든 가중치가 1이니 "이웃 9개를 똑같이 섞는다", 즉 평균입니다. divisor=9로 나눠 밝기를 보존합니다. /*divisor=*/는 주석으로 인자 이름을 표시해 가독성을 높인 것뿐입니다.

왜 흐려지나(직관): 각 픽셀을 주변과 평균 내면, 튀는 값(고주파 성분·노이즈·날카로운 경계)이 이웃에 녹아들어 부드러워집니다. 커널이 클수록(5×5, 7×7…) 더 넓게 평균해 더 흐려집니다.

3-7. sobelEdges — 두 방향 미분으로 윤곽 찾기

소벨은 컨볼루션의 특수 버전이라 재사용 대신 별도 루프로 썼습니다. 커널이 (gx, gy)이고, 결과를 합산이 아니라 벡터 크기로 합치기 때문입니다.

const std::array<float, 9> gx = { -1,0,1, -2,0,2, -1,0,1 };
const std::array<float, 9> gy = { -1,-2,-1, 0,0,0, 1,2,1 };

gx(가로 미분): 왼쪽 열이 음수(-1,-2,-1), 오른쪽 열이 양수(+1,+2,+1), 가운데 열은 0입니다. 즉 "오른쪽 밝기 − 왼쪽 밝기"를 잽니다. 좌우 밝기가 같으면(평평한 면) 합이 0이 되고, 세로 경계처럼 좌우가 확 다르면 큰 값이 나옵니다. 가운데 행에 ±2를 준 건 중심 행에 더 큰 가중치를 줘서 노이즈에 덜 흔들리게 하는 소벨 특유의 설계입니다.

gy(세로 미분): gx를 90도 돌린 것으로, "아래 − 위"를 재어 가로 경계에 반응합니다.

수학적 의미: gx는 밝기의 x 방향 편미분 ∂I/∂x, gy는 ∂I/∂y의 근사입니다. 둘을 묶은 벡터 (dx, dy)가 밝기의 그래디언트(gradient), 즉 "밝기가 가장 빠르게 변하는 방향과 세기"입니다.

float v = src.getPixel(sx, sy).r;   // 그레이 가정: r만 사용
dx += gx[i] * v;
dy += gy[i] * v;
...
float mag = std::sqrt(dx * dx + dy * dy);

입력이 이미 그레이스케일(R=G=B)이라고 가정하므로 .r 하나만 미분하면 됩니다. 두 커널을 같은 루프에서 동시에 적용해 dx, dy를 각각 누적합니다.

sqrt(dx² + dy²)인가(직관): (dx, dy)는 방향이 있는 벡터인데, "여기 에지가 얼마나 강한가"만 알면 되므로 방향을 버리고 길이(크기)만 취합니다. 피타고라스 정리 그대로 두 직교 성분의 제곱합의 제곱근이 벡터 길이입니다. 밝기 변화가 클수록 이 크기가 커져, 윤곽선이 밝게(흰색에 가깝게) 나타납니다.

성능 실무에서 정확한 크기 대신 |dx| + |dy|(맨해튼 근사)를 쓰기도 합니다. sqrt가 비싸기 때문인데, 요즘 CPU/GPU에서는 sqrt 비용이 크지 않아 정확도를 위해 그냥 써도 대개 괜찮습니다. 대량 픽셀에서 병목이 확인될 때만 근사를 고려하세요("추측 말고 측정").
함정 제자리(in-place) 컨볼루션은 버그의 원천입니다. 왼쪽 픽셀을 이미 갱신한 뒤 그 값을 다음 픽셀의 이웃으로 다시 읽으면 이미 필터링된 값이 또 섞여 결과가 번지고 방향성 얼룩이 생깁니다. 반드시 원본 스냅샷(src)에서 읽고 결과는 별도 버퍼(dst)에 쓰세요. 한 버퍼만 쓰고 싶다면, 필터 전에 통째로 복사본을 하나 떠서 그걸 src로 쓰면 됩니다.
주의 경계 처리 방식은 결과를 바꿉니다. 위 코드는 clamp-to-edge(가장자리 픽셀 반복)입니다. 대안으로 wrap(반대편으로 감기), mirror(반사), 또는 아예 경계 픽셀을 건너뛰기가 있습니다. 소벨에서 clamp를 쓰면 가장자리에서 같은 픽셀이 반복되어 가짜 에지가 살짝 생길 수 있음을 감안하세요. DirectX의 텍스처 샘플러로 옮기면 이 선택이 그대로 D3D11_TEXTURE_ADDRESS_MODECLAMP/WRAP/MIRROR에 대응합니다.
성능 가우시안/박스 블러는 분리 가능(separable)합니다. 2D N×N 블러는 픽셀당 N² 샘플이지만, 가로 1D 패스 + 세로 1D 패스로 쪼개면 픽셀당 2N 샘플로 줄어듭니다. 3×3은 9→6이라 이득이 작지만, 반경이 커질수록 O(N²)→O(N)의 차이가 결정적입니다(예: 15×15면 225 → 30, 7.5배). 소벨의 gx· gy도 사실 [1,2,1](스무딩) × [-1,0,1](미분)로 분리됩니다.

4. GPU로 옮기면 — DirectX 11 대응

지금까지 CPU에서 성분 하나하나 손으로 계산했는데, 이걸 GPU로 옮기면 모든 픽셀이 동시에 계산됩니다. 두 가지 길이 있습니다.

  1. 풀스크린 픽셀 셰이더 — 화면을 덮는 사각형 하나를 그리고, 각 픽셀에서 텍스처를 이웃 샘플링해 컨볼루션. 후처리(post-processing)의 표준 방식입니다.
  2. 컴퓨트 셰이더 — 렌더 파이프라인 없이 Dispatch로 직접 이미지를 돌립니다. 공유 메모리로 이웃을 재사용해 더 빠릅니다.

아래는 소벨 에지를 픽셀 셰이더로 옮긴 예입니다. 우리 C++ 루프의 gx·gy·sqrt가 그대로 보입니다.

sobel.hlsl (픽셀 셰이더)hlsl
Texture2D    gTex   : register(t0);   // 입력 이미지 (그레이스케일 가정)
SamplerState gSamp  : register(s0);   // CLAMP 주소 모드로 설정

cbuffer CB : register(b0)
{
    float2 texelSize;   // (1/width, 1/height): 이웃으로 한 픽셀 이동량
}

// 밝기 하나만 뽑는 헬퍼 (그레이 입력이면 .r만 써도 됨)
float luma(float2 uv)
{
    float3 c = gTex.Sample(gSamp, uv).rgb;
    return dot(c, float3(0.299, 0.587, 0.114));
}

float4 PSMain(float2 uv : TEXCOORD0) : SV_Target
{
    float dx = 0.0, dy = 0.0;
    // 3x3 이웃을 상대 오프셋 (-1..1)로 순회
    [unroll] for (int ky = -1; ky <= 1; ++ky)
    [unroll] for (int kx = -1; kx <= 1; ++kx)
    {
        float2 off = float2(kx, ky) * texelSize;   // 이웃 UV
        float  v   = luma(uv + off);
        // gx: 왼쪽 -부호, 오른쪽 +부호 / 가운데 행 x2
        float  wx  = (kx) * ((ky == 0) ? 2.0 : 1.0);
        // gy: 위 -부호, 아래 +부호 / 가운데 열 x2
        float  wy  = (ky) * ((kx == 0) ? 2.0 : 1.0);
        dx += wx * v;
        dy += wy * v;
    }
    float mag = sqrt(dx * dx + dy * dy);   // 그래디언트 크기
    return float4(mag, mag, mag, 1.0);     // 흑백 에지
}

4-1. 리소스 바인딩 — Texture2DSamplerState

Texture2D gTex : register(t0)는 우리 C++의 src 프레임버퍼에 대응합니다. GPU에서는 이미지가 텍스처로 올라가고, register(t0)는 "0번 텍스처 슬롯"이라는 뜻입니다 (C++ 쪽에서 PSSetShaderResources(0, 1, &srv)로 여기에 꽂습니다).

SamplerState gSamp는 "텍스처를 어떻게 읽을지"의 규칙입니다. 우리 clampCoord가 했던 경계 처리가 여기서는 샘플러의 주소 모드로 대체됩니다. C++ 쪽에서 D3D11_SAMPLER_DESC.AddressU/V = D3D11_TEXTURE_ADDRESS_CLAMP로 만들면, 우리의 clamp-to-edge와 완전히 같은 동작이 됩니다. 즉 GPU에서는 경계 처리를 코드로 안 짜고 샘플러 설정으로 끝냅니다.

4-2. texelSize — "한 픽셀 이동"을 UV로 환산

CPU에서는 이웃을 x + kx처럼 정수 픽셀 좌표로 옮겼습니다. 그런데 셰이더의 텍스처 좌표(UV)는 0~1로 정규화되어 있습니다(왼쪽 0, 오른쪽 1). 그래서 "1픽셀 옆"은 UV로 1/width만큼입니다. 이 값을 texelSize = (1/width, 1/height)로 cbuffer에 담아 넘깁니다. float2(kx, ky) * texelSize가 곧 CPU의 (kx, ky) 픽셀 오프셋과 같은 역할입니다.

함정 규약: 텍스처 V(세로 UV)는 위에서 아래로 증가합니다(위=0, 아래=1). 그래서 +ky가 "아래로"가 맞아, 소벨 gy의 "아래 − 위" 부호가 CPU 코드와 그대로 일치합니다. 만약 어떤 API에서 V가 뒤집혀 있으면(아래=0), 세로 에지 방향이 반대로 나오니 ky 부호를 뒤집거나 UV를 1 - v로 맞춰야 합니다.

4-3. 커널을 코드로 — 하드코딩 대신 부호 계산

float wx = (kx) * ((ky == 0) ? 2.0 : 1.0);
float wy = (ky) * ((kx == 0) ? 2.0 : 1.0);

CPU에서는 9개 값을 배열로 나열했지만, 여기서는 규칙을 식으로 풀었습니다. gx의 가중치는 "열 부호(kx) × 가운데 행이면 2배"입니다. kx=-1이면 -1·(…), kx=+1이면 +1·(…), kx=0(가운데 열)이면 0이 되어 그 열이 무시됩니다. ky==0(가운데 행)일 때만 2를 곱해 소벨의 -2,0,2 행을 만듭니다. 결과는 배열 {-1,0,1,-2,0,2,-1,0,1}과 한 성분도 빠짐없이 일치합니다. [unroll]은 짧은 고정 루프를 펼쳐 분기 오버헤드를 없애라는 힌트입니다.

4-4. C++ ↔ HLSL 1:1 대응표

C++ (CPU)HLSL (GPU)의미
const Framebuffer& srcTexture2D gTex입력 이미지
clampCoord(...)Sampler의 ADDRESS_CLAMP경계 처리
src.getPixel(sx,sy)gTex.Sample(gSamp, uv)이웃 읽기
x+kx (정수 픽셀)uv + float2(kx,ky)*texelSize이웃 오프셋
0.299f*r+...dot(c, float3(0.299,0.587,0.114))휘도
std::sqrt(dx*dx+dy*dy)sqrt(dx*dx+dy*dy)그래디언트 크기
이중 for (y) for (x)래스터라이저가 픽셀마다 PSMain 호출전 픽셀 순회(GPU는 병렬)
dst.setPixel(...)return ... : SV_Target결과 출력

가장 큰 차이는 바깥의 for (y) for (x) 루프가 사라진다는 점입니다. CPU는 픽셀을 하나씩 순서대로 돌지만, GPU는 래스터라이저가 모든 픽셀에 대해 PSMain을 동시에 호출합니다. 우리가 짠 것은 "픽셀 한 개의 계산"뿐이고, 반복은 하드웨어가 병렬로 처리합니다. 이것이 이미지 필터가 GPU에서 극적으로 빨라지는 이유입니다.

주의 · 규약 이 페이지의 컨볼루션은 벡터 변환이 아니라 스칼라 가중합이라 행벡터/행렬 규약 (v' = v*M, mul(v, M), 행 우선 저장, 왼손 좌표계, 클립 z [0,1])이 직접 등장하지는 않습니다. 다만 후처리 셰이더를 그릴 때 풀스크린 사각형의 정점을 변환한다면 그 단계에서는 프로젝트 전체와 동일한 규약을 따릅니다. 실무에서는 이런 필터를 직접 짜기보다 검증된 후처리 라이브러리나 DirectXTK의 포스트프로세스를 쓰는 편이 안전하며, 위 코드는 원리를 이해하기 위한 학습용입니다.