알파 블렌딩 — over 합성 — 한 줄 한 줄 직접 구현
반투명한 유리창 너머로 뒤 풍경이 비치듯이, 화면 위 반투명 픽셀은 자기 색과 뒤에 이미 그려진 색을
섞어야 합니다. 이 섞는 규칙의 표준이 Porter-Duff의 "over" 연산자입니다. 이 페이지에서는 그 한 줄짜리 공식
result = src·a + dst·(1−a)가 채널마다 정확히 무엇을 계산하는지, 왜 0~255 정수와 0~1 실수를 왔다 갔다 하는지,
그리고 "사전 곱셈 알파(premultiplied)"가 왜 연쇄 합성에서 수학적으로 옳은 방식인지를 곱셈 하나, 부호 하나까지
뜯어봅니다. 마지막에는 이 모든 것이 Direct3D 11의 D3D11_BLEND_DESC 몇 줄로 어떻게 GPU에 대응되는지 1:1로 짚습니다.
Color{ r, g, b, a } 구조체에 0~255 부호 없는 바이트로
저장하고, 알파도 0(완전 투명)~255(완전 불투명)입니다. 블렌드 계산은 0~1 실수로 정규화한 뒤 수행하고,
결과를 다시 바이트로 되돌립니다. 텍스처 V축은 위→아래(위쪽이 0), 클립 공간 z는 [0,1]입니다.
framebuffer.h에서 만든
Color 구조체, Framebuffer 클래스(getPixel/setPixel/inBounds/width/height),
그리고 실수를 0~255 바이트로 안전하게 자르는 헬퍼 toByte(float)를 #include해서 씁니다.
이 페이지에서는 그 재료를 조합만 합니다.
먼저: "합성(compositing)"이 뭘 섞는 건가
2D든 3D든, 화면의 한 픽셀에 색을 칠할 때 그 자리에 이미 다른 색이 있을 수 있습니다. 예를 들어 파란 배경을
먼저 칠하고, 그 위에 반쯤 투명한 빨간 원을 그리면, 원의 안쪽은 "빨강 반 + 파랑 반" 같은 보라색이 되어야 자연스럽습니다.
이때 새로 칠하려는 색을 소스(source, src), 이미 그 자리에 있던 색을 대상(destination, dst)이라
부릅니다. 합성이란 이 src와 dst를 알파(투명도) 값에 따라 어떻게 섞을지를 정하는 규칙입니다.
Porter & Duff는 1984년 논문에서 이런 합성 규칙을 12가지로 정리했는데, 그중 압도적으로 많이 쓰이는 것이 "over"입니다. 말 그대로 "src를 dst 위에 얹는다"는 뜻입니다. 직관은 이렇습니다:
- 소스 알파
a = 1(완전 불투명)이면 → 결과는 100% 소스 색. 뒤 배경은 완전히 가려집니다. - 소스 알파
a = 0(완전 투명)이면 → 결과는 100% 배경 색. 소스는 아예 안 보입니다. - 소스 알파
a = 0.5(반투명)이면 → 결과는 소스 절반 + 배경 절반. 유리창 너머 풍경처럼 섞입니다.
result.rgb = src.rgb × src.a + dst.rgb × (1 − src.a)
이 한 줄이 이 페이지의 전부입니다. 왼쪽 항 src.rgb × src.a는 "소스가 자기 알파만큼 앞을 덮는다",
오른쪽 항 dst.rgb × (1 − src.a)는 "나머지 1 − a만큼은 배경이 비친다"는 뜻입니다.
두 비율을 더하면 a + (1 − a) = 1, 즉 가중치의 합이 항상 1이라 색이 밝아지거나 어두워지지 않고
딱 "섞이기만" 합니다. 이것을 흔히 선형 보간(lerp)이라 부릅니다 — src.a를 보간 비율로 삼아
dst에서 src로 이동하는 것과 정확히 같습니다.
- 채널(R, G, B)별로 "over" 공식을 직접 구현한다.
- 프레임버퍼의 기존 픽셀(dst) 위에 소스(src)를 블렌드해서 덮어쓰는 함수를 만든다.
- 사전 곱셈 알파(premultiplied)가 왜 연쇄 합성에서 옳은지 이해하고, 더 단순한 식으로 다시 구현한다.
1. straight 알파에서의 "over" — blendOver
무엇을 만드나: 소스 색 하나와 대상 색 하나를 받아, over 공식으로 섞은 결과 색 하나를 돌려주는 순수 함수입니다. 왜 필요한가: 반투명 스프라이트, 안티에일리어싱된 폰트 가장자리, UI 오버레이 — 화면에 겹쳐 그리는 거의 모든 것이 픽셀 단위로 이 함수를 호출합니다. GPU가 하드웨어로 해주는 그 일을, 원리를 이해하기 위해 손으로 짜보는 것입니다.
여기서 "straight(비사전곱)" 알파란, 우리가 흔히 생각하는 평범한 방식입니다. RGB는 그냥 색 그 자체이고, 알파는 그와 따로 저장된 투명도입니다. 뒤에 나올 premultiplied와 대비되는 이름이라고만 기억하세요.
#pragma once
#include "framebuffer.h"
// straight(비사전곱) 알파에서의 "over" 연산.
// src를 dst 위에 알파 합성한 결과를 돌려준다.
inline Color blendOver(Color src, Color dst) {
float sa = src.a / 255.0f;
float ia = 1.0f - sa; // 배경이 비치는 비율
Color out;
out.r = toByte((src.r * sa + dst.r * ia) / 255.0f);
out.g = toByte((src.g * sa + dst.g * ia) / 255.0f);
out.b = toByte((src.b * sa + dst.b * ia) / 255.0f);
// 결과 알파도 합성: a_out = sa + da*(1-sa)
float da = dst.a / 255.0f;
out.a = toByte(sa + da * ia);
return out;
}
// 프레임버퍼의 (x,y) 위에 src를 알파 블렌드해서 덮어쓴다.
inline void blendPixel(Framebuffer& fb, int x, int y, Color src) {
if (!fb.inBounds(x, y)) return;
if (src.a == 255) { fb.setPixel(x, y, src); return; } // 불투명이면 그냥 쓰기(빠른 경로)
if (src.a == 0) return; // 완전 투명이면 아무것도 안 함
Color dst = fb.getPixel(x, y);
fb.setPixel(x, y, blendOver(src, dst));
}
시그니처 해부: inline Color blendOver(Color src, Color dst)
한 부분씩 뜯어봅니다.
-
inline— 이 함수를 헤더(.h)에 정의했기 때문에 붙입니다. 여러.cpp파일이 같은 헤더를#include하면 같은 함수 정의가 여러 번 생기는데, C++에서 그건 원래 "중복 정의" 오류입니다.inline은 "이 함수는 여러 번 정의돼도 다 같은 것이니 하나로 취급하라"고 링커에게 허락하는 키워드입니다(요즘은 성능상 인라인 전개 여부와는 별개의 의미로 더 자주 쓰입니다). -
Color반환 — 결과 색 하나를 값으로 돌려줍니다.Color는{r,g,b,a}네 바이트뿐인 작은 구조체라 값 복사 비용이 사실상 없습니다. 그래서 아래 인자들도 참조(const Color&)가 아니라 그냥 값으로 받습니다. -
Color src, Color dst— 소스와 대상을 값으로 복사해서 받습니다. 원본을 바꾸지 않고 새 색을 계산해 돌려주는 순수 함수이므로, 부작용이 없고 테스트하기 쉽습니다. (Vec3처럼 크기가 큰 타입이라면const Color&로 받아 복사를 피했겠지만, 4바이트짜리에는 참조가 오히려 손해일 수 있습니다.)
1단계 — 알파를 0~1로 정규화: float sa = src.a / 255.0f;
수학적 의미: over 공식의 알파는 "0에서 1 사이의 비율"이어야 합니다. 그런데 우리 Color는 알파를
0~255 정수로 저장합니다. 그래서 255로 나눠 [0,255] → [0,1]로 옮깁니다. 이걸 정규화(normalize)라고 합니다.
왜 255.0f이고 255가 아닌가: 여기가 입문자가 가장 자주 틀리는 곳입니다. src.a는
정수, 255도 정수입니다. C++에서 정수 ÷ 정수는 정수 나눗셈이라 소수점을 버립니다. 예를 들어
128 / 255는 0.5가 아니라 0이 됩니다! 나누는 수에 .0f를 붙여 255.0f
(float)로 만들면, 자동으로 src.a도 float으로 승격되어 실수 나눗셈이 일어나
128 / 255.0f ≈ 0.502가 됩니다.
/ 255(정수)와 / 255.0f(실수)는 결과가 하늘과 땅 차이입니다. 알파 128인 반투명 픽셀이 정수
나눗셈에선 sa = 0이 되어 완전히 사라지고, 반투명 효과가 통째로 날아갑니다. 색 계산에서
"왜 이미지가 안 보이지?"의 절반은 이 .0f 하나 빠진 것입니다.
2단계 — 배경이 비치는 비율: float ia = 1.0f - sa;
수학적 의미: over 공식 오른쪽 항의 (1 − src.a)입니다. 소스가 sa만큼 앞을 덮으니,
남은 1 − sa만큼은 뒤 배경이 비쳐 보입니다. ia는 "inverse alpha", 즉 "여집합 알파"라는 뜻의 흔한 이름입니다.
기하 직관(그림글): 알파를 "불투명한 물감을 얼마나 두껍게 발랐나"로 상상하면 좋습니다. sa = 0.7이면
앞 물감이 화면의 70%를 가리고, 나머지 30%(ia = 0.3)의 틈으로 뒤 색이 드러납니다. 두 값을 더하면 항상 100%이므로,
화면의 그 픽셀은 "앞 70% + 뒤 30%"로 정확히 꽉 찹니다.
3단계 — 채널별 over: out.r = toByte((src.r * sa + dst.r * ia) / 255.0f);
R 채널 한 줄을 완전히 해부합니다. G, B도 문자만 다를 뿐 똑같습니다.
수학적 의미 → 코드 매핑: 공식 result.r = src.r × sa + dst.r × ia를 그대로 옮긴 것입니다.
다만 주의할 점 — src.r과 dst.r은 여전히 0~255 정수입니다. 그래서 곱셈
src.r * sa의 결과도 0~255 스케일의 실수가 됩니다(예: 200 * 0.7 = 140). 두 항을 더하면 여전히
0~255 범위의 실수입니다. 그런데 toByte는 관례상 0~1 실수를 받아 0~255로 되돌리는 헬퍼이므로,
괄호 밖에서 다시 / 255.0f로 나눠 0~1 범위로 맞춰 준 뒤 toByte에 넘깁니다.
src.r(0~255) × sa(0~1) = 0~255 스케일 → 더해도 0~255 → /255.0f → 0~1 →
toByte → 다시 0~255 바이트. 곱하는 항(sa, ia)이 이미 0~1이므로, 정수 색을
한 번 더 255로 나눌 필요가 없고 마지막에 딱 한 번만 나눠주면 됩니다.
손계산 예제 — 정말 보라색이 나올까?
빨간 반투명 소스 src = {255, 0, 0, 128}을 파란 불투명 배경 dst = {0, 0, 255, 255} 위에 올려봅니다.
알파 128은 대략 절반입니다.
sa = 128 / 255.0f ≈ 0.502,ia = 1 − 0.502 = 0.498out.r = (255 × 0.502 + 0 × 0.498) / 255 = 128.0 / 255 ≈ 0.502→toByte→ 128out.g = (0 × 0.502 + 0 × 0.498) / 255 = 0→ 0out.b = (0 × 0.502 + 255 × 0.498) / 255 = 127.0 / 255 ≈ 0.498→ 127
결과는 {128, 0, 127, ...} — 빨강 절반 + 파랑 절반, 정확히 예상한 보라색입니다. 손으로 짚어보면
공식이 "섞는다"는 게 그저 두 색을 알파 비율로 평균 낸다는 사실이 눈에 들어옵니다.
4단계 — 결과 알파도 합성: out.a = toByte(sa + da * ia);
RGB만 섞으면 끝이 아닙니다. 이 결과 픽셀 자체가 또 다른 배경 위에 올라갈 수도 있으니, 결과의 투명도도 계산해야 합니다.
공식은 a_out = sa + da × (1 − sa)입니다.
기하 직관: "이 자리가 얼마나 불투명해졌나"를 묻는 것입니다. 소스가 sa만큼 채우고, 소스가 못 채운
틈(1 − sa = ia)을 대상의 불투명도 da가 da × ia만큼 마저 채웁니다. 예를 들어
소스가 반투명(sa = 0.5)이고 배경이 완전 불투명(da = 1)이면, a_out = 0.5 + 1 × 0.5 = 1.0 —
당연히 불투명 배경 위에 뭘 얹어도 결과는 여전히 불투명합니다. 반대로 배경도 반투명(da = 0.5)이면
a_out = 0.5 + 0.5 × 0.5 = 0.75로, 두 반투명이 겹쳐 더 진해집니다.
out.a = toByte(sa + da * ia)는 이미 0~1 실수(sa, da, ia가 전부 0~1)라
추가로 /255.0f를 하지 않습니다. RGB 줄과 달리 정수 색을 곱하지 않았기 때문입니다. 반대로
RGB 줄에서 /255.0f를 빠뜨리면 값이 255배 커져 전부 흰색(255로 클램프)이 됩니다. 스케일이 어디서 0~1인지,
어디서 0~255인지 항상 의식하세요.
2. 프레임버퍼에 얹기 — blendPixel
무엇을 만드나: blendOver는 색끼리의 순수 계산이었습니다. blendPixel은 그것을 실제
프레임버퍼의 특정 좌표 (x, y)에 적용해서 그 자리를 덮어쓰는 함수입니다. 스프라이트나 도형을 그릴 때
픽셀마다 이 함수를 호출합니다.
inline void blendPixel(Framebuffer& fb, int x, int y, Color src) {
if (!fb.inBounds(x, y)) return;
if (src.a == 255) { fb.setPixel(x, y, src); return; } // 불투명이면 그냥 쓰기(빠른 경로)
if (src.a == 0) return; // 완전 투명이면 아무것도 안 함
Color dst = fb.getPixel(x, y);
fb.setPixel(x, y, blendOver(src, dst));
}
줄 단위 해부
-
Framebuffer& fb— 참조로 받습니다. 이 함수의 목적은fb의 픽셀을 실제로 바꾸는 것이므로, 값 복사(Framebuffer fb)로 받으면 원본은 그대로고 복사본만 바뀌어 아무 효과가 없습니다. 참조(&)여야 호출한 쪽의 진짜 버퍼가 수정됩니다. 또 프레임버퍼는 수백만 픽셀을 담은 큰 객체라 복사 비용도 막대합니다. -
if (!fb.inBounds(x, y)) return;— 경계 검사. 화면 밖 좌표에 쓰면 배열 범위를 벗어나 메모리를 깨뜨립니다(정의되지 않은 동작, 크래시). 스프라이트가 화면 가장자리에 걸치면 일부 픽셀이 밖으로 나가므로, 매 픽셀 안전하게 막아 줍니다. 이 한 줄 덕분에 호출하는 쪽(스프라이트 blit)은 클리핑을 신경 쓸 필요가 없습니다. -
if (src.a == 255) { fb.setPixel(x, y, src); return; }— 빠른 경로(fast path). 소스가 완전 불투명이면 배경을 읽을 필요도, 섞을 필요도 없습니다. 그냥 소스 색을 그대로 씁니다.getPixel한 번과 곱셈 여덟 번을 통째로 아낍니다. 불투명 픽셀이 화면의 대다수인 경우가 많아 실전에서 꽤 큰 이득입니다. -
if (src.a == 0) return;— 완전 투명이면 결과는 100% 배경, 즉 아무것도 바꿀 필요가 없습니다. 바로 반환해getPixel/setPixel/블렌드를 전부 건너뜁니다. -
Color dst = fb.getPixel(x, y);— 여기까지 왔다면 소스는 진짜 반투명(1~254)입니다. 이제 그 자리의 기존 색을 읽어와blendOver(src, dst)로 섞은 뒤setPixel로 되씁니다. 읽기(dst) → 섞기 → 쓰기의 전형적인 read-modify-write 패턴입니다.
a == 255/a == 0 두 분기는 단순해 보여도, 반투명이 드물고 불투명/투명이 흔한 워크로드(예: 픽셀아트,
UI)에서 read-modify-write를 통째로 생략해 크게 이깁니다. 반대로 모든 픽셀이 반투명인 경우엔 분기 예측 실패로
오히려 미세하게 손해일 수 있으니, 워크로드를 알고 선택하세요. GPU에선 이 분기 없이 하드웨어 블렌드 유닛이 항상 처리합니다.
3. 사전 곱셈 알파 (premultiplied)
지금까지의 straight 방식은 픽셀 하나를 섞는 데는 완벽합니다. 문제는 반투명 위에 반투명을 연쇄로 여러 겹 쌓거나, 텍스처를 축소·확대하며 필터링할 때 드러납니다. 가장자리에 검거나 밝은 테두리(halo)가 스며 나오는 것이죠. 원인과 해결책을 순서대로 봅니다.
왜 straight는 필터링에서 색이 샐까 (그림글)
반투명 텍스처의 완전 투명한 픽셀 {255, 0, 0, 0}(알파 0인 빨강)을 생각해 봅시다. 알파가 0이니 화면엔 안 보입니다.
그런데 이 픽셀이 보이는 이웃 픽셀과 나란히 있고, 밉맵이나 bilinear 필터가 두 픽셀의 RGB를 알파와 무관하게 따로
평균 낸다면? "안 보여야 할 빨강"의 RGB가 이웃에 절반씩 섞여 들어가, 투명 영역 근처가 붉게 물듭니다. 사람 눈엔 안 보이던 색이
필터링 과정에서 되살아나는 것입니다 — 이게 halo(색 번짐)입니다.
해결책 — 사전 곱셈 알파: 저장하는 시점에 아예 RGB에 알파를 미리 곱해 둡니다(rgb' = rgb × a).
그러면 알파 0인 픽셀은 RGB도 {0,0,0}이 되어, 필터가 아무리 평균을 내도 기여도가 0입니다. 색이 샐
원본 자체가 사라지는 것이죠.
#pragma once
#include "framebuffer.h"
// straight -> premultiplied: 각 채널에 알파를 곱해 저장.
inline Color premultiply(Color c) {
float a = c.a / 255.0f;
return Color{ toByte(c.r / 255.0f * a),
toByte(c.g / 255.0f * a),
toByte(c.b / 255.0f * a),
c.a };
}
// 사전 곱셈된 색끼리의 "over"는 곱셈이 사라져 더 단순하고,
// 연쇄 합성에서도 결합법칙이 성립한다:
// out.rgb = src.rgb + dst.rgb * (1 - src.a)
// out.a = src.a + dst.a * (1 - src.a)
inline Color blendOverPremul(Color src, Color dst) {
float ia = 1.0f - src.a / 255.0f;
Color out;
out.r = toByte((src.r + dst.r * ia) / 255.0f);
out.g = toByte((src.g + dst.g * ia) / 255.0f);
out.b = toByte((src.b + dst.b * ia) / 255.0f);
out.a = toByte((src.a + dst.a * ia) / 255.0f);
return out;
}
premultiply 해부 — RGB에 알파를 미리 곱하기
float a = c.a / 255.0f; — 알파를 0~1로 정규화합니다(blendOver와 같은 이유, 같은 .0f 함정).
toByte(c.r / 255.0f * a) — R 채널을 봅니다. c.r / 255.0f로 색을 0~1로 만든 뒤, 알파 a를
곱해 미리 어둡게 만듭니다. 그리고 toByte로 0~255 바이트로 되돌립니다. 예를 들어 c = {255,0,0,128}
(반투명 빨강)이면 a ≈ 0.502, 저장되는 R은 toByte(1.0 × 0.502) = 128. RGB가 {128, 0, 0}으로
이미 반쯤 어두워진 채 저장됩니다.
c.a — 네 번째 필드인 알파는 곱하지 않고 그대로 둡니다. premultiplied는 "RGB에만 알파를 곱하고
알파 자체는 원래 값을 유지"하는 규칙입니다. 알파는 여전히 "이 픽셀이 얼마나 불투명한가"를 나타내야 하니까요.
Color{ ..., ..., ..., c.a }는 C++의 집합 초기화(aggregate initialization)입니다. 중괄호 안에
구조체 멤버 순서대로(r, g, b, a) 값을 나열해 Color 하나를 그 자리에서 만들어 반환합니다. 생성자를 따로
쓸 필요 없이 POD 구조체를 간결하게 채우는 방법입니다.
blendOverPremul — 곱셈이 사라진 더 단순한 over
핵심 통찰: straight 공식은 out.rgb = src.rgb × sa + dst.rgb × ia였습니다. 그런데 premultiplied
데이터에서는 src.rgb가 이미 src.a를 품고 있습니다(저장 시 곱해뒀으니까). 즉 src.rgb × sa의
× sa가 이미 데이터 안에 녹아 있어, 곱할 필요가 없습니다. 공식이 이렇게 단순해집니다:
out.rgb = src.rgb + dst.rgb × (1 − src.a) out.a = src.a + dst.a × (1 − src.a)
이제 RGB와 알파가 완전히 똑같은 형태가 됩니다 — 넷 다 "src + dst × ia"입니다. 코드에서도 R/G/B/A 네 줄이
문자만 다를 뿐 동일한 것을 확인하세요. straight 버전에서는 알파 줄만 형태가 달랐던 것(sa + da*ia, 정규화 유무 차이)과
대조됩니다.
줄 매핑: float ia = 1.0f - src.a / 255.0f;로 배경 비율만 구하면, 각 채널은
(src.r + dst.r * ia) / 255.0f입니다. src.r은 이미 곱해진 0~255 정수, dst.r * ia는
"배경이 ia만큼 비침", 둘을 더해 0~255 스케일이 되고, 마지막에 /255.0f로 0~1로 낮춰
toByte에 넘깁니다.
왜 "연쇄 합성에서 결합법칙"이 중요한가
세 겹 A over B over C를 합성한다고 합시다. straight 방식은 (A over B) over C와 A over (B over C)가
미묘하게 다른 결과를 낼 수 있습니다 — 중간에 색을 다시 알파로 나눴다 곱하는 과정에서 오차와 색 번짐이 끼기 때문입니다.
premultiplied 방식은 위 공식이 순수한 덧셈과 한 번의 곱셈뿐이라, 어느 순서로 묶어도 결과가 같습니다
(수학적으로 결합법칙 성립). 그래서 그룹 단위로 미리 합성해 두었다가 나중에 배경에 얹는 식의 최적화가 안전합니다.
영화 VFX나 UI 컴포지팅이 거의 항상 premultiplied를 쓰는 이유입니다.
blendOver에 넣으면 × sa가 한 번 더 곱해져 반투명 부분이 새까매집니다. 파이프라인 전체에서
한 규칙을 고정하세요. Direct3D 11의 D3D11_BLEND_DESC에서 premultiplied over는
SrcBlend = D3D11_BLEND_ONE, straight는 SrcBlend = D3D11_BLEND_SRC_ALPHA로 두고,
둘 다 DestBlend = D3D11_BLEND_INV_SRC_ALPHA, BlendOp = D3D11_BLEND_OP_ADD를 씁니다
(이 상태를 CreateBlendState로 만들고 OMSetBlendState로 설정).
4. GPU로 — Direct3D 11 블렌드 상태 대응
우리가 손으로 짠 blendOver/blendOverPremul은, GPU에서는 출력 병합 단계(Output Merger, OM)의
고정 기능 하드웨어가 픽셀마다 자동으로 해줍니다. 우리가 할 일은 "어떤 계수로 섞을지"를 상태로 기술하는 것뿐입니다. GPU의
블렌드 공식은 정확히 우리 공식과 같은 꼴입니다:
result = src.rgb × SrcBlend (BlendOp) dst.rgb × DestBlend
여기서 SrcBlend/DestBlend가 소스/대상에 곱할 계수, BlendOp가 둘을 합치는 연산(보통 덧셈)입니다.
우리 두 함수를 이 계수로 번역하면 이렇게 1:1로 대응됩니다.
| 우리 C++ 함수 | SrcBlend | DestBlend | BlendOp | 대응하는 식 |
|---|---|---|---|---|
blendOver (straight) |
D3D11_BLEND_SRC_ALPHA |
D3D11_BLEND_INV_SRC_ALPHA |
D3D11_BLEND_OP_ADD |
src×a + dst×(1−a) |
blendOverPremul (premultiplied) |
D3D11_BLEND_ONE |
D3D11_BLEND_INV_SRC_ALPHA |
D3D11_BLEND_OP_ADD |
src×1 + dst×(1−a) |
핵심 차이는 SrcBlend 한 곳뿐입니다. straight는 소스에 SRC_ALPHA(× a)를 곱해야 하지만,
premultiplied는 소스가 이미 알파를 품었으니 ONE(× 1, 즉 곱하지 않음)입니다. 우리 코드에서
blendOverPremul에 src.r * sa가 사라진 것과 정확히 같은 이야기입니다.
#include <d3d11.h>
#include <wrl/client.h>
using Microsoft::WRL::ComPtr;
// premultiplied "over" 블렌드 상태를 만들어 파이프라인에 건다.
// device: 이미 생성된 ID3D11Device, ctx: ID3D11DeviceContext
ComPtr<ID3D11BlendState> MakePremulOverState(ID3D11Device* device) {
D3D11_BLEND_DESC desc = {}; // 전체 0으로 초기화
desc.AlphaToCoverageEnable = FALSE; // MSAA 알파-투-커버리지 미사용
desc.IndependentBlendEnable = FALSE; // 모든 렌더타깃에 RT[0] 설정 공유
D3D11_RENDER_TARGET_BLEND_DESC& rt = desc.RenderTarget[0];
rt.BlendEnable = TRUE; // 블렌딩 켜기(끄면 그냥 덮어쓰기)
// 컬러(RGB) 블렌드: out.rgb = src.rgb*ONE + dst.rgb*(1-src.a)
rt.SrcBlend = D3D11_BLEND_ONE;
rt.DestBlend = D3D11_BLEND_INV_SRC_ALPHA;
rt.BlendOp = D3D11_BLEND_OP_ADD;
// 알파 블렌드: out.a = src.a*ONE + dst.a*(1-src.a) (RGB와 동일 형태)
rt.SrcBlendAlpha = D3D11_BLEND_ONE;
rt.DestBlendAlpha = D3D11_BLEND_INV_SRC_ALPHA;
rt.BlendOpAlpha = D3D11_BLEND_OP_ADD;
rt.RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL; // RGBA 전부 기록
ComPtr<ID3D11BlendState> state;
device->CreateBlendState(&desc, state.GetAddressOf());
return state;
}
// 매 프레임 그리기 전에 이 상태를 출력 병합 단계에 설정한다.
void BindBlend(ID3D11DeviceContext* ctx, ID3D11BlendState* state) {
const float blendFactor[4] = { 0, 0, 0, 0 }; // BLEND_FACTOR 안 쓰므로 0
ctx->OMSetBlendState(state, blendFactor, 0xFFFFFFFF); // 샘플 마스크 전체 1
}
이 코드가 우리 함수와 어떻게 맞물리나
-
D3D11_BLEND_DESC desc = {};— 빈 중괄호{}는 구조체 전체를 0/FALSE로 초기화합니다. 채우지 않은 필드가 쓰레기 값으로 남는 것을 막는, C++의 흔한 안전 관용구입니다. -
RenderTarget[0]— D3D11은 렌더 타깃을 최대 8개까지 동시에 그릴 수 있고, 각각 블렌드 설정을 가질 수 있습니다.IndependentBlendEnable = FALSE로 두면 전부[0]설정을 따르므로, 우리는 하나만 채웁니다. -
SrcBlend = D3D11_BLEND_ONE,DestBlend = D3D11_BLEND_INV_SRC_ALPHA— 이 두 줄이out.rgb = src.rgb + dst.rgb × (1 − src.a), 즉 우리blendOverPremul의 RGB 줄과 글자 그대로 같은 계산입니다.ONE= "×1"(곱하지 않음),INV_SRC_ALPHA= "×(1 − src.a)". -
SrcBlendAlpha/DestBlendAlpha— RGB와 알파를 따로 설정하는 필드입니다. premultiplied에서는 알파도 RGB와 똑같은 형태(src.a + dst.a × ia)이므로 같은 계수를 넣습니다. 우리 코드에서 A 줄이 RGB 줄과 동일했던 것과 일치합니다. -
OMSetBlendState(state, blendFactor, 0xFFFFFFFF)— 만든 상태를 파이프라인에 겁니다.blendFactor는D3D11_BLEND_BLEND_FACTOR를 쓸 때만 의미가 있어 여기선 0. 마지막0xFFFFFFFF는 샘플 마스크로, "모든 MSAA 샘플을 사용"이라는 뜻입니다.
ID3D11BlendState)는 한 번 만들어 재사용하세요. 매 프레임 CreateBlendState를
호출하면 드라이버 검증 비용이 크게 듭니다. 보통 초기화 때 straight/premul/불투명(블렌드 끔) 몇 개를 미리 만들어 두고,
그리기 종류에 따라 OMSetBlendState로 바꿔 끼우기만 합니다. 상태 전환도 공짜는 아니니, 같은 블렌드끼리 묶어(batch)
그리면 전환 횟수가 줄어 유리합니다.
HLSL에서의 위치 — 셰이더는 블렌드를 안 한다
한 가지 흔한 오해를 짚고 넘어갑니다. 픽셀 셰이더는 블렌딩을 하지 않습니다. 셰이더는 그저 그 픽셀의
소스 색(알파 포함)을 계산해 내보낼 뿐이고, dst와 섞는 일은 그 다음 단계인 출력 병합기가 위 블렌드 상태에 따라
자동으로 합니다. 그래서 premultiplied 파이프라인이라면, 셰이더가 내보내는 색이 이미 premultiplied 형태여야
SrcBlend = ONE과 아귀가 맞습니다.
Texture2D gTex : register(t0);
SamplerState gSmp : register(s0);
struct PSIn {
float4 pos : SV_Position; // 클립 공간 위치(래스터라이저가 채움)
float2 uv : TEXCOORD0; // 텍스처 좌표(V는 위→아래)
float4 col : COLOR0; // 정점 색 · 틴트(straight 알파)
};
float4 main(PSIn i) : SV_Target {
float4 tex = gTex.Sample(gSmp, i.uv); // straight 알파 텍스처라 가정
float4 c = tex * i.col; // 틴트 적용(채널별 곱)
// straight -> premultiplied: RGB에만 알파를 곱해 내보낸다.
// 이래야 OM의 SrcBlend = ONE 과 아귀가 맞는다.
c.rgb *= c.a;
return c; // 이 값이 dst와 (ONE, INV_SRC_ALPHA)로 블렌드된다
}
줄 단위 해부
-
Texture2D gTex : register(t0);,SamplerState gSmp : register(s0);— 텍스처와 샘플러를 각각t(SRV) /s(샘플러) 슬롯에 바인딩합니다. C++ 쪽에서PSSetShaderResources(0, ...),PSSetSamplers(0, ...)로 꽂은 것과 슬롯 번호가 맞아야 합니다. -
float4 tex = gTex.Sample(gSmp, i.uv);— UV로 텍스처를 샘플링합니다. 이 한 줄이 앞 절의 소프트웨어sampleBilinear/sampleNearest에 대응합니다 — 어떤 필터를 쓸지는gSmp(샘플러 상태)가 정합니다. -
float4 c = tex * i.col;— HLSL의*는 벡터의 성분별 곱입니다. 텍스처 색에 정점 색(틴트)을 채널마다 곱해 색조를 입힙니다. 알파끼리도 곱해집니다. -
c.rgb *= c.a;— 이 페이지의 핵심 한 줄..rgb스위즐로 R/G/B 세 채널만 골라 알파를 곱합니다(.a는 건드리지 않음). 우리 C++premultiply가 하던 일을 GPU에서 픽셀마다 하는 것입니다. 이 한 줄을 빼먹고SrcBlend = ONE을 쓰면 반투명 부분이 과하게 밝아지고, 반대로 이 줄을 넣고SRC_ALPHA를 쓰면 두 번 곱해져 새까매집니다. -
return c : SV_Target;— 이 색을 렌더 타깃으로 내보냅니다. 여기서 함수는 끝나고, 실제 dst와의 섞기는 OM 단계가 우리가 건 블렌드 상태로 처리합니다.
c.rgb *= c.a를 했다면 반드시 SrcBlend = ONE(premultiplied), 안 했다면 SrcBlend = SRC_ALPHA(straight).
셰이더의 곱셈과 OM의 계수는 언제나 한 세트로 맞춰야 합니다.
정리
- over 합성은
result = src×a + dst×(1−a)— 가중치 합이 항상 1인 선형 보간입니다. - 색은 0~255 바이트로 저장하되 계산은 0~1 실수로 하고,
/ 255.0f의.0f를 절대 빠뜨리지 마세요. blendPixel의 경계 검사와 불투명/투명 빠른 경로는 안전성과 성능을 동시에 챙깁니다.- 연쇄 합성·필터링에서는 premultiplied가 halo를 없애고 결합법칙을 보장해 실전 표준입니다.
- 이 모든 것이 D3D11에서는
SrcBlend/DestBlend/BlendOp상태와 셰이더의c.rgb *= c.a한 줄로 GPU 하드웨어에 그대로 대응됩니다.