Автор: Дмитрий Трифонов «DikobrAz»

Опубликовано: 11.01.2008

Изменено: 10.01.2008

Постоянная ссылка

Комментарии [3]

Мягкие тени на GPU


В статье описан альтернативный алгоритм получения сглаженных, по-возможности геометрически правильных теней, полностью реализуемый на GPU на базе DirectX 9 (Penumbra Wedges).


Содержание

1. Введение
2. Существующие решения
3. Алгоритм
4. Оптимизация
5. Проблемы и решения
6. Заключение
7. Исходный код

1. Введение

В статье описан альтернативный алгоритм получения сглаженных, по-возможности геометрически правильных теней. Также стояла задача полностью переложить расчёт теней на GPU. Данный метод можно реализовать используя только возможности DirectX 9 (пример и статья рассмотрены на базе этого API), однако рассчитан он для применения с возможностями DirectX 10, в частности с геометрическими шейдерами.

Для понимания статьи желательно быть знакомым с алгоритмом Shadow Volumes (по-моему он неплохо описан здесь). Иметь представление о программировании шейдеров, использовании alphablending, рендеринге в текстуру.

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

2. Существующие решения

Существуют несколько техник построения мягких теней в реальном времени. Самое простое решение - размытие границы тени на какую-то фиксированнцю величину. Есть множество реализаций подобной техники на основе алгоритма Shadow Maps. Но это слишком сильное приближение, такая тень выглядит слишком нереалистично в большистве случаев. Более точный алгоритм Penumbra Wedges (penumbra - полутень, wedge - клин. Одно из описаний алгоритма здесь) В общих чертах он состоит из следующих ступеней для каждого источника света:
  • Инициализация vBuffer'a (visibility buffer - хранит знаяения освещённости точки) 0, 1, т.е. отрисовка в него области умбры(полной тени).
  • Добавление (вычитание) освещённости для области пенумбры (полутени).
  • Добавление освещения от источника света, учитывая значения в vBuffer'е.

  • Рис 1. Пенумбральные клинья для
    прямоугольного источника света

    На деле это очень сложный алгоритм, и далее будут рассмотрены основные проблемы его реализации.


    Рис 2. Soft shadow maps


    Рис 3. Penumbra wedges

    3. Алгоритм

    С точки зрения физики, пренебрегая волновой природой происхождения света, освещённость в точке зависит от того, какую часть источника света из неё мы видим. В случае точечного источника света граница тени будет жёсткой, но в реальности таких источников не существует, поэтому тень имеет размытую границу. Для определения освещённости точки мы будем интерполировать её значение от 0.0 до 1.0 от границы полной тени до границы полутени (рис 5).

    В реальном времени физически правильно вычислять объёмы полной тени и полутени не удастся. А для того, чтобы считать эти объёмы на GPU надо уметь получать границу тени в точке силуэта, независимо от остальной части объекта. В алгоритме Penumbra Wedges границы полутеневых клиньев получаются как касательные к сфере света, проведенные через ребро объекта(рис 5). Такой метод даёт наиболее приближенный к реальности результат, но он не переносим полностью на GPU, потому что для построение замкнутого объема нам надо учитывать взаимное расположение ребёр, то есть нельзя обрабатывать вершины или рёбра объекта независимо (рис 4). Так что первой задачей алгоритма будет построение замкнутого полутеневого объема на графическом процессоре.


    Рис 4. Соседние рёбра определяют пересечение пенумбральных клиньев

    Для каждой вершины силуэта найдем точку на сфере источника света, от которой будем вытягивать полутеневой объем. Самое простое и наиболее приближенное к идеалу решение, которое я нашёл: point = lightPosition - vertexNormal * lightRadius, где vertexNormal - интерполированная нормаль в точке (никаких smooth groups не использовать). На практике это весьма неплохое приближение, несмотря на его простоту. В самом деле, силуетные ребра (как и усреднённые нормали в их вершинах) хоть и не параллельны одной плоскости, но, как правило, не сильно от неё отклоняются. В этом случае нормаль в вершине будет близка по направлению к нормали касательной плоскости к сфере источника света, проходящей через ребро объекта, разделяющего эту вершину (эта плоскость ограничивает наиболее правильный пенумбральный клин - рис 5, приближённое построение - рис 6).


    Рис 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,

    где destColor - значение цвета во фреймбуффере srcColor - рассчитанный цвет освещённости (без ambient составляющей), destAlpha - значение alpha в точке фреймбуффера.

       
    Рис 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).


    Рис 9. Пересечение полутеневых объёмов

    Теперь рассмотрим алгоритм целиком:

    • Получаем текстуру глубин (содержит глубину каждой точки сцены). Для этого можно отрисовать всю сцену в текстуру, с помощью шейдера, возвращающего глубину точки.
    • Рисуем сцену с фоновым освещением.
    • Для каждого источника света
      • Очищаем stencil buffer. Инициализируем единицей alpha компоненты точек фреймбуффера
      • Применяем алгоритм shadow volumes для ограничивания зоны полной тени.
      • Рисуем пенумбральные клинья методом описанным выше, значения освещённости сохраняем в alpha компоненте фреймбуфера.
      • Устанавливаем состояния для alpha-blending'a, чтобы цвет в точке рассчитывался по формуле:
        color = destColor + srcColor*destAlpha
        Рисуем сцену для добавления освещения.

    Примечания:

    *) Есть возможность обойти это ограничение. Нужно разбить пенумбральный клин на 2 части - первая вне объема тени от центра источника света, вторая внутри. В одном случае рассчитывать освещённость точки, в другом, наоборот, - затенённость. При добавлении освещения сначала отрисовать сцену вне объема тени, учитывая освещённость, потом внутри - учитывая затенённость. Реализовывать данный метод не пробовал.


    Рис 10. Разбиение клина на внешний и внутренний

    **) Для ATI(AMD) видеокард можно использовать depth-textures.

    4. Оптимизация

    Вообще говоря, рисовать полностью пенумбральный клин, чтобы все точки полутени были обсчитаны, необязательно. Рассмотрим 2 варианта отображения клина. Первый метод более универсальный, но более "тяжелый" для видеокарты. Второй способ быстрее, но охватывает не все случаи.

    1. Можно отрисовывать тольку ту часть, которая не проходит тест глубины (значени z больше чем в zBuffer'е) и только те грани клина, которые дальше от наблюдателя. Назовем условно метод zFail (это просто название, не алгоритм zFail Stencil Shadow Volumes).
    2. Рисовать только ту часть, которая проходит тест глубины и ближе к наблюдателю. В этом случае, будут проблемы, если наблюдатель находится внутри пенубрального клина. Этот метод назовём zPass.

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


    Рис 11. Части пенумбрального клина для zfail, zpass методов.

    В теории есть ещё возможность немного улучшить качетсво алгоритма. В исходном алгоритме, для низкодетализированных моделей тень получается "угловатой" (рис 12). Можно слегка подкорректировать переднюю плоскость клина в точке силуэта, так чтобы при интерполяции её между вершинами получалась гладкая поверхность. Надо рассчитать её так, чтобы нормаль к ней и к вершине, лежали в одной плоскости, проходящей через силуэтную вершину (рис 13).

       
    Рис 12. Острый угол тени     Рис 13. Корректировка передней плоскости(поверхности). Вид от источника света.

    Как уже было сказано основные надежды по оптимизации алгоритма возлагаются на геометрические шейдеры.

    • Нахождение и создание пенубральных клиньев можно полностью переложить на геометрический шейдер. Не придется копировать лишние вершины и создавать дополнительные примитивы для модели, отбрасывающей тень.
    • На геометрическом шейдере можно также создавать не полный пенубральный клин, а только те части, которые требуются для zFail или zPass метода.
    Ещё пара общих оптимизаций:
    • Где можно использовать zPass алгоритм.
    • Уменьшать детализацию модели(незначительная оптимизация).

    5. Проблемы и решения

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

    • Проблемы точности. Иногда границы тени и полутени идут не точно в стык, также заметны интервалы между клиньями пенумбры. В большинстве случаев подобные проблемы решаются путем небольшого увеличения пенубральных клиньев, использования depth bias и т. д.
    • Проблемы быстродействия (DirectX 9). Всё-таки алгоритм является довольно "тяжелым". Во-первых зона пенумбры бывает довольно большой и клинья имеют большую площадь, что значительно увеличивает требование к скорости заполнения (fillrate). Во-вторых, приходится обрабатывать огромное колличество примитивов для каждой модели, отбрасывающей тень, кроме того приходится копировать лишние вершины для построения пенумбральных клиньев.

    6. Заключение

    Был рассмотрен метод получения сглаженных, геометрически правильных теней, полностью реализуемый на GPU. Алгоритм не слишком хорош для реализации с использованием DirectX 9, но будет достаточно быстр с использованием геометрических шеёдеров, чтобы его можно было применять в приложениях реального времени уже сейчас. Подход имеет ряд достоинств (простота, скорость, возможность реализации на графическом процессоре) по сравнению с существующими решениями, но менее физически точен. Алгоритм наиболее удачно подходит для применения к "гладким" или высокодеталлизированным объектам, хорошо смотрится для не слишком больших источников света.

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


    Ушастый стульчик

    7. Исходный код

    Исполняемый файл и исходники: gpu_soft_shadows.zip (1.5 Мб)

     


     

    Статья участвует в конкурсе статей по программированию #2 (2007).