Color-ID 피킹: RGB에 객체 번호를 심고 glReadPixels로 읽기

3D 뷰어나 에디터에서 메시·포인트 클라우드를 클릭했을 때, 가장 먼저 물어야 하는 질문은 단순합니다. “지금 그 픽셀에 그려진 것은 무엇인가?” CPU에서 모든 삼각형·점을 일일이 검사하기 전에, GPU가 이미 그린 결과를 그대로 활용하는 방법이 있습니다. 각 객체에 고유한 정수 ID를 주고, 그 ID를 색(RGB)으로 바꿔 오프스크린에 그린 뒤, 마우스 위치의 픽셀만 읽어 ID로 되돌리는 기법이 바로 Color-ID 피킹 입니다.

이 글에서는 ID ↔ RGB 인코딩, 피킹 전용 패스, glReadPixels로 한 점을 읽어 복원하는 흐름까지를 처음부터 따라가 봅니다. 조명·톤매핑이 섞인 “예쁜 씬” 과 피킹용 버퍼를 분리하는 이유, 그리고 “클릭했는데 항상 0” 같은 실무 함정도 함께 정리합니다.

이 글을 읽고 나면

다음 흐름을 직접 설계·디버깅할 수 있게 됩니다.

  • 선택 가능한 객체(또는 포인트)마다 양의 정수 ID를 부여하고, 0은 배경으로 약속하기

  • Pick FBO에 flat RGB만으로 재드로우하기 (블렌딩·MSAA off)

  • 마우스 좌표를 프레임버퍼 공간으로 바꾼 뒤 1×1 glReadPixels 하기

  • RGB → 정수 ID로 복원한 뒤 하이라이트·툴팁에 연결하기

읽고 나면 “클릭했는데 엉뚱한 메시가 골라지거나 ID가 항상 0”인 상황을, 좌표·인코딩·패스 설정 순서로 좁혀 볼 수 있습니다.

왜 Color-ID인가

래스터라이저는 이미 ​깊이 테스트 로 가시성을 해결합니다. 화면에 최종적으로 남은 픽셀의 색만 읽으면, 뒤에 가려진 물체를 잘못 고르는 문제를 상당 부분 피할 수 있습니다. 특히 포인트 스프라이트처럼 CPU 기하(점)와 화면에 찍힌 모양(원형 디스크)이 다른 경우에도, “실제로 그린 픽셀”과 선택이 일치한다는 점이 큰 장점입니다.

전체 단계는 네 줄로 요약할 수 있습니다.

  1. 정수 ID를 24비트 RGB로 쪼개 인코딩한다.

  2. 피킹 셰이더로 Pick FBO에 flat 색만 그린다 (블렌딩 off).

  3. UI 마우스 Y를 OpenGL 원점(보통 왼쪽 아래)에 맞게 뒤집어 픽셀을 읽는다.

  4. 읽은 바이트를 다시 정수 ID로 합친다.

Ray–mesh picking이 월드 hit 점·법선이 필요할 때 유리하다면, Color-ID는 “화면에 보이는 것” 을 고르는 데 강하고 구현도 짧습니다. 포인트가 촘촘한 클라우드, 스프라이트 discard가 있는 파티클처럼 “픽셀 모양 = 선택 모양”이어야 하는 경우에 특히 잘 맞습니다.

ID ↔ RGB 인코딩

24비트면 약 1,670만 개의 ID를 담을 수 있습니다. 보통은 하위 8비트를 R, 다음 8비트를 G, 그다음 8비트를 B에 넣습니다. 인코드와 디코드의 shift 방향·채널 순서 만 양쪽에서 같으면 됩니다.

Cpp

struct Rgb8 { unsigned char r, g, b; }; Rgb8 encodeId(uint32_t id) { return Rgb8{ static_cast<unsigned char>(id & 0xFFu), static_cast<unsigned char>((id >> 8) & 0xFFu), static_cast<unsigned char>((id >> 16) & 0xFFu), }; } uint32_t decodeId(unsigned char r, unsigned char g, unsigned char b) { return uint32_t(r) | (uint32_t(g) << 8) | (uint32_t(b) << 16); }

셰이더에 유니폼으로 넘길 때는 0~1 범위로 나눕니다.

Cpp

const Rgb8 c = encodeId(object_id); glUniform3f(loc, c.r / 255.0f, c.g / 255.0f, c.b / 255.0f);

메시처럼 “객체 하나 = ID 하나”이면 유니폼만으로 충분합니다. 포인트 클라우드처럼 정점마다 ID가 다르면 attribute로 실어 보내고, 프래그먼트에서 flat으로 보간을 막는 편이 안전합니다. (선형 보간이 ID 색을 섞으면 복원이 깨집니다.)

가장 단순한 피킹 프래그먼트 셰이더는 색만 출력합니다.

Glsl

#version 330 core uniform vec3 u_id_color; // 0..1 out vec4 frag_color; void main() { frag_color = vec4(u_id_color, 1.0); }

씬 패스와 정점 셰이더를 공유하고, 피킹일 때만 ID를 내고 싶을 때는 다음 코드의 u_pick_mode로 분기할 수 있습니다. 포인트 스프라이트라면 ​원형 discard도 피킹에서 동일하게 적용해야 “보이는 원”과 “선택되는 원”이 맞습니다.

Glsl

uniform bool u_pick_mode; in vec3 v_color; flat in vec3 v_id_color; out vec4 frag_color; void main() { // point sprite: 원 밖은 씬과 똑같이 discard vec2 c = 2.0 * gl_PointCoord - 1.0; if (dot(c, c) > 1.0) discard; frag_color = u_pick_mode ? vec4(v_id_color, 1.0) : vec4(v_color, 1.0); }

Pick FBO에 그리는 패스

피킹 패스의 목표는 “예쁘게 보이게”가 아니라 ​픽셀마다 깨끗한 ID 색 을 남기는 것입니다. 대략 이런 순서가 됩니다.

  1. Pick FBO를 바인드하고, clear 색을 (0,0,0,1)로 지운다.

  2. 깊이 테스트는 켠다 (가려진 물체가 선택되면 안 되므로).

  3. 블렌딩·MSAA를 끈다.

  4. 선택 가능한 객체만 ID 색으로 드로우한다.

  5. (클릭/호버 시) glReadPixels로 1×1을 읽는다.

씬(Scene)과 카메라·모델 행렬은 같게 두는 것이 중요합니다. 행렬이 어긋나면 “화면에는 A가 보이는데 ID 지도에는 B가 있다”는 상황이 됩니다. 깊이 attachment도 Pick FBO에 두는 편이, 겹친 물체 중 ​앞에 보이는 것 만 남기는 데 필요합니다.

glReadPixels로 한 점 읽기

OpenGL 프레임버퍼의 원점은 보통 ​왼쪽 아래 입니다. ImGui·윈도우 마우스 Y는 ​왼쪽 위 를 원점으로 쓰는 경우가 많으므로, 읽기 전에 Y를 뒤집어야 합니다. 또한 독 패널·레터박스 때문에 “윈도우 좌표 ≠ 뷰포트/FBO 좌표”인 경우가 흔하니, pick FBO 해상도와 뷰포트 오프셋 기준으로 변환하세요.

Cpp

uint32_t pickAt(GLuint pick_fbo, int mouse_x, int mouse_y, int fb_width, int fb_height) { glBindFramebuffer(GL_READ_FRAMEBUFFER, pick_fbo); glReadBuffer(GL_COLOR_ATTACHMENT0); const int x = mouse_x; const int y = fb_height - 1 - mouse_y; // UI 상단 원점 → GL 하단 원점 unsigned char pixel[3] = {0, 0, 0}; glPixelStorei(GL_PACK_ALIGNMENT, 1); // 3바이트 행도 안전하게 glReadPixels(x, y, 1, 1, GL_RGB, GL_UNSIGNED_BYTE, pixel); glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); return decodeId(pixel[0], pixel[1], pixel[2]); }

GL_PACK_ALIGNMENT 기본값은 4입니다. 가로 1픽셀·RGB 3바이트만 읽을 때도 정렬 때문에 값이 어긋날 수 있으니, 읽기 전에 1로 맞추는 습관이 안전합니다. 포맷은 인코딩과 맞춘 GL_RGB + GL_UNSIGNED_BYTE를 쓰고, 알파를 섞어 쓰지 않도록 합니다.

Pick FBO를 언제 갱신할지는 용도에 따라 달라져요.

  • 만약 마우스를 올렸을 때 바로 하이라이트되거나 툴팁이 보여야 한다면, ​매 프레임마다 Pick FBO를 업데이트하는 방식이 더 편리 합니다.

  • 반면, 단순히 “클릭해서 선택”만 필요하다면, 클릭 순간에만 피킹 패스를 돌리는 ​지연 방식 으로도 충분해요.
    즉, 실시간 반응이 꼭 필요하지 않다면 불필요하게 매 프레임 돌릴 필요는 없습니다.

객체 단위 vs 포인트 단위

단위

ID 부여

드로우

메시 객체

객체마다 유니폼 색

객체 루프에서 u_id_color 갱신

포인트

정점 attribute / flat ID

한 번의 GL_POINTS

에디터에서 “바디 선택”과 “포인트 하나 선택”을 모두 지원하면, ID 네임스페이스를 나누거나(상위 비트에 종류 플래그), 패스를 두 번 두는 편이 안전합니다. 한 공간에 메시 ID와 포인트 ID를 섞어 쓰면, 하이라이트·삭제 로직이 서로 다른 테이블을 가리키게 되어 디버깅이 어려워집니다.

클릭 시만 그릴지, 매 프레임 유지할지

전략

장점

단점

클릭 시 pick 패스

평소 GPU 부담이 적음

첫 클릭에 지연, 호버 불가

매 프레임 pick FBO

호버·툴팁이 쉬움

드로우 비용이 매 프레임 고정

저해상도 pick

비용 절감

작은 포인트·가는 선을 놓치기 쉬움

호버가 필요 없는 메시 선택 도구라면 클릭 시 패스로 시작해도 충분합니다. 포인트가 촘촘한 클라우드에서는 pick 해상도를 씬과 같게 유지하는 편이 안전합니다. 저해상도로 줄일 때는 마우스 좌표도 같은 비율로 스케일해야 합니다.

설계 체크리스트

실무에 붙일 때 자주 빠뜨리는 항목입니다.

  1. ID 0 = 배경 clear 색 과 동일한 관례를 고정합니다.

  2. ​인코딩·디코딩 식을 한 헤더 에 모아 셰이더 유니폼과 CPU 복원이 어긋나지 않게 합니다.

  3. Pick 패스에서 ​블렌딩·MSAA·톤맵·linear 필터 를 끕니다.

  4. 마우스 좌표를 pick FBO 해상도·뷰포트 오프셋 으로 변환합니다 (HiDPI, 독 패널).

  5. GL_PACK_ALIGNMENT를 1로 맞춘 뒤 RGB 3바이트를 읽습니다.

  6. 씬 패스와 ​동일한 discard·깊이 테스트 를 유지해 “보이는 것 = 선택”을 지킵니다.

자주 발생하는 문제

항상 ID 0

배경 clear만 읽고 있을 가능성이 큽니다. 다음 순서로 좁혀 보세요.

  1. Y flip이 맞는지 (위아래가 뒤집히면 빈 영역을 읽음)

  2. 독/패널 오프셋·HiDPI 스케일이 pick FBO와 같은지

  3. Pick FBO 바인드·glReadBuffer가 맞는지

  4. 피킹 드로우가 실제로 돌았는지 (카메라·컬링·스킵 분기)

디버그로 pick 텍스처를 화면에 띄우면, “아예 안 그려졌는지 / 좌표만 틀린지”가 바로 갈립니다.

인접 객체가 번갈아 선택됨

가장 흔한 원인은 블렌딩·MSAA 잔여, 또는 pick attachment의 GL_LINEAR 필터입니다. 가장자리 픽셀이 두 ID의 평균 색이 되어, 읽을 때마다 다른 정수로 복원됩니다. GL_NEAREST와 블렌딩 off, 단일 샘플 FBO를 확인하세요.

ID가 살짝 빗나감 (예: 256 차이)

인코드/디코드의 shift 방향이 서로 다르거나, GL_RGBA로 읽고 알파·패딩을 섞어 쓴 경우가 많습니다. 채널 순서(R→G→B)와 GL_PACK_ALIGNMENT를 다시 맞추세요. 유니폼을 / 255.0으로 넣었는지, 셰이더에서 다시 스케일하지 않는지도 확인합니다.

클릭할 때마다 프레임이 끊김

전형적인 readback stall입니다. 우선은 1×1만 읽기, ​클릭 시에만 피킹 패스 로 줄입니다. 그래도 부족하면 PBO 비동기 읽기를 검토하고, 호버가 꼭 필요하지 않다면 매 프레임 갱신을 포기하는 편이 낫습니다.

씬과 선택이 어긋남

피킹 discard·포인트 크기·깊이 테스트가 씬과 다르면 “눈에 보이는 원”과 “선택되는 사각형”이 달라집니다. u_pick_mode 분기에서 기하·discard만 공유하고, 출력 색만 바꾸는 구성이 안전합니다.

On this page