1. Введение
2. Существующие решения
3. Алгоритм
4. Оптимизация
5. Проблемы и решения
6. Заключение
7. Исходный код
В статье описан альтернативный алгоритм получения сглаженных, по-возможности геометрически правильных теней. Также стояла задача полностью переложить расчёт теней на GPU. Данный метод можно реализовать используя только возможности DirectX 9 (пример и статья рассмотрены на базе этого API), однако рассчитан он для применения с возможностями DirectX 10, в частности с геометрическими шейдерами.
Для понимания статьи желательно быть знакомым с алгоритмом Shadow Volumes (по-моему он неплохо описан здесь). Иметь представление о программировании шейдеров, использовании alphablending, рендеринге в текстуру.
Своей задачей я ставил получение более "дешёвого" алгоритма приближённого построения геометрически правильных теней, чем существующие решения. Поскольку алгоритм является весьма "сырым", и скорее всего, я не везде находил оптимальные решения проблем, не стоит воспринимать его как точную инструкцию. Я скорее надеюсь привлечь читателя к идее этого метода, возможно вы сможете доработать его, или даже если вам не придётся по душе сама идея, то вы узнаете какие-то новые решения проблем, связанных с построением мягких теней.
На деле это очень сложный алгоритм, и далее будут рассмотрены основные проблемы его реализации.
С точки зрения физики, пренебрегая волновой природой происхождения света, освещённость в точке зависит от того, какую часть источника света из неё мы видим. В случае точечного источника света граница тени будет жёсткой, но в реальности таких источников не существует, поэтому тень имеет размытую границу. Для определения освещённости точки мы будем интерполировать её значение от 0.0 до 1.0 от границы полной тени до границы полутени (рис 5).
В реальном времени физически правильно вычислять объёмы полной тени и полутени не удастся. А для того, чтобы считать эти объёмы на GPU надо уметь получать границу тени в точке силуэта, независимо от остальной части объекта. В алгоритме Penumbra Wedges границы полутеневых клиньев получаются как касательные к сфере света, проведенные через ребро объекта(рис 5). Такой метод даёт наиболее приближенный к реальности результат, но он не переносим полностью на GPU, потому что для построение замкнутого объема нам надо учитывать взаимное расположение ребёр, то есть нельзя обрабатывать вершины или рёбра объекта независимо (рис 4). Так что первой задачей алгоритма будет построение замкнутого полутеневого объема на графическом процессоре.
Для каждой вершины силуэта найдем точку на сфере источника света, от которой будем вытягивать полутеневой объем. Самое простое и наиболее приближенное к идеалу решение, которое я нашёл: point = lightPosition - vertexNormal * lightRadius, где vertexNormal - интерполированная нормаль в точке (никаких smooth groups не использовать). На практике это весьма неплохое приближение, несмотря на его простоту. В самом деле, силуетные ребра (как и усреднённые нормали в их вершинах) хоть и не параллельны одной плоскости, но, как правило, не сильно от неё отклоняются. В этом случае нормаль в вершине будет близка по направлению к нормали касательной плоскости к сфере источника света, проходящей через ребро объекта, разделяющего эту вершину (эта плоскость ограничивает наиболее правильный пенумбральный клин - рис 5, приближённое построение - рис 6).
Резонно было бы находить точку, из которой вытягивать объём полной тени по формуле point2 = lightPosition + vertexNormal * lightRadius, но, к сожалению, это не даёт возможности использовать stencil test (имеется ввиду метод получения тени алгоритма Shadow Volumes) для ограничивания абсолютно неосвещённой области. Могут образовываться "дыры" в области полной тени, из-за того что грани такого объёма будут пересекаться. Так что объём полной тени будем строить как обычный теневой объем от точечного источника света (реализацию Stencil Shadow Volumes полностью на GPU можно посмотреть, например, в примерах DirectX SDK) *.
Кроме построения самого пенумбрального клина, нам также понадобятся ограничивающие его плоскости (рис. 7),
чтобы определять какие точки сцены лежат в области полутени, то есть в пенумбральном клине(как построить эти плоскости см. пример шейдера далее).
Теперь мы можем определить, лежит ли точка в области полутени, зная её координату(эта точка должна лежать по одну сторону от всех 4х плоскотей клина).
То есть мы можем нарисовать клинья и определить значения освещённости, в точках полутени.
Для нахождения координат точки сцены, которую "загораживает" пенумбральный клин можно использовать его
текущую x, y координату и значение в z-buffer'е как z координату. Узнать это значение в пиксельном шейдере без
дополнительных изощрений не получится. Стандартный прием: отрисовать сцену в текстуру, с помощью шейдера, который возвращает значение
глубины точки, а потом узнавать z-координату из этой текстуры **.
Сохранять значения освещённости можно в некую текстуру(vBuffer), как это
было сделано в алгоритме Penumbra Wedges, но так как у нас объем замкнутый, а значения освещённости для точки мы узнаем сразу, т.е.
ненужно делать никаких усреднений, смешиваний, мы можем хранить значение освещённости в alpha компоненте цвета, а при добавлении
освещения, просто установить опции смешивания таким образом:
color = destColor + srcColor*destAlpha
,![]() |
![]() |
|
Рис 7. Плоскости пенумбрального клина | Рис 8. Определение точек внутри клина |
Приведу полный код шейдера, строящего пенумбральный клин и рассчитывающего освещённость. В данной реализации, ввиду отсутвия поддержки геометрических шейдеров моей видеокартой, я копировал на каждое ребро по 6 вершин для построения пенумбральных клиньев, определял силуэтные рёбра и вытягивал их в пенумбральные клинья в вершинном шейдере.
float4x4 invTransform; // invTransform = ~worldViewProjMatrix float4x4 worldViewMatrix; float4x4 worldViewProjMatrix; struct VS_INPUT_VE { float4 position : POSITION; float3 vNormal0 : TEXCOORD0; // нормаль в первой вершине ребра float3 vNormal1 : TEXCOORD1; // нормаль во второй вершине ребра float4 normal : NORMAL; // нормаль к первой смежной грани ребра float3 backNormal : TEXCOORD2; // нормаль ко второй смежной грани ребра float4 edge : TEXCOORD3; // ребро соединяющее данную вершину ребра с другой }; struct VS_OUTPUT_VE { float4 position : POSITION; float4 front : TEXCOORD0; // плоскости ограничивающие пенумбральный клин float4 back : TEXCOORD1; float4 left : TEXCOORD2; float4 right : TEXCOORD3; float4 projPos : TEXCOORD4; // та же позиция, пригодится в пиксельном шейдере }; // нахождение левой и правой плоскости пенумбрального клина float4 GetLRPlane(float3 pos, float3 sv0, float3 sv1) { float4 plane; plane.xyz = normalize( cross(pos - sv0, pos - sv1) ); plane.w = -dot(pos, plane.xyz); return plane; } // нахождение передней и задней плоскости пенумбрального клина float4 GetFBPlane(float3 pos, float3 edge, float3 sv) { float4 plane; plane.xyz = normalize( cross(edge, pos - sv) ); plane.w = -dot(pos, plane.xyz); return plane; } // Вершинный шейдер, выполняющий построение пенумбрального клина VS_OUTPUT_VE ExtrudePenumbra( VS_INPUT_VE vertex ) { VS_OUTPUT_VE result; float4 extruded; float3 dir; float3 sphereVert0, sphereVert1; // Если обе грани ребра освещены, или неосвещены одновременно, // то данная вершина не входит в пенумбральный клин dir = vertex.position - lightPosition; if ( dot(dir, vertex.normal) * dot(dir, vertex.backNormal) > 0.0 ) { result.front = result.back = result.left = result.right = 0.0; result.projPos = result.position = 0.0; return result; } // Мы немного увеличим пенумбру, чтобы не было прорези // между тенью и полутенью sphereVert0 = lightPosition + vertex.vNormal0 * 0.15; sphereVert1 = lightPosition - vertex.vNormal0 * lightRadius; extruded = vertex.position; if ( vertex.normal.w > 0.0 ) // в w-компоненте указано как вытягивать вершину { // вытягивание от источника, вершина лежит в back-плоскости клина dir = vertex.position.xyz - sphereVert0; extruded.xyz += normalize(dir) * (lightRange - length(dir)); } else if ( vertex.normal.w < 0.0 ) { // вершина лежит в front-плоскости клина dir = vertex.position.xyz - sphereVert1; extruded.xyz += normalize(dir) * (lightRange - length(dir)); } // Еслм w == 0 не вытягиваем вершину result.projPos = result.position = mul(extruded, worldViewProjMatrix); // w - хранит ориентацию ребра. Если ребро идёт от первой вершины // до второй, то w == 1.0, иначе -1.0. // Необходимо "развернуть" ребро во второй вершине, // чтобы нормали к front, back плоскостям не поменяли знак dir = vertex.edge * vertex.edge.w; // Находим плоскости пенумбрального клина result.front = GetFBPlane(vertex.position.xyz, dir, sphereVert1); result.back = -GetFBPlane(vertex.position.xyz, dir, sphereVert0); // во второй вершине left и right плоскости меняются местами if (vertex.edge.w > 0.0) { result.left = -GetLRPlane(vertex.position.xyz, sphereVert0, sphereVert1); result.right = GetLRPlane(vertex.position.xyz + vertex.edge.xyz, lightPosition, sphereVert0 - vertex.vNormal1 * lightRadius); } else { result.right = -GetLRPlane(vertex.position.xyz, sphereVert0, sphereVert1); result.left = GetLRPlane(vertex.position.xyz + vertex.edge.xyz, lightPosition, sphereVert0 - vertex.vNormal1 * lightRadius); } return result; } // Пиксельный шейдер, рассчитывающий освещённость в точке. // Работаем в системе координат объекта float4 PenumbraAlpha( VS_OUTPUT_VE vertex ) : COLOR0 { // Надо найти текстурную координату, соответствующую точке сцены float2 projPos = vertex.projPos / vertex.projPos.w; float2 texel = float2(projPos.x + 1.0, -projPos.y + 1.0) / 2.0; // Кордината точки в сцене float4 vertPos = mul(float4(projPos.x, projPos.y, tex2D(zTextureSampler, texel).r, 1.0), invTransform); // Расстояния до внутренней и внешней плоскости клина float distInner = dot(vertPos, vertex.back); float distOuter = dot(vertPos, vertex.front); // Смотрим, чтобы мы находились внутри пенумбрального клина if (dot(vertPos, vertex.left) * dot(vertPos, vertex.right) > 0.0 && distInner * distOuter > 0.0) { float alpha = distInner / (distOuter + distInner); // Для сферического источника освещённость в пенумбральном клине // изменяется не линейно, это небольшое улучшение return 3*pow(alpha, 2.0) - 2*pow(alpha, 3.0); } else return 1.0; }
В пиксельнои шейдере, если точка лежит вне клина мы возвращаем 1.0, то есть точка полностью освещена. Возникает вопрос, не можем ли мы попасть в область полутени другого клина - можем. Для избавления от этой проблемы я установил опции смешивания так, чтобы выбиралось минимальное значение цвета (по умолчанию alpha компоненты всех точек фреймбуффера проинициализированы 1). Это так же позволит решить проблему, в случае если несколько полутеней накладываются, будет выбираться самое меньшее значение освещённости (рис 9).
Теперь рассмотрим алгоритм целиком:
color = destColor + srcColor*destAlpha
Примечания:
*) Есть возможность обойти это ограничение. Нужно разбить пенумбральный клин на 2 части - первая вне объема тени от центра источника света, вторая внутри. В одном случае рассчитывать освещённость точки, в другом, наоборот, - затенённость. При добавлении освещения сначала отрисовать сцену вне объема тени, учитывая освещённость, потом внутри - учитывая затенённость. Реализовывать данный метод не пробовал.
**) Для ATI(AMD) видеокард можно использовать depth-textures.
Вообще говоря, рисовать полностью пенумбральный клин, чтобы все точки полутени были обсчитаны, необязательно. Рассмотрим 2 варианта отображения клина. Первый метод более универсальный, но более "тяжелый" для видеокарты. Второй способ быстрее, но охватывает не все случаи.
Тут надо учесть, что для отрисовки только дальних или ближних граней пенубрального клина установить отсечение задних или передних граней соответственно, недостаточно. Потому что в случае, когда освещённая грань и неосвещённая поменяются местами, надо будет инвертировать порядок вывода вершин. Так что для реализации метода на GPU без геометрических шейдеров, придётся всё-таки рисовать все грани клина.
В теории есть ещё возможность немного улучшить качетсво алгоритма. В исходном алгоритме, для низкодетализированных моделей тень получается "угловатой" (рис 12). Можно слегка подкорректировать переднюю плоскость клина в точке силуэта, так чтобы при интерполяции её между вершинами получалась гладкая поверхность. Надо рассчитать её так, чтобы нормаль к ней и к вершине, лежали в одной плоскости, проходящей через силуэтную вершину (рис 13).
![]() |
![]() |
|
Рис 12. Острый угол тени | Рис 13. Корректировка передней плоскости(поверхности). Вид от источника света. |
Как уже было сказано основные надежды по оптимизации алгоритма возлагаются на геометрические шейдеры.
Основные недостатки алгоритма с точки зрения самой модели построения теней были указаны в третьем пункте. Но, к сожалению, есть ещё несколько проблем:
Был рассмотрен метод получения сглаженных, геометрически правильных теней, полностью реализуемый на GPU. Алгоритм не слишком хорош для реализации с использованием DirectX 9, но будет достаточно быстр с использованием геометрических шеёдеров, чтобы его можно было применять в приложениях реального времени уже сейчас. Подход имеет ряд достоинств (простота, скорость, возможность реализации на графическом процессоре) по сравнению с существующими решениями, но менее физически точен. Алгоритм наиболее удачно подходит для применения к "гладким" или высокодеталлизированным объектам, хорошо смотрится для не слишком больших источников света.
Я старался рассмотреть основные сложности алгоритма, но в статье было опущено много ньюансов, связанных с реализацией. Для более глубокого понимания можно посмотреть код, прилагаемый к статье, но я надеюсь, что тот, кто захочет реализовать метод сделает это самостоятельно, возможно, у вас получится решить многие технические проблемы более изящно.
Исполняемый файл и исходники: gpu_soft_shadows.zip (1.5 Мб)
Статья участвует в конкурсе статей по программированию #2 (2007).