ImDrawList로 스칼라→색 컬러맵 위젯 만들기

과학·엔지니어링 뷰어에서는 응력, 온도, 거리장처럼 ​숫자로만 이루어진 스칼라 필드 를 화면에 보여 줘야 할 때가 많습니다. 그때 흔히 쓰는 방법이 값을 색으로 바꾸는 것인데, 단순 색 피커(ColorEdit4)만으로는 “이 파랑이 0.2인지 200인지”를 사용자에게 전달하기 어렵습니다.

이 글에서는 미리 정의한 컬러맵(stop 테이블)을 ImDrawList로 그리고, 데이터 범위(min/max)와 현재 값을 함께 조작하는 ​커스텀 위젯 패턴을 정리합니다. 특정 유체·입자 파이프라인에 묶지 않고, “스칼라 → RGBA” 변환과 UI 표현에만 집중합니다. 실제 필드 샘플링은 렌더러나 컴퓨트 쪽에 두는 편이 역할 분리가 깔끔합니다.

이 글을 읽고 나면

예제 기준으로 다음을 구현할 수 있습니다.

  • ColorMap stop 테이블 — t ∈ [0,1]에 대응하는 색 목록

  • SampleColorMap(t) — 인접 stop 사이를 선형 보간해 색을 얻는 함수

  • ColorMapEdit 위젯 — 그라데이션 바 + min/max 드래그 + 현재 값 마커

  • Properties 패널에 붙이는 프리셋 콤보와 호버 값 연동

읽고 나면 Properties 옆에 붙일 수 있는 작은 시각화 컨트롤을 갖게 됩니다. 뷰포트에서 피킹한 스칼라를 마커로 올려 주면, “지금 보고 있는 점이 범위의 어디쯤인지”도 한눈에 확인할 수 있습니다.

실행 예제(데스크톱 + 웹): (Dock 없이 Properties / Field Preview만 구성합니다. 범례와 미리보기가 같은 SampleColorMap을 씁니다.)

왜 기본 ColorEdit만으로는 부족한가

ImGui::ColorEdit4​단일 색 을 고르는 데 최적화되어 있습니다. 반면 컬러맵은 다음 세 가지가 한 세트로 있어야 의미가 있습니다.

구성 요소

역할

Stop 테이블

t ∈ [0,1] → RGB(A). 팔레트 자체의 정의

데이터 범위

value_min, value_max. 실제 물리량 스케일

미리보기·상호작용

그라데이션 바, 범위 드래그, 프리셋 선택, 값 마커

사용자가 보는 것은 색만이 아닙니다. “이 구간이 0~1인지, 0~200인지”가 같이 보여야 범례로 기능합니다. 그래서 색 배열과 스케일을 ​한 위젯으로 묶는 편이 UX가 자연스럽습니다.

컬러맵 데이터 — stop과 보간

컬러맵의 핵심은 ​몇 개의 기준점(stop) 과, 그 사이를 채우는 ​보간 입니다. stop이 세 개만 있어도 Viridis 비슷한 팔레트를 흉내 낼 수 있고, 실무에서는 더 많은 stop을 넣어 부드럽게 만들기도 합니다.

Cpp

struct ColorStop { float t; // 0~1 정규화 위치 ImVec4 rgba; }; struct ColorMap { const char* name; std::vector<ColorStop> stops; }; ImVec4 SampleColorMap(const ColorMap& map, float t) { if (map.stops.empty()) { return ImVec4(1, 1, 1, 1); } // 범위를 벗어나면 양 끝 색으로 clamp t = t < 0.0f ? 0.0f : (t > 1.0f ? 1.0f : t); if (t <= map.stops.front().t) { return map.stops.front().rgba; } for (size_t i = 1; i < map.stops.size(); ++i) { if (t <= map.stops[i].t) { const auto& a = map.stops[i - 1]; const auto& b = map.stops[i]; const float u = (t - a.t) / (b.t - a.t); return ImVec4( a.rgba.x + (b.rgba.x - a.rgba.x) * u, a.rgba.y + (b.rgba.y - a.rgba.y) * u, a.rgba.z + (b.rgba.z - a.rgba.z) * u, a.rgba.w + (b.rgba.w - a.rgba.w) * u); } } return map.stops.back().rgba; } ColorMap MakeViridisLike() { return {"Viridis-like", { {0.0f, ImVec4(0.27f, 0.00f, 0.33f, 1)}, {0.5f, ImVec4(0.13f, 0.57f, 0.55f, 1)}, {1.0f, ImVec4(0.99f, 0.91f, 0.14f, 1)}, }}; }

SampleColorMap이 하는 일은 단순합니다. 입력 t를 0~1로 변환하고, 어느 두 범위 사이에 있는지 찾은 뒤, 그 사이를 비율 u로 섞습니다. 실제 데이터 값 v를 색으로 바꿀 때는 먼저 범위로 정규화합니다.

Text

t = (v - value_min) / (value_max - value_min) 색 = SampleColorMap(map, t)

ImDrawList로 바 그리기 — 커스텀 위젯의 기본 패턴

ImGui 커스텀 위젯의 기본 흐름은 이렇게 나뉩니다.

  1. ​레이아웃: “이만큼 공간이 필요하다”고 커서/아이템에 알린다.

  2. ​페인트: 확보한 화면 좌표 위에 ImDrawList로 직접 그린다.

  3. ​상호작용: DragFloat 같은 기존 위젯으로 값을 바꾸거나, 필요하면 InvisibleButton으로 히트 테스트를 직접 한다.

아래 ColorMapEdit는 그라데이션 바를 그린 뒤, min/max는 평범한 DragFloat로 편집합니다. 바 자체는 장식이자 범례이고, 숫자 입력만으로도 같은 범위를 바꿀 수 있게 두는 것이 접근성에도 좋습니다.

Cpp

bool ColorMapEdit(const char* label, ColorMap& map, float* value_min, float* value_max, float marker_value /* optional NaN */) { ImGui::TextUnformatted(label); const ImVec2 bar_size(ImGui::GetContentRegionAvail().x, 18.0f); const ImVec2 p0 = ImGui::GetCursorScreenPos(); ImDrawList* dl = ImGui::GetWindowDrawList(); // 0~1을 여러 세로 조각으로 나눠 색을 채운다 const int segments = 64; for (int i = 0; i < segments; ++i) { const float t0 = float(i) / segments; const float t1 = float(i + 1) / segments; const ImVec4 c = SampleColorMap(map, 0.5f * (t0 + t1)); const ImU32 col = ImGui::ColorConvertFloat4ToU32(c); dl->AddRectFilled( ImVec2(p0.x + bar_size.x * t0, p0.y), ImVec2(p0.x + bar_size.x * t1, p0.y + bar_size.y), col); } dl->AddRect(p0, ImVec2(p0.x + bar_size.x, p0.y + bar_size.y), IM_COL32(0, 0, 0, 180)); // 현재 값 마커 (NaN이면 그리지 않음) if (marker_value == marker_value && *value_max > *value_min) { const float tn = (marker_value - *value_min) / (*value_max - *value_min); const float x = p0.x + bar_size.x * tn; dl->AddLine(ImVec2(x, p0.y - 2), ImVec2(x, p0.y + bar_size.y + 2), IM_COL32(255, 255, 255, 220), 2.0f); } // 레이아웃만 전진 — 실제 픽셀은 위에서 DrawList로 이미 제출함 ImGui::Dummy(bar_size); bool changed = false; changed |= ImGui::DragFloat("Min", value_min, 0.1f); changed |= ImGui::DragFloat("Max", value_max, 0.1f); if (*value_min > *value_max) { std::swap(*value_min, *value_max); } return changed; }

포인트는 Dummy입니다. ImGui는 Dummy로 “이 크기만큼 커서를 내린다”만 알고, 그 안의 그림은 GetWindowDrawList()에 우리가 직접 넣습니다. 즉 ​레이아웃과 페인트를 분리 하는 것이 커스텀 위젯의 기본 패턴입니다.

바를 64조각으로 나눈 이유는 단순합니다. stop이 몇 개뿐이어도, 조각마다 중앙 t를 샘플하면 눈으로 보기엔 부드러운 그라데이션이 됩니다. 매 프레임 64개의 작은 쿼드는 부담이 거의 없고, stop이 수백 개로 늘어나면 그때는 1D 텍스처로 베이크하는 편이 낫습니다.

Properties에 붙이기

위젯만 있으면 반쪽입니다. Properties 패널에서 프리셋을 고르고, 뷰포트 호버 값을 마커로 넘기는 쪽까지 묶으면 실제 툴에 가깝습니다.

Cpp

struct FieldDisplaySettings { ColorMap map = MakeViridisLike(); float min_v = 0.0f; float max_v = 1.0f; int preset = 0; }; void DrawFieldPanel(FieldDisplaySettings& s, float hover_value) { if (!ImGui::Begin("Properties")) { ImGui::End(); return; } const char* presets[] = {"Viridis-like", "Grayscale", "CoolWarm"}; if (ImGui::Combo("Colormap", &s.preset, presets, 3)) { // preset 인덱스에 맞춰 stops 교체 // (사용자 커스텀 편집과 프리셋 교체를 구분하면 되돌리기도 쉽습니다) } ColorMapEdit("Scalar legend", s.map, &s.min_v, &s.max_v, hover_value); ImGui::End(); }

뷰포트에서 피킹(또는 픽셀 읽기)으로 얻은 스칼라가 있으면 hover_value로 마커를 움직입니다. 값이 없을 때는 NaN을 넘겨 마커를 숨기면 됩니다. 프리셋 변경은 stops 전체를 갈아끼우는 방식으로 두고, 사용자가 직접 편집한 stop과 구분하면 “프리셋으로 되돌리기”도 구현하기 쉽습니다.

설계 체크리스트

실무에 붙일 때 자주 빠뜨리는 항목을 모아 두었습니다.

  1. stop 테이블과 샘플 함수를 UI/GPU가 공유 할 수 있게 둡니다. 범례만 고치면 화면 색이 안 바뀌는 사고가 가장니다.

  2. DrawList + Dummy 로 레이아웃과 페인트를 분리합니다. 커서 위치를 읽은 직후 그리고, 바로 공간을 확보하세요.

  3. ​데이터 범위(min/max)를 색과 함께 편집합니다. 색만 있는 범례는 해석이 불가능합니다.

  4. min==max, NaN 마커 를 방어합니다. 정규화·마커 그리기 전에 검사하세요.

  5. ​프리셋 변경은 stops 교체 로 처리하고, 사용자 커스텀과 구분합니다.

  6. ​숫자 입력만으로도 동일 범위를 바꿀 수 있게 둡니다. 바는 시각 보조이고, DragFloat가 실제 편집 수단입니다.

자주 발생하는 문제

바가 다른 위젯 위에 그려짐

GetCursorScreenPos를 읽은 시점과 Dummy로 공간을 확보하는 순서가 어긋난 경우가 많습니다. 그리기 직전에 커서 위치를 읽고, 그린 직후 Dummy로 레이아웃을 진행하세요. 반대로 Dummy를 먼저 호출하면 커서가 이미 내려가서, 바는 엉뚱한 자리에 그려집니다.

창을 스크롤하면 바가 남음

창 DrawList에 직접 그린 도형은, 스크롤·클리핑과 어긋나면 “남는” 것처럼 보일 수 있습니다. 필요할 때 PushClipRect로 아이템 영역을 자르거나, AddRectFilled 대신 미리 만든 텍스처 바를 Image로 제출하는 방법도 있습니다.

뷰포트 색과 범례가 다름

가장 흔한 원인은 두 가지입니다. 하나는 ​선형 보간 vs sRGB 처럼 색 공간이 다른 경우이고, 다른 하나는 stop 개수·값이 UI와 셰이더에서 어긋난 경우입니다. 샘플 함수(또는 베이크된 1D 텍스처)를 하나로 맞추는 것이 가장입니다.

성능이 걱정될 때

매 프레임 세로 조각 64개는 부담이 거의 없습니다. stop이 수백 개이거나, 같은 맵을 여러 창에서 반복해 그리면 그때는 1D 텍스처로 한 번 베이크해 두고 AddImage로 그리는 편이 낫습니다. UI와 GPU가 같은 텍스처를 쓰면 색 불일치도 줄어듭니다.

Docutils System Messages

On this page