DEV/MATH[ V0.0.1-ALPHA ]//articles/rgb-tricks

О цвете в играх по-простому

Всем привет! Меня зовут Гриша Дядиченко, и я технический директор и основатель White Label Games. Уже больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном заказная разработка, иногда собственные прототипы по выходным.

Открываете вы любой движок, кидаете на материал три ползунка — R, G, B — и думаете, что управляете цветом. А управляете ли? Да? Конечно же нет. По сути вы крутите три числа, в которые ваш собственный глаз сжал весь видимый спектр ещё до того, как движок успел что-то посчитать. RGB — это не то, как свет устроен в реальности.

Чтож, за неделю мы разобрали четыре места, где эта техника живёт: само зрение, обратную задачу «вернуть спектр из трёх чисел», тонкие плёнки и дисперсию в огранённом камне.

Зачем это геймдеву

Я НЕ призываю вас считать цвет спектрально в реалтайме. Для подавляющего большинства игр RGB — ровно то, что нужно: дёшево, предсказуемо и в кадре почти всегда неотличимо от спектрально-точного результата. Спектральный трейс дороже, шумнее, и в большинстве сцен зритель разницы не увидит. Так что ценность этой статьи не «делайте правильно», а «знайте область применимости».

А врёт он не абстрактно — есть вполне конкретный список мест, где трёх чисел перестаёт хватать:

  • Тонкие плёнки и иридесценция — мыльный пузырь, бензин на луже, перламутр автокраски, побежалость металла, надкрылья жуков, вся sci-fi-эстетика «нефтяного» хрома. Это не экзотика из бумаги: в Unity HDRP иридесценция (по модели Belcour-Barla 2017)
  • Дисперсия — «огонь» бриллианта, гранёное стекло, самоцветы, магические кристаллы, хром. Тут обычно фейкают аберрацией-постпроцессом.
  • Небо и атмосфера — рэлеевское рассеяние сидит в КАЖДОМ sky-шейдере. Голубое небо и красный закат — это про то, как разные длины волн рассеиваются по-разному. Вы трогаете спектр каждый раз, когда настраиваете атмосферу, просто не называете это так.
  • Флуоресценция и UV — неон под блэклайтом, «кислотная» эстетика, светящаяся краска. RGB-конвейер этого не умеет в принципе: флуоресценция переносит энергию между длинами волн, а RGB-перемножение так не работает. Фейкают через emissive.
  • Цвет под цветным и непрямым светом (GI) — как только в сцене цветной источник или сильное непрямое освещение, покомпонентный RGB-перенос (R·R, G·G, B·B) даёт цветовые сдвиги относительно спектрального переноса.

А целиком спектральные движки — это не игры. Это кино, архвиз, product-viz и конфигураторы: Manuka у Weta, Mitsuba, спектральные режимы Octane и RenderMan.

Тогда зачем вам, разработчику игр, всё это знать? Две причины.

  1. Не попасть в ловушку дешёвого фейка. Мёртвый иридесцентный материал, который не переливается; плоский самоцвет без «огня»; неон, который не вспыхивает под блэклайтом. В реалтайме вы в любом случае фейкаете — вопрос только в том, насколько качественно. А понимание того, как это устроено на самом деле, как раз и подсказывает, как фейкать правильно: что в эффекте важно сохранить, чем можно пренебречь и почему индустрия пришла именно к такому упрощению.
  2. Сам разговор про область применимости упрощений — главный навык. Уметь сказать «вот здесь упрощение точное, а вот здесь оно врёт, и вот цена точного варианта» — это то, что отличает инженера от тыкальщика ползунков.
[больше интересного в телеграм]

Часть 1. RGB как упрощение

Начнём со зрения. В сетчатке три типа колбочек — L, M и S (long / medium / short по положению пика чувствительности). Любой свет, попавший в глаз, сворачивается в три отклика. Отсюда простой факт: чтобы воспроизвести для человека цвет, обычно хватает трёх чисел. Эта трёхмерность встроена в зрение, а не в движок.

В зрение встроена именно трёхмерность (трихроматизм). Колбочки кодируют отклики L/M/S; sRGB — это уже инженерный выбор базиса трёх первичных на дисплее, связанный с LMS/XYZ линейными преобразованиями. Так что «RGB встроен в глаз» — неточно. Встроено число 3. RGB — наш выбор поверх него.

И ещё, чтобы не плодить миф: L-колбочка — не «красная». Её пик в жёлто-зелёной области, не на красном. Подписывать L как «красную колбочку» — частая ошибка, не делайте так. Конкретные длины волн пиков, если будете приводить, берите из Stockman & Sharpe (2000) / CVRL.

По сути, цвет — не свойство света и не одно число, а интеграл спектральной плотности по трём кривым чувствительности. Запомните эту мысль — она вернётся в каждой следующей части.

Отклик колбочки — одной формулойОсторожно! Математика!

Отклик каждой колбочки k — это интеграл по всему видимому спектру:

responsek=S(λ)Rk(λ)dλ,k{L,M,S}\text{response}_k = \int S(\lambda)\, R_k(\lambda)\, d\lambda, \qquad k \in \{L, M, S\}

Здесь S(λ) — спектр света, попавшего в глаз, а R_k(λ) — кривая чувствительности колбочки k.

Теперь о том, где эта техника начинает спотыкаться. Раз отображение «спектр → три отклика» сворачивает бесконечномерную функцию в три числа, оно Неинъективное отображение сопоставляет разным входам один и тот же выход — по выходу вход однозначно не восстановить. Здесь: разные спектры дают один цвет.. У одного цвета — бесконечно много спектров-прообразов. Два разных спектра, дающие одинаковые L/M/S, глаз видит как один цвет. Это метамеры.

Демо недели показывает конкретный случай — illuminant metamerism, метамерию по источнику. Два Свотч — образец цвета: небольшая плашка-выкраска конкретного материала, как в веере красок или палитре. с разными спектрами отражения совпадают под одной лампой и расходятся под другой.

Почему совпадают под одной лампой, а под другой — нетОсторожно! Математика!

Воспринимаемый цвет свотча — это тот же интеграл, только теперь под ним и лампа, и отражение:

Ck=L(λ)ρ(λ)Rk(λ)dλC_k = \int L(\lambda)\, \rho(\lambda)\, R_k(\lambda)\, d\lambda

где L(λ) — спектр лампы, ρ(λ) — спектр отражения свотча. Под лампой L₁ интегралы двух свотчей совпадают — это буквально один цвет. Меняем лампу на L₂ — интегралы разъезжаются, и цвета становятся разными.

RGB-движок перемножает цвета покомпонентно: R·R, G·G, B·B. А физика требует перемножить спектры и потом проинтегрировать. Поэтому в непрямом свете, многократных отражениях и участвующих средах RGB-перенос даёт цветовые сдвиги и нарушает сохранение энергии (Meng et al. 2015). Формулировка, которую держим весь сезон: RGB достаточно для глаза, но не для физики переноса. На практике движки гоняют спектр через сэмплинг длин волн — например, hero wavelength sampling (Wilkie et al. 2014): один «геройский» λ плюс равноотстоящие, чтобы сбить цветовой шум.

Часть 2. Обратная задача: вернуть спектр из трёх чисел

Художник красит RGB-текстурой. Спектра на входе нет — есть три числа на тексель. И пока вы остаётесь в RGB, это не проблема. Проблема начинается ровно тогда, когда нужно посчитать что-то спектральное — дисперсию, плёнку, флуоресценцию. Для них нужен спектр на входе, а его недостаёт.

Задача RGB→спектр недоопределена: один RGB при фиксированном наблюдателе и источнике порождается бесконечным множеством спектров — это те же метамеры, вид сбоку. Без регуляризации решений бесконечно много, и наивная подгонка «возьмём хоть какой-нибудь спектр» выдаёт значения вне [0,1] и пилообразные кривые. Годится? Да? Конечно же нет.

Чтобы выбрать физичный спектр, накладывают два ограничения: рефлектанс в [0, 1] (поверхность не отражает больше, чем получила — это энергосохранение) и гладкость (натуральные спектры отражения плавные). Так что нужен метод, который держит и то и другое по построению.

Историческая отправная точка — Smits (1999): базис из 7 спектров, линейная комбинация, отбор по гладкости. Просто, но не точно на всём гамуте. Дальше:

  • Meng et al. (2015) — гладкий физичный спектр по XYZ, точный матч цвета.
  • Jakob & Hanika (2019) — текущий индустриальный стандарт. Спектр параметризуется сигмоидой от квадратичного полинома: три коэффициента на тексель (столько же памяти, сколько у RGB), порядка шести операций на оценку при заданной λ. Гладкий и энергосохраняющий по построению, нулевая ошибка на всём sRGB-гамуте. Принят в PBRT и Mitsuba. Код открыт: github.com/mitsuba-renderer/rgb2spec.

«нулевая ошибка на sRGB» — это именно sRGB. Для широких гамутов (ACES, Rec.2020) нужны расширенные таблицы.

А прямо под наше демо ложится работа посвежее — Belcour, Barla & Guennebaud (2023), «One-to-Many Spectral Upsampling». Она даёт не одно решение, а семейство всех спектров, дающих заданный цвет. Художник так управляет метамерией, ватохромизмом (смена цвета с толщиной слоя) и Эффект Усамбары — у некоторых камней (гранатов) воспринимаемый цвет меняется с длиной пути света сквозь материал: тонкий слой выглядит одним цветом, толстый — другим. Назван по горам Усамбара..

Ещё одна ветка — представление через Тригонометрические моменты — описание функции набором первых коэффициентов её разложения (как первые члены ряда Фурье); компактно задаёт спектр небольшим числом чисел. (Peters et al. 2019). Корректнее — моменты ограниченного сигнала, а не буквально DFT спектра.

Как Jakob-Hanika считают спектр — псевдокод (GLSL)Осторожно! Код!

Выглядит как-то так — концептуально:

// RGB → коэффициенты (offline / precompute, метод Jakob-Hanika)
vec3 c = rgb2spec_fetch(albedoRGB);          // 3 коэффициента из таблицы
// Спектр на длине волны λ — сигмоида от квадратичного полинома:
float x   = fma(fma(c.x, lambda, c.y), lambda, c.z);
float refl = 0.5 + 0.5 * x * inversesqrt(1.0 + x * x); // ∈ [0,1] по построению

Часть 3. Тонкие плёнки

Иридесценцию — мыльный пузырь, бензин на воде, побежалость стали — считают аналитической интерференцией и отдают сразу RGB. Канон здесь — Belcour & Barla (2017), «A Practical Extension to Microfacet Theory for the Modeling of Varying Iridescence». Это есть в Unity HDRP как Iridescence в Lit/StackLit.

Физика такая: свет отражается и от верхней, и от нижней границы плёнки, а её толщина — порядка длины волны видимого света. Две отражённые волны складываются: для одних длин волн усиливают друг друга, для других гасят — отсюда и переливы. Чем толще плёнка относительно длины волны, тем быстрее цвет «пробегает» по спектру при наклоне.

Оптическая разность хода — формулаОсторожно! Математика!

Всё держится на оптической разности хода между двумя отражениями:

OPD=2ndcosθt\mathrm{OPD} = 2\, n\, d \cos\theta_t

где n — показатель преломления плёнки, d — толщина, θ_t — угол преломления. Фаза пропорциональна OPD / λ, поэтому отражательная способность R(λ) быстро осциллирует по спектру.

Фокус Belcour-Barla в том, чтобы не считать спектр в рантайме по длинам волн, а один раз, заранее, аналитически свернуть всю эту переливающуюся картину сразу в цвет. Отражение плёнки быстро «гуляет» по спектру, но то, как оно ложится на плавные кривые чувствительности глаза, удаётся посчитать наперёд — точной формулой. В кадре это остаётся почти даром: те же три числа на пиксель, что и у обычного RGB, без всякого спектрального сэмплинга.

И вот где это перестаёт работать. Свёртка в три числа точна, пока плёнка отражает все длины волны примерно одинаково. У диэлектриков — мыльный пузырь, плёнка масла — так и есть: они почти не различают цвета света, и предрасчёт совпадает со спектром. А металлы различают: медь гасит синюю часть и отражает красную (отсюда и её цвет; канонические замеры — Johnson & Christy, 1972), золото ведёт себя похоже. Для них «один цвет на всю плёнку» уже неверно — заранее свёрнутые три числа разъезжаются с настоящим спектральным расчётом, и цвет плывёт.

Точная причина: Френель, n+ik и поляризацияОсторожно! Физика!

Формально свёртка точна, пока коэффициенты Френеля плёнки слабо зависят от длины волны. У диэлектриков показатель преломления n(λ) в видимом почти постоянен. У проводников комплексный показатель n + ik сильно зависит от λ (медь, золото) — амплитуда осцилляций отражения сама модулируется по спектру, и аппроксимация несколькими компонентами расходится с эталоном.

Сюда же поляризация. Коэффициенты Френеля разные для s- и p-поляризаций, и на двух границах плёнки их фаза и амплитуда расходятся (особенно у проводников и около угла Брюстера). Модель Belcour работает с s/p амплитудами Френеля — она не «игнорирует поляризацию полностью»; но видимый свет неполяризован и усредняется, а фазовая разность s/p в плёнке на проводнике как раз и есть реальный источник того, что усреднённая-в-RGB модель теряет точность.

А когда техника не тянет, путь к эталону тот же, что и везде: спектральный рендер через hero wavelength sampling (Wilkie 2014), плюс апсемплинг (Meng 2015 / Jakob-Hanika 2019), чтобы кормить его из RGB-ассетов. Обратите внимание: это противоположное направление по сравнению с пре-интегралом Belcour — тот идёт спектр→RGB, апсемплинг — RGB→спектр.

И отдельно про «побежалость стали»: это оксидная плёнка (диэлектрик) на металле — промежуточный случай. Цвет в основном от плёнки, но подложка-металл подмешивает λ-зависимость.

Часть 4. Дисперсия и призма

Хроматическую аберрацию и радужку фейкают: сдвиг трёх каналов в экранном пространстве или три луча под тремя углами. Дёшево, и в большинстве сцен незаметно.

Но сразу разведём два явления, иначе будет фактическая ошибка. Хроматическая аберрация — артефакт линзы: оптика фокусирует разные λ в разных точках сенсора. Дисперсия — свойство материала: n зависит от λ, и луч преломляется по Снеллу под разными углами. Фейк «сдвиг R/G/B» имитирует именно аберрацию линзы, а не материальную дисперсию. Называть post-process «фейковой дисперсией» — неверно.

Теперь сама физика дисперсии. Главное в одной фразе: показатель преломления зависит от длины волны — синий луч преломляется сильнее красного, поэтому белый свет на входе разъезжается в спектр на выходе. У алмаза этот разброс особенно велик — отсюда «огонь» бриллианта, когда камень рассыпает спектр по граням.

Селлмейер и закон Снелла — формулыОсторожно! Математика!

Зависимость показателя преломления от длины волны формализуется уравнением Селлмейера:

n2(λ)=1+jBjλ2λ2Cjn^2(\lambda) = 1 + \sum_j \frac{B_j\, \lambda^2}{\lambda^2 - C_j}

А закон Снелла с λ-зависимым n даёт свой угол на каждой границе для каждой длины волны:

n1(λ)sinθ1=n2(λ)sinθ2n_1(\lambda)\, \sin\theta_1 = n_2(\lambda)\, \sin\theta_2

Тезис простой: n(λ) — это про путь луча. На одной границе разброс мал; видимый спектр копится при накоплении по пути — две грани призмы, многократные полные внутренние отражения в огранённом камне. Фейк задаёт разделение каналов один раз и не накапливает его на каждом следующем преломлении и TIR. Поэтому он и сыпется на крупном плане бриллианта — дело в механизме не-накопления, а не просто в том, что «выглядит хуже». А «незаметно в большинстве сцен» — это качественная инженерная эвристика.

Что делать, когда камень вылез на передний план. Путь к эталону — монохроматический трейс: каждому лучу одна λ, n(λ) по Селлмейеру, преломление и TIR трассируются под этим λ, итог сворачивается через CIE colour-matching XYZ→sRGB. Цена — цветовой шум: одного сэмпла на λ мало. Снимают это hero wavelength (Wilkie 2014) — пучок из геро-λ и равноотстоящих переиспользует геометрию пути — и приёмом «лочить λ только при первом попадании в диспергирующий материал».

Концепт honest λ-raymarch на бриллианте (GLSL)Осторожно! Код!
// Концепт honest λ-raymarch на бриллианте (фрагмент идеи, не полный трейс):
float lambda = sampleHeroWavelength(seed);   // геро-λ + равноотстоящие
float n      = sellmeier(lambda);            // n(λ) материала
// refract()/TIR на каждой грани считаем под этим n(λ) — спектр расходится веером на каждой грани
vec3 xyz = cieMatch(lambda) * radianceAlongPath;
color += xyzToSRGB(xyz);                      // накопить по сэмплам

Спектр-веер из призмы (refraction) и переливчатость тонких плёнок (interference) из части 3 — это разные явления, хоть оба и «радужные».

Интерактив

Все четыре эпизода — в одном интерактиве, переключаются табами. Каждый — отдельная 3D-сцена со своим органом управления, который доводит её до точки, где упрощение перестаёт совпадать с реальностью. Что показывает каждый:

  • Эпизод 1 — метамерия по источнику. Две объёмные сферы. Ползунок плавно переводит освещение от белого к выбранной лампе (тёплой или узкополосному RGB-LED): под белым обе одного цвета, под лампой расходятся по оттенку. Мини-график под сценой показывает, что спектры отражения у них разные с самого начала — глаз этого под белым просто не видит.
  • Эпизод 2 — один RGB → три спектра. Три сферы — это один и тот же RGB, развёрнутый в три разных правдоподобных спектра (по идее Belcour 2023). Под белым все три одинаковы; добавляешь лампе цветность — и один цвет разъезжается в три. На мини-графике — три непохожих спектра одного RGB.
  • Эпизод 3 — тонкая плёнка. Переливающийся гранёный объект; цвет плёнки берётся из спектральной палитры (серия Ньютона). Ползунок толщины гонит переливы по спектру, а переключатель подложки показывает границу: на пузыре/масле (диэлектрик) переливы чистые, на меди/золоте (проводник) уезжают в цвет металла.
  • Эпизод 4 — дисперсия. Огранённый бриллиант на 360°-панораме. Камень преломляет окружение и расщепляет свет по граням в радугу — «огонь». Материал задаёт показатель преломления (у алмаза он самый высокий — и огонь самый яркий), ползунок усиливает дисперсию.
[ DEMO W1 ]//

Цвет — это не три числа

Выводы

Итак, что у нас получилось. RGB — это не то, как свет устроен в реальности, а первая из череды техник, и основана на идее работы человеческого глаза: три типа колбочек сворачивают спектр в три числа. Для воспроизведения цвета на дисплее этого обычно достаточно. Для физики светопереноса — нет. Как только в дело идут метамерия, непрямой свет, тонкие плёнки на металле или дисперсия в огранённом камне, три числа начинают врать, потому что физика хочет, чтобы вы перемножали спектры и интегрировали, а не перемножали R·R, G·G, B·B.

Хорошая новость в том, что область применимости каждый раз понятна и измерима. Belcour-Barla точны на диэлектрической плёнке и плывут на меди. Фейковая аберрация держится в большинстве сцен и сыпется на крупном плане бриллианта. А там, где RGB не тянет, давно есть готовый точный путь — spectral upsampling и hero wavelength sampling, просто он дороже. По сути весь сезон про это: знать, как всё устроено в реальности, какие виды рендера существуют кроме RGB, чтобы грамотно это фейкать и смешивать техники.

Надеюсь, статья была полезна. Заходите в телеграм-блог. И если у вас есть, что добавить или поправить (особенно по реалтайм-реализации в Unity), давайте обсудим в комментариях — тут полно нюансов.

[больше интересного в телеграм]
// нужна помощь в проекте?

Консультации и техническое сопровождение

Геймдев, графика, AR/VR и computer vision — с математикой как ядром. Разбор вашей задачи и предложение по формату — бесплатно.

Оставить заявку →