← 개념 · 데모 가이드 | 구현 허브 | 2장. 2D 그래픽스 | 알파 블렌딩 — over 합성
IMPLEMENTATION · CHAPTER 2

알파 블렌딩 — over 합성 — 한 줄 한 줄 직접 구현

반투명한 유리창 너머로 뒤 풍경이 비치듯이, 화면 위 반투명 픽셀은 자기 색뒤에 이미 그려진 색을 섞어야 합니다. 이 섞는 규칙의 표준이 Porter-Duff의 "over" 연산자입니다. 이 페이지에서는 그 한 줄짜리 공식 result = src·a + dst·(1−a)가 채널마다 정확히 무엇을 계산하는지, 왜 0~255 정수와 0~1 실수를 왔다 갔다 하는지, 그리고 "사전 곱셈 알파(premultiplied)"가 왜 연쇄 합성에서 수학적으로 옳은 방식인지를 곱셈 하나, 부호 하나까지 뜯어봅니다. 마지막에는 이 모든 것이 Direct3D 11의 D3D11_BLEND_DESC 몇 줄로 어떻게 GPU에 대응되는지 1:1로 짚습니다.

규약 이 문서는 D3D11 규약으로 통일합니다: 색은 Color{ r, g, b, a } 구조체에 0~255 부호 없는 바이트로 저장하고, 알파도 0(완전 투명)~255(완전 불투명)입니다. 블렌드 계산은 0~1 실수로 정규화한 뒤 수행하고, 결과를 다시 바이트로 되돌립니다. 텍스처 V축은 위→아래(위쪽이 0), 클립 공간 z는 [0,1]입니다.
전제 아래 코드는 모두 2장의 framebuffer.h에서 만든 Color 구조체, Framebuffer 클래스(getPixel/setPixel/inBounds/width/height), 그리고 실수를 0~255 바이트로 안전하게 자르는 헬퍼 toByte(float)#include해서 씁니다. 이 페이지에서는 그 재료를 조합만 합니다.

먼저: "합성(compositing)"이 뭘 섞는 건가

2D든 3D든, 화면의 한 픽셀에 색을 칠할 때 그 자리에 이미 다른 색이 있을 수 있습니다. 예를 들어 파란 배경을 먼저 칠하고, 그 위에 반쯤 투명한 빨간 원을 그리면, 원의 안쪽은 "빨강 반 + 파랑 반" 같은 보라색이 되어야 자연스럽습니다. 이때 새로 칠하려는 색을 소스(source, src), 이미 그 자리에 있던 색을 대상(destination, dst)이라 부릅니다. 합성이란 이 srcdst를 알파(투명도) 값에 따라 어떻게 섞을지를 정하는 규칙입니다.

Porter & Duff는 1984년 논문에서 이런 합성 규칙을 12가지로 정리했는데, 그중 압도적으로 많이 쓰이는 것이 "over"입니다. 말 그대로 "src를 dst 위에 얹는다"는 뜻입니다. 직관은 이렇습니다:

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로 이동하는 것과 정확히 같습니다.

  1. 채널(R, G, B)별로 "over" 공식을 직접 구현한다.
  2. 프레임버퍼의 기존 픽셀(dst) 위에 소스(src)를 블렌드해서 덮어쓰는 함수를 만든다.
  3. 사전 곱셈 알파(premultiplied)가 왜 연쇄 합성에서 옳은지 이해하고, 더 단순한 식으로 다시 구현한다.

1. straight 알파에서의 "over" — blendOver

무엇을 만드나: 소스 색 하나와 대상 색 하나를 받아, over 공식으로 섞은 결과 색 하나를 돌려주는 순수 함수입니다. 왜 필요한가: 반투명 스프라이트, 안티에일리어싱된 폰트 가장자리, UI 오버레이 — 화면에 겹쳐 그리는 거의 모든 것이 픽셀 단위로 이 함수를 호출합니다. GPU가 하드웨어로 해주는 그 일을, 원리를 이해하기 위해 손으로 짜보는 것입니다.

여기서 "straight(비사전곱)" 알파란, 우리가 흔히 생각하는 평범한 방식입니다. RGB는 그냥 색 그 자체이고, 알파는 그와 따로 저장된 투명도입니다. 뒤에 나올 premultiplied와 대비되는 이름이라고만 기억하세요.

blend.hcpp
#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)

한 부분씩 뜯어봅니다.

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 / 2550.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.rdst.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은 대략 절반입니다.

결과는 {128, 0, 127, ...} — 빨강 절반 + 파랑 절반, 정확히 예상한 보라색입니다. 손으로 짚어보면 공식이 "섞는다"는 게 그저 두 색을 알파 비율로 평균 낸다는 사실이 눈에 들어옵니다.

4단계 — 결과 알파도 합성: out.a = toByte(sa + da * ia);

RGB만 섞으면 끝이 아닙니다. 이 결과 픽셀 자체가 또 다른 배경 위에 올라갈 수도 있으니, 결과의 투명도도 계산해야 합니다. 공식은 a_out = sa + da × (1 − sa)입니다.

기하 직관: "이 자리가 얼마나 불투명해졌나"를 묻는 것입니다. 소스가 sa만큼 채우고, 소스가 못 채운 틈(1 − sa = ia)을 대상의 불투명도 dada × 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)에 적용해서 그 자리를 덮어쓰는 함수입니다. 스프라이트나 도형을 그릴 때 픽셀마다 이 함수를 호출합니다.

blend.h (이어서)cpp
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));
}

줄 단위 해부

성능 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입니다. 색이 샐 원본 자체가 사라지는 것이죠.

premultiplied.hcpp
#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 CA over (B over C)가 미묘하게 다른 결과를 낼 수 있습니다 — 중간에 색을 다시 알파로 나눴다 곱하는 과정에서 오차와 색 번짐이 끼기 때문입니다. premultiplied 방식은 위 공식이 순수한 덧셈과 한 번의 곱셈뿐이라, 어느 순서로 묶어도 결과가 같습니다 (수학적으로 결합법칙 성립). 그래서 그룹 단위로 미리 합성해 두었다가 나중에 배경에 얹는 식의 최적화가 안전합니다. 영화 VFX나 UI 컴포지팅이 거의 항상 premultiplied를 쓰는 이유입니다.

함정 straight와 premultiplied를 섞어 쓰면 색이 두 번 어두워지거나(중복 곱셈) 밝아집니다. 예를 들어 이미 premultiply된 텍스처를 straight용 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로 설정).
성능 사전 곱셈은 블렌드식에서 곱셈 하나를 없애줄 뿐 아니라, 텍스처 필터링/밉맵 생성 시 경계 halo를 원천 차단합니다. 반투명 텍스처를 다룬다면 로딩 시 미리 premultiply해 두는 편이 실전에서 거의 항상 이득입니다.

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, 즉 곱하지 않음)입니다. 우리 코드에서 blendOverPremulsrc.r * sa가 사라진 것과 정확히 같은 이야기입니다.

blendstate.cpp — premultiplied over 상태 만들기cpp
#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
}

이 코드가 우리 함수와 어떻게 맞물리나

성능 블렌드 상태 객체(ID3D11BlendState)는 한 번 만들어 재사용하세요. 매 프레임 CreateBlendState를 호출하면 드라이버 검증 비용이 크게 듭니다. 보통 초기화 때 straight/premul/불투명(블렌드 끔) 몇 개를 미리 만들어 두고, 그리기 종류에 따라 OMSetBlendState로 바꿔 끼우기만 합니다. 상태 전환도 공짜는 아니니, 같은 블렌드끼리 묶어(batch) 그리면 전환 횟수가 줄어 유리합니다.

HLSL에서의 위치 — 셰이더는 블렌드를 안 한다

한 가지 흔한 오해를 짚고 넘어갑니다. 픽셀 셰이더는 블렌딩을 하지 않습니다. 셰이더는 그저 그 픽셀의 소스 색(알파 포함)을 계산해 내보낼 뿐이고, dst와 섞는 일은 그 다음 단계인 출력 병합기가 위 블렌드 상태에 따라 자동으로 합니다. 그래서 premultiplied 파이프라인이라면, 셰이더가 내보내는 색이 이미 premultiplied 형태여야 SrcBlend = ONE과 아귀가 맞습니다.

sprite_ps.hlsl — premultiplied 출력hlsl
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)로 블렌드된다
}

줄 단위 해부

함정 "셰이더에서 premultiply했는데 왜 이상하지?"의 대부분은 블렌드 상태와 짝이 안 맞아서입니다. 셰이더에서 c.rgb *= c.a를 했다면 반드시 SrcBlend = ONE(premultiplied), 안 했다면 SrcBlend = SRC_ALPHA(straight). 셰이더의 곱셈과 OM의 계수는 언제나 한 세트로 맞춰야 합니다.

정리