Deltadev-math.ru
поддержать игру
// ТЕНЬ

Почему тень зубчатая и грязная: карта глубины, bias, PCF и каскады

Как считают тени в реальном времени: карта глубины со стороны света и подмена вопроса о видимости, shadow acne и минимально достаточный bias, отрыв тени и slope-scale, зубцы по краю и размер текселя, PCF и порядок «сравнить, потом усреднить», каскады и их швы.

22 августа 2026·40 мин чтения·shadow mapacne и biasPCFкаскады
Дейв стоит в свете прожектора, Delta показывает карту глубины со стороны источника

Прошлая статья закончилась на том, что модель освещения не считает тени вообще. max(0, N·L) отвечает на вопрос «повёрнута ли площадка к источнику» — и на этом всё. Между площадкой и лампой может стоять стена, модели это безразлично: она про ориентацию, а не про видимость.

Вопрос «видит ли точка источник» решается отдельным механизмом, и почти вся графика реального времени решает его одним и тем же способом: рендерит сцену со стороны света, запоминает глубину и потом сравнивает. Способ работает, но подменяет исходный вопрос похожим — и все знакомые беды теней растут ровно из этой подмены.

Разберём:

  • как карта глубины превращает вопрос о видимости в сравнение двух чисел, и какой именно вопрос при этом оказывается задан;
  • откуда берётся shadow acne — и почему минимально достаточный запас считается формулой, а не подбирается ползунком;
  • чем за этот запас платят: peter-panning, отрыв тени от основания, и почему на закатном солнце он становится неприличным;
  • зубцы по краю: тексель карты против пикселя экрана, и почему увеличение разрешения — самый дорогой из возможных ответов;
  • PCF и порядок действий, который нельзя переставлять: сравнить, потом усреднить;
  • каскады: схемы разбиения, швы, дрожание края и то, во что это обходится по памяти и по проходам;
  • и как всё это собирается в два прохода рендера и одну функцию в шейдере.
// @easy_dev_math

Такие разборы — с рабочим кодом — выходят в канале каждую неделю.

Тень — это тест видимости

Итак, задача формулируется в одну строку. Для точки PP на поверхности нужно узнать, есть ли что-нибудь между ней и источником света. Есть — точка в тени, множитель прямого освещения ноль. Нет — считаем свет по всем формулам прошлой статьи.

Прямой ответ даёт луч: выпустить из PP отрезок в сторону лампы и проверить пересечения со сценой. Ответ будет точным, потому что это буквально определение тени. Стоить он будет одного луча на пиксель на источник, и в 1978 году, когда задачу решали впервые, это было немыслимо. Сегодня это уже возможно, но и сегодня это дорого — и рядом лежит приём, который справляется одной текстурой.

Переиспользовать буфер глубины

Приём предложил Лэнс Уильямс в работе «Casting curved shadows on curved surfaces» (SIGGRAPH, 1978), и держится он на одном наблюдении. Буфер глубины уже умеет отвечать на вопрос «какая поверхность ближайшая вдоль этого луча». Обычно его считают из камеры. А если посчитать его из источника света, получится ровно карта ближайших поверхностей со стороны лампы — то есть карта того, что освещено.

Отсюда два прохода:

  1. Рендерим сцену из позиции света. Цвет не нужен, пишется только глубина. Результат — текстура, которую называют картой теней (shadow map).
  2. Рендерим сцену из камеры. Для каждой затеняемой точки переводим её в систему координат света и сравниваем её глубину с той, что записана в карте.

Сравнение читается так:

в тени, если d(P)>D[тексель(P)]\text{в тени, если } \quad d(P) > D\bigl[\,\text{тексель}(P)\,\bigr]

Слева — глубина самой точки относительно света. Справа — глубина ближайшей поверхности, которую свет увидел в том же направлении. Если точка глубже, значит по дороге стояло что-то ещё.

Перевод в пространство света — обычная цепочка матриц, та же самая, что переводит вершины в пространство камеры:

vec4 lightPos = uLightViewProj * vec4(worldPos, 1.0);
vec3 proj = lightPos.xyz / lightPos.w * 0.5 + 0.5;  // → [0, 1]
float stored = texture(uShadowMap, proj.xy).r;
float visible = proj.z <= stored ? 1.0 : 0.0;

Матрица MlightM_{\text{light}} для солнца — ортографическая: у направленного источника лучи параллельны, схождения в точку нет. Для прожектора берут перспективную, для точечной лампы — шесть карт на грани куба, потому что светит она во все стороны сразу.

Почему у солнца именно ортографическая проекцияОсторожно! Математика!

Перспективная проекция сводит лучи в одну точку — позицию наблюдателя. У направленного источника такой точки нет: он бесконечно далеко, лучи параллельны. Ортографическая проекция это и описывает.

Побочная выгода — то, как проекция распределяет точность глубины. Ортографическая пишет в карту линейную функцию — в нормировке на [0,1][0, 1]

zortho(z)=znfnz_{\text{ortho}}(z) = \frac{z - n}{f - n}

— каждый метр дальности получает одинаковый кусок диапазона. Перспективная из-за деления на ww пишет гиперболу:

zpersp(z)=f(zn)z(fn)z_{\text{persp}}(z) = \frac{f\,(z - n)}{z\,(f - n)}

Спросим, на какой дистанции потрачена половина всех представимых значений глубины:

f(zn)z(fn)=12z1/2=2fnf+n    2n(fn)\frac{f\,(z - n)}{z\,(f - n)} = \frac{1}{2} \quad\Longrightarrow\quad z_{1/2} = \frac{2fn}{f + n} \;\approx\; 2n \quad (f \gg n)

Половина точности сгорает до удвоенной ближней плоскости. При n=0,1n = 0{,}1 и f=100f = 100 это 20 сантиметров: первые 10 сантиметров дальности забирают половину диапазона, оставшиеся 99,8 метра делят вторую. Для камеры такой размен осмыслен — вблизи точность нужнее. Для карты теней прожектора это головная боль: далёкая геометрия квантуется огромными шагами, и запас глубины приходится подбирать иначе, чем для солнца.

[ DEMO 01 ]//

Глазами света

Ползунок разрешения тут важнее, чем кажется. Карта — это не «изображение сцены со стороны света», это конечный набор чисел: сколько текселей, столько лучей, столько записанных глубин. Между ними у карты нет данных, и когда позже понадобится глубина в промежутке — её никто не восстановит.

Какой вопрос на самом деле задан

Собственно, вот главное место статьи, и всё остальное — его последствия.

Спрашивали: видна ли точка PP из источника. Отвечают: глубже ли PP, чем ближайшая поверхность в том текселе, куда PP спроецировалась.

Два вопроса совпадают только в идеальном случае — когда тексель бесконечно мал, а глубина хранится без потерь. В реальности не выполняется ни то, ни другое:

  • тексель имеет размер, и внутри него все точки получают одно и то же записанное число;
  • глубина хранится с конечной точностью — обычно 24 бита, иногда 16;
  • карта покрывает конечную область: за её пределами данных нет вообще.

Каждое из трёх ограничений даёт свой класс артефактов. Первое — acne и зубцы, второе — «плавающие» тени на больших дистанциях, третье — тени, обрывающиеся по невидимой линии. Дальше по статье разбираем их по очереди.

Acne: поверхность против самой себя

Первое, что видит человек, включивший тени по инструкции из документации, — полосы. Ровный пол покрывается муаром из чередующихся светлых и тёмных лент, стены идут зеброй, а на округлых поверхностях появляется рябь. Артефакт называется shadow acne.

Разберём, откуда он берётся, потому что тут всё считается на бумаге.

Тексель хранит одну точку, а отвечает за целый кусок

Рендер карты — это растеризация. В тексель попадает глубина того места, куда пришёлся его центр. Одно число.

При шейдинге в этот же тексель проецируется не одна точка, а весь кусок поверхности, накрытый текселем. И у этого куска своя глубина в каждой точке — если поверхность к лучу света хоть немного наклонена.

Возьмём поверхность, на которую свет ложится под углом φ\varphi к самой плоскости (φ=90\varphi = 90^\circ — свет в лоб, φ0\varphi \to 0 — скользящий). Чем ниже свет, тем длиннее след текселя на поверхности и тем больший разброс глубин прячется внутри одного записанного числа. Разброс растёт как tanθ\tan\theta, где θ\theta — угол между нормалью поверхности и направлением на свет, то есть ровно тот угол, косинус которого стоит в NLN \cdot L.

Отсюда минимальный запас, при котором acne исчезает полностью: bmin=12wtanθb_{\min} = \tfrac{1}{2}\, w \tan\theta — половина разброса внутри текселя. Меньше — и часть поверхности объявляет себя загороженной. Причём при нулевом запасе тёмной оказывается ровно половина поверхности — та половина каждого текселя, что смотрит от света. Не «примерно много», а половина: именно поэтому acne выглядит как регулярная зебра, а не как случайный шум.

Вывод минимального запаса и доли ложной тениОсторожно! Математика!

Тексель шириной ww ложится на поверхность следом длиной w/sinφw/\sin\varphi, и на этой длине глубина набирает cosφ\cos\varphi на единицу. Вместе:

Δd=wcosφsinφ=wctgφ=wtanθ\Delta d = w \cdot \frac{\cos\varphi}{\sin\varphi} = w \cdot \operatorname{ctg}\varphi = w \cdot \tan\theta

Дальше арифметика. Записано значение в центре текселя, сравниваемая точка отстоит от центра не больше чем на w/2w/2. Значит ошибка сравнения доходит до половины разброса:

bmin=12wtanθb_{\min} = \tfrac{1}{2}\, w \tan\theta

Считается и доля ложной тени: смещение точки от центра текселя равномерно размазано по [w/2,w/2][-w/2,\, w/2], ложная тень возникает там, где ошибка превысила запас, отсюда

facne=max(0,  12bwtanθ)f_{\text{acne}} = \max\left(0,\; \tfrac{1}{2} - \frac{b}{w \tan\theta}\right)

При b=0b = 0 — ровно половина.

[ DEMO 02 ]//

Acne и цена запаса

Красным на поверхности — ложная тень, оранжевым — обратная беда, свет там, где должна быть настоящая тень. Верхний счётчик меряет долю ложной тени прямо по кадру, второй считает её по формуле из спойлера — расходятся они разве что на округлении, и на нулевом запасе оба приходят к 50%.

Чем платят за запас

Запас — это сдвиг сравниваемой глубины: точка притворяется чуть ближе к свету, чем она есть. Значит и её тень начинается позже — отклеивается от основания предмета, и предмет начинает выглядеть парящим над полом. Артефакт так и называется — peter-panning. Размер отрыва пропорционален запасу: xотрыв=bcosφx_{\text{отрыв}} = b \cos\varphi.

Вывод отрыва из запасаОсторожно! Математика!

Точка на полу, отстоящая на xx от места, где загородивший объект касается пола, находится от него на расстоянии x/cosφx/\cos\varphi вдоль луча. При запасе bb она считается освещённой, пока это расстояние меньше запаса:

xотрыв=bcosφx_{\text{отрыв}} = b \cos\varphi

А теперь подставим минимально достаточный запас в отрыв и посмотрим, что получилось:

xотрывbmin=w2cos2φsinφx_{\text{отрыв}}\big|_{b_{\min}} = \frac{w}{2}\cdot\frac{\cos^2\varphi}{\sin\varphi}

Вот это и есть вся боль теней одной формулой. При высоком солнце знаменатель большой, отрыв микроскопический. При закатном — sinφ0\sin\varphi \to 0, и отрыв уходит в бесконечность. Карта 102421024^2 на область в 20 метров, солнце в 45° — отрыв 7 миллиметров, никто не заметит. То же самое солнце в 10° — 5,5 сантиметра. В 5° — уже 11 сантиметров, и тень заметно отходит от ног.

Отсюда, кстати, практический вывод, который в документации движков обычно не пишут: закат — самое дорогое время суток для теней. Если в игре есть цикл дня и ночи, настроенные на полдень параметры к вечеру перестают работать — это свойство метода.

Три способа дать запас

Константа. Самое простое: вычесть из глубины фиксированное число. Работает ровно до тех пор, пока tanθ\tan\theta не вырос — то есть до первого наклонного пола или до первого заката. Подобранная на одной сцене константа переезжает на другую и там ломается.

Slope-scale bias. Раз минимум пропорционален tanθ\tan\theta, поправку туда и вписывают:

b=bconst+bslopetanθb = b_{\text{const}} + b_{\text{slope}} \tan\theta

Именно эта формула стоит за glPolygonOffset(factor, units) и за парой «Depth Bias / Slope Scaled Depth Bias» в настройках движков. Она снимает зависимость от наклона — но за неё же и платит: чем острее угол, тем больше запас и тем сильнее отрыв.

Normal offset. Приём другого рода: двигать не сравниваемое число, а место выборки. Точку перед проецированием сдвигают вдоль нормали поверхности:

vec4 lightPos = uLightViewProj * vec4(worldPos + N * uNormalOffset, 1.0);

Сдвиг вдоль нормали уводит точку выборки в соседний тексель, где записана глубина уже не той площадки, с которой мы её сравниваем. Самозатенение пропадает, а вот отрыва тени не появляется: сравниваемая глубина не подкручена, изменилось только место выборки. Платить приходится смещением самого края тени на величину сдвига — на плоских участках это незаметно, а вот на тонкой геометрии тень начинает соскальзывать с предмета. В демке этот режим стоит четвёртым, разница с константой на низком солнце видна сразу.

И четвёртый способ — отрезать проблему геометриейОсторожно! Практика!

В теневом проходе можно рисовать не передние грани, а задние: glCullFace(GL_FRONT). Тогда в карту попадёт дальняя стенка объекта, а не ближняя, и освещённая поверхность окажется заведомо ближе к свету, чем записанное значение. Самозатенение снимается почти целиком, и запас нужен символический.

Цена — требование к геометрии. Приём работает на замкнутых объектах с толщиной. Лист бумаги, забор из одного полигона, любая односторонняя геометрия задней грани не имеют, и в карте для них не окажется вообще ничего: тени пропадут. В сценах, где смешано и то, и другое, приходится держать оба режима и помечать модели вручную — что само по себе тот ещё источник ошибок.

Зубцы: тексель против пикселя

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

Своя сетка — это сетка карты. Тень заканчивается там, где кончился тексель, потому что внутри текселя ответ один на всех.

Насколько крупный тексель на самом деле

Тексель — квадрат в проекции света, со стороной

w=LNw = \frac{L}{N}

где LL — сторона области, которую накрывает карта, NN — разрешение. Карта 204822048^2 на область 100 метров даёт около пяти сантиметров — звучит прилично.

Только на поверхность этот квадрат ложится не квадратом. След текселя на полу растянут делением на синус:

wслед=wsinφw_{\text{след}} = \frac{w}{\sin\varphi}

Те же пять сантиметров при солнце в 15° превращаются в 19 сантиметров следа. При 5° — в 56. Ступенька по краю тени будет ровно такой ширины, и уже на среднем плане её видно невооружённым глазом.

[ DEMO 03 ]//

Зубцы по краю

В демке сетка текселей нарисована поверх пола, и край тени буквально ложится на её линии. Ползунок высоты света показывает второй множитель: при том же разрешении низкое солнце размазывает тексель на метры.

Почему разрешение — плохой ответ

Первое желание — поднять NN. Оно работает, но арифметика тут неприятная.

Ступенька обратно пропорциональна NN, а память карты растёт как N2N^2. Чтобы уменьшить зубец вдвое, карту надо взять вчетверо большую. При четырёх байтах на тексель — а именно столько занимает привычный формат с 24-битной глубиной — карта 1024² весит 4 мегабайта, 2048² — 16, 4096² — 64. И это одна карта на один источник; в сцене с тремя тенями от прожекторов расход утраивается.

Причём заплатив памятью, вы не получаете пропорционального выигрыша по картинке. Тексель бьётся не с абсолютным размером, а с размером пикселя на экране, а тот меняется с дистанцией. Разница между ними — то, что называют перспективным алиасингом: у камеры под ногами пиксель покрывает миллиметры, а тексель карты, растянутой на весь уровень, — сантиметры.

Полезно посмотреть на отношение:

r=wследразмер пикселя на дистанции zr = \frac{w_{\text{след}}}{\text{размер пикселя на дистанции } z}

Единица — идеал: тексель ровно в пиксель, ступеньки не видно, лишней памяти не потрачено. Больше единицы — зубцы. Меньше — вы платите за разрешение, которого глаз не увидит. У одной карты на весь уровень rr проходит весь диапазон от сотен у ног до долей на горизонте, и никакое одно NN не годится сразу для всех дистанций.

Три разных алиасинга, которые часто путаютОсторожно! Математика!

Перспективный — тот, что описан выше: пиксель мельчает с приближением к камере, тексель нет. Его можно записать точно. Пиксель на дистанции zz накрывает в мире полосу

p(z)=2ztan(fov/2)Hp(z) = \frac{2\,z \tan(\text{fov}/2)}{H}

где HH — высота кадра в пикселях. Тексель карты от zz не зависит вовсе, и отношение из основного текста разворачивается в гиперболу:

r(z)=LNsinφH2ztan(fov/2)    1zr(z) = \frac{L}{N \sin\varphi} \cdot \frac{H}{2\,z \tan(\text{fov}/2)} \;\propto\; \frac{1}{z}

Дистанция в кадре гуляет на три порядка, а NN сидит в формуле константой — единицей rr становится на одной-единственной дистанции: ближе неё зубцы, дальше — разрешение, потраченное впустую. Лечится тем, что карту подгоняют под фрустум камеры, а фрустум режут на куски со своим L/NL/N у каждого. Это пятая часть.

Проекционный — след текселя растёт на поверхностях, повёрнутых к свету ребром. Треугольник тот же, что в выводе acne: поперечник текселя ww — катет напротив угла φ\varphi, след на поверхности — гипотенуза:

wслед=wsinφ    (φ0)w_{\text{след}} = \frac{w}{\sin\varphi} \;\longrightarrow\; \infty \quad (\varphi \to 0)

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

Временной — при движении камеры сетка текселей едет вместе с картой, и край тени переливается от кадра к кадру. Отдельно неприятен тем, что на статичном скриншоте его нет. Лечится привязкой карты к целым текселям, об этом тоже в пятой части.

Первые два — про то, что тень выглядит грубой. Третий — про то, что она выглядит грязной в движении, и раздражает он обычно сильнее.

Что на самом деле помогает

Заметьте, где именно тратится разрешение в наивной схеме: карта накрывает весь уровень, потому что источник светит на всё сразу. При этом камера в конкретном кадре видит малую его часть.

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

Вторая — заметить, что и внутри видимого куска разрешение нужно неравномерно. Это уже каскады, и до них осталась одна часть.

PCF: край как доля, а не как ответ

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

Да? Конечно же нет. И разбор того, почему нет, стоит дороже самого приёма.

Что не так с размытием карты

Карта хранит глубины. Среднее двух глубин — это глубина поверхности, которой в сцене нет.

Возьмите тексель на краю стены: слева записана крыша на высоте два метра, справа — пол. Усредните — получится ступенька высотой метр, висящая в воздухе. Сравнив себя с ней, пол объявит себя то освещённым, то затенённым в зависимости от того, по какую сторону этой ступеньки оказалась его глубина, и результат по-прежнему будет бинарным: край никуда не денется, он просто уедет в сторону. Ровно это и происходит на местах контакта: тень отклеивается от предметов там, где никакого запаса глубины не выставляли.

Приём, который работает, придумали Ривз, Салезин и Кук («Rendering antialiased shadows with depth maps», SIGGRAPH, 1987) и назвали percentage-closer filtering. Идея в перестановке двух действий:

  • неправильно: усреднить глубины соседних текселей → сравнить один раз;
  • правильно: сравнить с каждым текселем по отдельности → усреднить ответы.

Во втором случае усредняются нули и единицы, и результат — доля выборок, оказавшихся на свету. Полбалла означает «половина карты вокруг говорит, что тут светло», и это уже осмысленная величина.

float lit = 0.0;
for (int j = -R; j <= R; ++j)
  for (int i = -R; i <= R; ++i) {
    float stored = texture(uShadowMap, uv + vec2(i, j) * texel).r;
    lit += step(depth - bias, stored);   // 1.0, если освещено
  }
return lit / float((2 * R + 1) * (2 * R + 1));
[ DEMO 04 ]//

PCF: край как доля

Переключатель порядка в демке ставит эти два варианта рядом. На «размыть → сравнить» край не смягчается ни на пиксель — зато тени сползают с оснований, и это единственное, что вы получаете за ту же цену в выборках.

Сколько это стоит

Ядро n×nn \times n — это n2n^2 выборок из текстуры на каждый пиксель на каждый источник. 3×3 — девять, 5×5 — двадцать пять. Для полноэкранного прохода в 1080p это 18,7 и 51,8 миллиона выборок соответственно, и на мобильном GPU разница между ними хорошо заметна в кадре.

Поэтому в железе есть подпорка. Видеокарты умеют делать сравнение внутри выборки: тип sampler2DShadow в GLSL и SamplerComparisonState в HLSL берут четыре соседних текселя, сравнивают каждый с переданной глубиной и возвращают билинейно взвешенную долю — то есть готовое PCF 2×2 по цене одной выборки. Четыре таких обращения, разнесённых на два текселя, накрывают область 4×4: шестнадцать сравнений по цене четырёх выборок.

Почему точки лучше брать не по сеткеОсторожно! Математика!

У квадратного ядра есть свой характерный дефект: край распадается на ровные ступени вместо одной. Причина в регулярности — все пиксели берут выборки в одной и той же решётке, и ошибка у соседних пикселей одинаковая, то есть складывается в видимый узор.

Лечится двумя приёмами, обычно вместе.

Нерегулярное ядро. Точки раскладывают по кругу так, чтобы они не садились на сетку: исторически — таблицей Пуассона, сейчас чаще спиралью Фибоначчи:

ri=Ri+0,5n,θi=i2π(2ϕ),ϕ=1+52r_i = R\sqrt{\frac{i + 0{,}5}{n}}, \qquad \theta_i = i \cdot 2\pi\,(2 - \phi), \qquad \phi = \frac{1 + \sqrt{5}}{2}

Шаг по углу — золотой угол 2π(2ϕ)137,52\pi\,(2 - \phi) \approx 137{,}5^\circ. Корень отвечает за равномерность: кольцо между соседними радиусами имеет одну и ту же площадь,

πri+12πri2=πR2n=const\pi r_{i+1}^2 - \pi r_i^2 = \frac{\pi R^2}{n} = \mathrm{const}

— по точке на кольцо, и плотность постоянна по всему кругу. Золотое сечение взято не для красоты: из всех чисел оно хуже всего приближается дробями — его цепная дробь состоит из одних единиц. Шаг, близкий к рациональной доле оборота, за несколько витков собирает точки в лучи; золотой угол не даёт им выстроиться ни в лучи, ни в кольца ни при каком nn, и ни одна точка не садится в узел решётки.

Свой поворот в каждом пикселе. Ядро крутят на угол, взятый из маленькой текстуры шума. Ошибка остаётся той же по величине, но перестаёт быть согласованной у соседей — и полосы превращаются в зерно, которое глаз ловит хуже. Дальше это зерно обычно догоняет темпоральное сглаживание.

В демке это пятый вариант ядра. Ошибка там не меньше, чем у спирали без поворота — она просто по-другому распределена.

Что PCF не чинит

Ширина размытия у PCF — это ширина ядра в текселях, помноженная на размер текселя. И всё. Она не зависит ни от размера источника, ни от того, как далеко предмет стоит от поверхности, на которую падает тень.

У настоящей полутени зависит и то, и другое: тень от ноги на асфальте почти острая, тень от той же ноги, если поднять её на метр, заметно размыта. Приём, который это воспроизводит, называется PCSS (Fernando, 2005): сначала отдельным проходом ищут, насколько глубоко в карте лежит загородивший объект, из этого считают ширину полутени и уже её берут радиусом ядра. Стоит это ещё одного набора выборок на пиксель, и потому вписывается не в каждый бюджет.

Второе, и более важное. PCF не добавляет карте разрешения. Если тексель кладётся на пол сорокасантиметровым пятном, то ядро 5×5 размажет край на два метра — и получится не мягкая тень, а размытая неправильная. Смягчать имеет смысл край, который уже стоит там, где надо; чинить фильтром грубую карту не выйдет.

Каскады: разное разрешение на разной дистанции

В третьей части мы уткнулись в то, что одного разрешения на всю дальность не бывает. У ног нужны сантиметры, на горизонте хватит метров, а карта одна и тексель у неё везде одинаковый.

Чтож, раз требования к плотности разные на разных дистанциях, разобьём дальность на куски и дадим каждому свою карту. Приём называется каскадными картами теней (cascaded shadow maps), и на сегодня это стандартный способ делать тени от солнца в открытом мире.

Где резать

Пусть камера видит от nn до ff, а каскадов kk. Границы можно ставить по-разному, и разница между схемами — не косметическая.

Равномерно — каждому каскаду одинаковый кусок дальности:

Ciuni=n+(fn)ikC_i^{\text{uni}} = n + (f - n)\,\frac{i}{k}

Просто и плохо. При дальности 300 метров и четырёх каскадах первый накрывает всё до 75 метров — то есть ровно та зона, где нужна максимальная плотность, обслуживается одной картой на семьдесят пять метров.

Логарифмически — одинаковый кусок в логарифме дальности:

Cilog=n(fn)i/kC_i^{\text{log}} = n \left(\frac{f}{n}\right)^{i/k}

Эта схема идеальна по плотности: она держит отношение «тексель к пикселю» постоянным по всей дальности, что и требовалось. И она непригодна в чистом виде. При n=0,3n = 0{,}3 и f=300f = 300 первая граница встаёт на 1,7 метра: целый каскад тратится на пятно под ногами, а на всё остальное остаётся на один каскад меньше.

Практическая схема — взвешенное среднее двух крайностей:

Ci=λCilog+(1λ)CiuniC_i = \lambda\, C_i^{\text{log}} + (1 - \lambda)\, C_i^{\text{uni}}

Её предложили Чжан и соавторы в работе про parallel-split shadow maps (2006, разбор — в десятой главе GPU Gems 3), и с тех пор она стоит почти везде. λ\lambda — та самая ручка, которая в настройках движка называется чем-нибудь вроде «Cascade Distribution»: ноль — равномерно, единица — логарифм, между ними — размен разрешения ближней зоны на разрешение дальней.

[ DEMO 05 ]//

Каскады и их швы

График под фрустумом — плотность текселей на метр по дистанции. Переключая схемы, обратите внимание не на среднее, а на самое низкое место: качество тени определяется худшим каскадом, а не лучшим.

Насколько велика карта одного каскада

Тут есть неочевидное место. Каскад — это кусок фрустума, усечённая пирамида, а карта — квадрат. Насколько большим должен быть квадрат?

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

Устойчивое решение — накрывать кусок сферой. Сфера не меняет радиус при повороте, значит и тексель остаётся постоянного размера. Радиус считается точно: у куска между z0z_0 и z1z_1 центр охватывающей сферы лежит на оси взгляда, и найти его можно из равенства расстояний до ближних и дальних углов.

Вывод радиуса охватывающей сферыОсторожно! Математика!

Обозначим через k2=tan2(fovy/2)(1+a2)k^2 = \tan^2(\text{fov}_y/2)\,(1 + a^2) квадрат отношения «расстояние до угла кадра» к «дальности по оси», где aa — соотношение сторон. Угол кадра на дистанции zz отстоит от оси на zkzk.

Центр сферы zcz_c ищем из условия, что ближние и дальние углы равноудалены:

z02k2+(z0zc)2=z12k2+(z1zc)2z_0^2 k^2 + (z_0 - z_c)^2 = z_1^2 k^2 + (z_1 - z_c)^2

Раскрываем и сокращаем на (z0z1)(z_0 - z_1):

zc=(z0+z1)(k2+1)2z_c = \frac{(z_0 + z_1)(k^2 + 1)}{2}

Если получилось zc>z1z_c > z_1 — а на широком угле обзора так и выходит, — сфера садится на дальнюю плоскость, и её радиус равен расстоянию до дальних углов, z1kz_1 k. Сторона карты — диаметр, то есть 2r2r.

Отсюда, кстати, видно, чем платят за устойчивость: сфера накрывает заметно больше, чем сам кусок фрустума, и часть текселей уходит на пустоту. Это осознанный размен — дрожание края в движении раздражает сильнее, чем потеря части разрешения.

Второй половиной лечения дрожания будет привязка карты к целым текселям: центр карты каждый кадр округляют до узла её же сетки. Тогда при движении камеры сетка не ползёт непрерывно, а прыгает ровно на тексель, и край тени остаётся на месте относительно геометрии.

Швы

Плотность внутри каскада постоянна, между каскадами — скачет. На границе это видно как шов: с одной стороны тень чуть острее, с другой чуть грубее, а если запас глубины у каскадов подобран по-разному, то ещё и сдвигается.

Прячут шов полосой смешивания: в окрестности границы считают тень по обоим каскадам и плавно перемешивают по дистанции. Стоит это удвоенного числа выборок в узкой полосе экрана — терпимо. Кроме того, запас глубины обязан пересчитываться на каждый каскад: тексель у них разного размера, а bminb_{\min} из второй части пропорционален именно ему. Одна константа на все каскады даёт acne в дальнем и отрыв в ближнем.

Чего это стоит

Главная статья расходов — не память, хотя четыре карты 204822048^2 это 64 мегабайта. Главное — проходы. Каскад рисуется отдельным проходом по геометрии, и объект, попавший в три каскада, рисуется трижды. Плюс основной проход. Это ровно тот случай, когда узкое место кадра оказывается не в шейдерах, а в количестве команд отрисовки — про них была отдельная статья.

Поэтому в движках каскадов обычно не больше четырёх, дальний каскад часто обновляют не каждый кадр, а в список объектов для дальних каскадов не включают мелочь, тень которой на такой дистанции всё равно меньше текселя.

Оба прохода целиком

Соберём. Ниже — рабочая сцена: два прохода рендера, настоящая карта глубины в кадровом буфере, все переключатели из предыдущих частей.

[ DEMO 06 ]//

Оба прохода целиком

Функция, которая считает тень, напечатана целиком. Это тот же исходник, который склеивается в компилируемый шейдер демки, а не его пересказ:

float shadow(vec3 worldPos, vec3 N, vec3 L) {
  // Normal-offset: точку двигают вдоль нормали ДО перевода в свет —
  // сдвигается место выборки, а не сравниваемое число.
  vec4 lightPos = uLightViewProj * vec4(worldPos + N * uNormalOffset, 1.0);
  vec3 proj = lightPos.xyz / lightPos.w * 0.5 + 0.5;

  // За границами карты ничего не записано. Считаем, что там открыто —
  // иначе весь мир за краем карты уйдёт в тень.
  if (proj.z > 1.0) return 1.0;
  if (any(lessThan(proj.xy, vec2(0.0))) || any(greaterThan(proj.xy, vec2(1.0)))) return 1.0;

  // Slope-scale bias: чем острее свет ложится на поверхность, тем сильнее
  // гуляет её глубина внутри одного текселя карты.
  float cosT = clamp(dot(N, L), 1e-3, 1.0);
  float tanT = sqrt(1.0 - cosT * cosT) / cosT;
  float bias = uBiasConst + uBiasSlope * tanT;

  vec2 texel = 1.0 / vec2(textureSize(uShadowMap, 0));

  // PCF: каждая выборка отвечает «в тени / не в тени», и усредняются ОТВЕТЫ.
  // Усреднить сначала глубины — значит сравнить себя с поверхностью, которой
  // в сцене нет.
  float lit = 0.0;
  for (int j = -PCF_R; j <= PCF_R; ++j) {
    for (int i = -PCF_R; i <= PCF_R; ++i) {
      float stored = texture(uShadowMap, proj.xy + vec2(float(i), float(j)) * texel).r;
      lit += step(proj.z - bias, stored);
    }
  }
  float taps = float((2 * PCF_R + 1) * (2 * PCF_R + 1));
  return lit / taps;
}

Пройдёмся по ней сверху вниз.

Сдвиг вдоль нормали идёт первым, до перевода в пространство света. Так и должно быть: normal offset двигает место, откуда берётся выборка, а не сравниваемое число. Если поставить его после проекции, получится обычный сдвиг по глубине с другим коэффициентом.

Деление на w и переход в [0, 1]. У ортографической проекции солнца w=1w = 1 и деление ничего не меняет, но для прожектора оно обязательно. Дальше координаты сдвигаются из NDC в диапазон текстуры — та самая * 0.5 + 0.5.

Два выхода наружу. Если точка глубже дальней плоскости карты или вышла за её пределы по xy, функция возвращает единицу — «освещено». Это не забота о корректности, а выбор из двух зол: вне карты данных нет, и любой ответ будет выдуманным. «Освещено» выдумывать безопаснее, потому что альтернатива — чёрная стена по невидимой линии. В движках вместо этого чаще делают затухание тени к краю дальнего каскада.

Запас. cosT — это NLN \cdot L, зажатый снизу, чтобы не делить на ноль. Из него получается tanθ\tan\theta и дальше формула из второй части. Обратите внимание: обе константы приходят снаружи в единицах глубины карты, а не в метрах. У ортографической проекции перевод простой — поделить мировой запас на диапазон глубины; у перспективной он зависит от дистанции, и это ещё одна причина, по которой тени от прожекторов настраиваются отдельно.

Цикл PCF. textureSize даёт разрешение карты прямо в шейдере, из него — размер текселя в координатах текстуры. step(proj.z - bias, stored) возвращает единицу, если освещено. Складываются именно ответы, а не глубины.

PCF_R — это #define, а не uniform. Иначе цикл не развернётся, и вместо прямолинейного кода получится ветвление в каждом пикселе. Смена размера ядра означает пересборку шейдера — поэтому в движках варианты качества теней компилируются заранее, отдельными комбинациями ключей.

Где на самом деле тратится кадр

Функция выше стоит n2n^2 выборок из текстуры. Это заметно, но не это главный расход.

Главный — первый проход. Чтобы построить карту, нужно заново прогнать всю геометрию сцены, с отсечением, с выставлением состояний, с командами отрисовки. Один каскад — один такой прогон. Четыре каскада — четыре, плюс основной проход: пять раз за кадр по одному и тому же списку объектов.

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

Выводы

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

Отсюда и разбор симптомов:

  • полосатая рябь на ровных поверхностях — запаса глубины нет или он меньше 12wtanθ\tfrac{1}{2} w \tan\theta;
  • рябь появилась только к вечеру — запас константный, а tanθ\tan\theta вырос вместе с заходом солнца;
  • предметы парят над полом — запас больше необходимого, и тень отъехала на bcosφb\cos\varphi;
  • и рябь, и парение одновременно — окно между ними закрылось: карта слишком грубая для такого угла света, числом это уже не чинится;
  • край тени идёт крупной лесенкой — тексель кладётся на поверхность пятном шире пикселя;
  • лесенка мелкая, но переливается в движении — карта не привязана к целым текселям;
  • тень отклеилась от предмета, хотя запас нулевой — глубины усреднили до сравнения вместо после;
  • тень мягкая, но контакт с полом потерян — ядро фильтра расширили, чтобы спрятать грубость карты;
  • полоса на определённой дистанции — шов каскадов без смешивания или с разным запасом у соседей;
  • тени обрываются по невидимой линии — кончилась область, которую накрывает карта.

Чего мы не разобрали

Другие способы хранения. Всё в статье построено на сравнении глубин, но глубину можно хранить так, чтобы карту стало можно фильтровать заранее и обычными средствами. В variance shadow maps (Donnelly и Lauritzen, 2006) в текстуру пишут глубину и её квадрат, а видимость оценивают неравенством Чебышёва; развитие идеи — exponential (Annen и соавторы, 2008) и moment shadow maps (Peters и Klein, 2015). Все они меняют один набор артефактов на другой: вместо acne появляется просвет тени в местах, где загораживающих поверхностей несколько.

Тени от точечных ламп. Шесть граней кубической карты, свой набор швов и своя арифметика запаса, потому что проекция там перспективная.

Контактные тени. Короткая трассировка по буферу глубины прямо в экранном пространстве — то, что подбирает мелкие детали, для которых тексель карты слишком крупен. Это уже соседняя тема, ближе к затенению окружением.

Трассировка. Тот самый прямой ответ из первой части, который в 1978 году был немыслим, а сейчас — вариант. Он снимает разом весь список артефактов выше, потому что не подменяет вопрос. Взамен приносит свой: шум, который надо чем-то давить.

И общая закономерность, сквозная для всей серии. Карта теней не врёт — она даёт точный ответ на свой вопрос. Ошибка начинается там, где разница между её вопросом и вашим перестала быть маленькой: скользящий свет, грубая карта, протяжённый источник. Разница между «подкрутил bias, стало лучше» и «знаю, что чиню», — в том, чтобы держать в голове, где именно эти два вопроса расходятся.

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

Надеюсь, статья была полезна. Заходите в телеграм-канал, такие разборы выходят там каждую неделю, и буду рад дополнениям в комментариях: у теней нюансов заметно больше, чем влезло в шесть частей.

// @easy_dev_math

Разборы графики с кодом — каждую неделю в канале.