Totem Engine 4 Blog. Статья 4. Environment. Atmosphere Scattering.
Автор: Олег Владимирович Ферапонтов
Автор: Олег Владимирович Ферапонтов

Начнем с картинки для затравки, так как она отображается на главной.
Тема атмосферного рассеивания света хорошо описана в разных англоязычных источниках (смотри в конце статьи), на нашем языке даже есть статья от fzr125 тут на gamedev.ru, но она про предрассчитанный вариант техники, я же буду вести речь про полный рассчет рассеивания.
Итак, для чего нужна еще одна статья. Во-первых, чтобы я сам не забыл со временем как что делал. Во-вторых, написание и попытка объяснить кому-то усиливает понимание вопроса. Ну а, в-третьих, когда я делал все равно были моменты, до которых приходилось доходить самому.
Статья описывает метод расчета рассеивания света в атмосфере в определенной точке этой атмосферы при нахождении зрителя между внутренним и внешним ее радиусами.
Но начнем мы не с этого.
1. Полусфера неба.
Для начала нам нужно на что-то отобразить небо. Лучший вариант отображать на полусферу, центр которой X и Z (Y – высота) всегда соответсвует X и Z позиции наблюдателя.
Я выбрал на мой взгляд самый простой вариант — взять верхнюю половину от октаэдра ( кто не в курсе http://ru.wikipedia.org/wiki/%D0%9E%D0%BA%D1%82%D0%B0%D1%8D%D0%B4%D1%80 ) и каждый его треугольник снова триангулировать (разбить на несколько треугольников внутри) до нужной густоты сетки, а затем вытянуть все вершины по сферическим координатам.
Я не стал использовать стандартный алгоритм триангуляции, потому что он требует рекурсии, в место этого я вывел некую общую закономерность в конечном результате триангуляции. Смотрим на рисунок ниже.

На нем приведены три треугольника, каждый следующий это триангулированный вариант предыдущего. Как видим сетка постоянно нарастает, но сохраняет порядок. При этом каждый предыдущий треугольник помещается в последующий абсолютно.
Вводим следующее понятие — линия. Это все треугольники, которые умещаются в одну горизонтальную линию, высотой в высоту треугольника (на рисунке синими цифрами). Длина линии — это число нижних треугольников в линии. На рисунке по порядку для нижней линии это 2, 4, 8. Число линий (уровней) вершин всегда на 1 больше, чем длина максимальной линии.
Я не буду строить треугольник отдельно, а потом его растягивать, я сделаю это сразу. Для этого надо понять другие картинки.

На картинке верхней вид сверху на нашу уже растянутую четверть от полусферы, а на картинке нижней вид сбоку. В начале координаты самая высокая точка, по удалению от нее высота в точке уменьшается, но ростет радиус по XZ. Про высоту я говорю как отдельную величину, так как можно было бы приплюснуть сферу в эллипс, но я этого не делаю.
По этой картинке можно сгенерировать позицию вершин и нормаль довольно просто.
Мы генерируем вершины по слоям от нижнего к верхнему (по возрастанию Z). Каждая последующая вершина в слое в сферических координатах смещается на некоторый постоянный угол от начала координат. Этот угол зависит от номера вершины в слое и общем числе вершин.
Alpha = PI/2 / (VertexesCount-1);
Если двигаться по слоям и вершинам в слое, то можно считать два угла отклонения (для X и Z) от начала координат мы можем точно выставить позицию вершины. А зная позицию мы точно можем высчитать нормаль, потому что она всегда направлена в центр сферы. Для генерации вершин используется только один входной параметр - максимальная длина линии треугольников.
//----------------------------------------------------------------------- // создать буфер вершин //----------------------------------------------------------------------- bool TE4RenderSceneExtAtmosphere::CreateVertexBuffer() { std::vector<TE4Point_N_Vertex> vVertexes; LPTE4Point_N_Vertex pVertex = NULL; size_t sLevel = 0, sNumInLevel = 0, i =-1; size_t sCurrentRaduis = 0; float fCurRadiusAlpha = 0.0f, fCurRadiusBetta = 0.0f; D3DXVECTOR3 v3Normal; // проход по уровням for( sLevel = 0; sLevel < m_sGridLevelsCount + 1; sLevel++ ) { // выделяем место для новой строки vVertexes.resize( vVertexes.size( ) + m_sGridLevelsCount - sLevel + 1 ); // проходим по вершинам в уровне for( sNumInLevel = 0; sNumInLevel < m_sGridLevelsCount - sLevel + 1; sNumInLevel++ ) { // новая вершина i++; // получаем pVertex = &vVertexes[i]; // текущий радиус sCurrentRaduis = sNumInLevel + sLevel; // считаем угол смещения в радиусе ( сектор в радианах делим на число вершин ) fCurRadiusAlpha = sCurrentRaduis == 0 ? 0.0f : sLevel * cTE4_fPI_DIV2 / sCurrentRaduis; // угол смещения по высоте fCurRadiusBetta = cTE4_fPI_DIV2 - sCurrentRaduis * cTE4_fPI_DIV2 / m_sGridLevelsCount; // позиция pVertex->X = m_fGridRadius * cosf( fCurRadiusBetta ) * cosf( fCurRadiusAlpha ); pVertex->Y = m_fGridRadius * sinf( fCurRadiusBetta ); pVertex->Z = m_fGridRadius * cosf( fCurRadiusBetta ) * sinf( fCurRadiusAlpha ); // считаем нормаль v3Normal.x = -pVertex->X; v3Normal.y = -pVertex->Y; v3Normal.z = -pVertex->Z; D3DXVec3Normalize( &v3Normal, &v3Normal ); pVertex->nX = v3Normal.x; pVertex->nY = v3Normal.y; pVertex->nZ = v3Normal.z; } } // ... }
Нужно еще связать наши вершины в индексный буфер. Учитывая регулярную сетку мы это сделаем просто — проходим по слоям вершин, высчитываем первую вершину в текущем слое и первую в следующем. Зная нижние и верхние 4 вершины создаем пару треугольников. Двигаемся к следующему делая тоже самое. Исключение составляет последняя вершина — для нее создается только один треугольник.
//-----------------------------------------------------------------------
// создать буфер индексов
//-----------------------------------------------------------------------
bool TE4RenderSceneExtAtmosphere::CreateIndexBuffer()
{
std::vector<UINT> vIndexes;
size_t sLevel = 0, sNumInLevel = 0, i = 0;
size_t sCurLevelBegin = 0, sNextLevelBegin = 0, sCurLevelVertexesCount = 0;
// проход по уровням
for( sLevel = 0; sLevel < m_sGridLevelsCount; sLevel++ )
{
// число вершин в уровне
sCurLevelVertexesCount = m_sGridLevelsCount - sLevel + 1;
// выделяем место для новой строки
vIndexes.resize( vIndexes.size() + ( m_sGridLevelsCount - sLevel - 1) * 6 + 3 );
// первая вершина следующего уровня
sNextLevelBegin = sCurLevelBegin + sCurLevelVertexesCount;
// проходим по вершинам в уровне кроме последней
for( sNumInLevel = 0; sNumInLevel < m_sGridLevelsCount - sLevel; sNumInLevel++ )
{
// 2 грани на 4 точки
vIndexes[i++] = sNextLevelBegin + sNumInLevel;
vIndexes[i++] = sCurLevelBegin + sNumInLevel;
vIndexes[i++] = sCurLevelBegin + sNumInLevel + 1;
if( sNumInLevel != m_sGridLevelsCount - sLevel - 1 )
{
vIndexes[i++] = sCurLevelBegin + sNumInLevel + 1;
vIndexes[i++] = sNextLevelBegin + sNumInLevel + 1;
vIndexes[i++] = sNextLevelBegin + sNumInLevel;
}
}
// заканчиваем
sCurLevelBegin = sNextLevelBegin;
}
// ...
}Восьмая часть сферы готова. Нам нужно таких 4 штуки. Мы просто 4 раза отобразим их (а точнее столько, сколько видим в идеале) или один раз с инстансингом.

2. Рассеивание.
Я стал реализовывать Scattering, потому эта технология позволяет легко менять время суток, просто изменяя позицию солнца. Кроме того отпадает необходимость в использовании текстур окружения, которые необходим рисовать художникам для каждой игровой сцены.
Я читал про Precomputed Scattering, но поначалу не понял принцип генерации текстур, поэтому начал с полного расчета всего алгоритма в шейдере. После реализации меня вполне устроил результат и производительность, поэтому я на нем и остановился.
Итак, как я уже говорил, теории по этой части в интернете полно, я ниже приведу пару ссылок. Описывать ее полноценно я не буду, потому что просто не разбираюсь достаточно в теме. Я опишу в большей степени как правильно настроить шейдер. Для этого смотрим картинку.

Нужно сразу показать вершинный шейдер, именно в нем по позиции точки мы расчитываем цвет неба, в пиксельном шейдере мы потом из трех цветов вершин собираем цвет в текущем пикселе.
//--------------------------------------------------------------------------------------
// Global variables
//--------------------------------------------------------------------------------------
// matrixes
float4x4 g_MatrixViewProj;
float4x4 g_MatrixWorld;
// light
float3 g_LightDir;
float4 g_LightDiffuse;
// camera
float3 g_EyePos;
// atmospheric scattering
// planet inner radius, planet outer radius, height min, max
float4 g_RadiusInOutHeightMinMax;
// scattering coefficients : KrESun, KmESun, Kr4PI, Km4PI
float4 g_ScatterFactors;
// optical depth : scale, scaleDepth, ScaleOverScaleDepth, Iterations
float4 g_ScaleIter;
// gravity and Exposure : g, g ^ 2, exposure,
float4 g_Gravity;
// wave length
float4 g_InvWaveLength;
//--------------------------------------------------------------------------------------
// Structures
//--------------------------------------------------------------------------------------
struct vertexInput
{
float3 position : POSITION;
float3 normal : NORMAL;
};
struct vertexOutput
{
float4 hPosition : POSITION;
float4 ColorRayleigh : TEXCOORD0;
float4 ColorMie : TEXCOORD1;
float3 Direction : TEXCOORD2;
};
//--------------------------------------------------------------------------------------
// Vertex support functions
//--------------------------------------------------------------------------------------
float RayIntersection( float3 f3Pos, float3 f3Dir, float fOuterRadius )
{
float A = dot(f3Dir, f3Dir);
float B = 2.0f * dot(f3Pos, f3Dir);
float C = dot( f3Pos, f3Pos ) - fOuterRadius * fOuterRadius;
float t0 = (-B + sqrt(B*B - 4.0f * A * C) ) / 2.0f * A;
float t1 = (-B - sqrt(B*B - 4.0f * A * C) ) / 2.0f * A;
return t0 >= 0.0f ? t0 : t1;
}
float scale(float cos)
{
float x = 1.0f - cos;
return g_ScaleIter.y * exp(-0.00287f + x*(0.459f + x*(3.83f + x*(-6.80f + x*5.25f))));
}
//--------------------------------------------------------------------------------------
// Vertex shader
//--------------------------------------------------------------------------------------
vertexOutput VS_Scattering( vertexInput IN )
{
vertexOutput OUT;
// world space vertex position without camera position
float3 f3WorldPos = mul( float4( IN.position, 1.0f ), g_MatrixWorld );
// camera always in atmosphere
// camera always in center of sphere, only height change
// position camera in world space
float3 v3Camera = float3( 0.0f, g_EyePos.y, 0.0f );
// ray from camera to current vertex (sky pos) in begin world space
float3 v3Ray = normalize(f3WorldPos - v3Camera);
// camera height part between Min and Max
float fCameraHeight = (g_EyePos.y - g_RadiusInOutHeightMinMax.z) / (g_RadiusInOutHeightMinMax.w - g_RadiusInOutHeightMinMax.z);
// camera height in planet space
fCameraHeight = g_RadiusInOutHeightMinMax.x + fCameraHeight * (g_RadiusInOutHeightMinMax.y - g_RadiusInOutHeightMinMax.x);
// star position in planet space
float3 v3Start = float3( 0.0f, fCameraHeight, 0.0f );
float fDistance = RayIntersection( v3Start, v3Ray, g_RadiusInOutHeightMinMax.y );
// star angle
float fStartAngle = dot(v3Ray, v3Start) / fCameraHeight;
// depth = exp(ScaleOverScaleDepth * ( inner radius - eye height )
float fStartDepth = exp(g_ScaleIter.z * ( g_RadiusInOutHeightMinMax.x - fCameraHeight ) );
float fStartOffset = fStartDepth * scale(fStartAngle);
// Initialize the scattering loop variables
float fSampleLength = fDistance / g_ScaleIter.w;
float fScaledLength = fSampleLength * g_ScaleIter.x;
float3 v3SampleRay = v3Ray * fSampleLength;
float3 v3SamplePoint = v3Start + v3SampleRay * 0.5;
// Now loop through the sample points
float3 v3FrontColor = float3(0.0, 0.0, 0.0);
for(int i=0; i< (int)g_ScaleIter.w; i++)
{
float fHeight = length(v3SamplePoint);
// depth = exp(ScaleOverScaleDepth * ( inner radius - cur height )
float fDepth = exp(g_ScaleIter.z * (g_RadiusInOutHeightMinMax.x - fHeight) );
float fLightAngle = dot(-g_LightDir.xyz, v3SamplePoint) / fHeight;
float fCameraAngle = dot(v3Ray, v3SamplePoint) / fHeight;
float fScatter = (fStartOffset + fDepth * (scale(fLightAngle) - scale(fCameraAngle)));
float3 v3Attenuate = exp(-fScatter * (g_InvWaveLength.xyz * g_ScatterFactors.z + g_ScatterFactors.w));
v3FrontColor += v3Attenuate * (fDepth * fScaledLength);
v3SamplePoint += v3SampleRay;
}
float4x4 mWorldAlignCamera = g_MatrixWorld;
mWorldAlignCamera._41_43 += g_EyePos.xz;
// view space pos
OUT.hPosition = mul( float4( IN.position, 1.0f ), mul( mWorldAlignCamera, g_MatrixViewProj ) );
// Finally, scale the Mie and Rayleigh colors
OUT.ColorRayleigh.xyz = v3FrontColor * (g_InvWaveLength.xyz * g_ScatterFactors.x);
OUT.ColorRayleigh.w = 1.0f;
OUT.ColorMie.xyz = v3FrontColor * g_ScatterFactors.y;
OUT.ColorMie.w = 1.0f;
OUT.Direction = v3Camera - f3WorldPos;
return OUT;
}Если просто объяснять принцип, то суть в том, что мы работаем с некоторой планетой. Находясь в ее атмосфере, мы располагаемся между двумя ее радиусами — внутренний — это радиус от центра планеты до поверхности. Внешний — от центра планеты до края атмосферы. Для земли это две цифры: 6356.75 и 6456.55 км. Не имеет значение в какой величине они заданы, главное, что они используются для просчета масштаба в переменной g_ScatterFactors.
Игровая сцена не может располагаться так далеко от начала координат как виртуальные физические радиусы, поэтому я задаю крайние положения камеры в сцене как минимальная и максимальная высота, которая соответсвует физическим радиусам в нашей сцене.
Когда мы смотрим на определенную точку нашей полусферы неба, мы в физическом смысле смотрим на определенную точку атмосферы, при чем ближе к горизону мы смотрим, тем более отдаленная это точка атмосферы (смотри 3 источник).
Вектор от этой точки к позиции камеры указывает какие лучи в данный момент со стороны неба мы видим, при чем это могут быть не только прямые лучи, но и пришедшие из другой части неба. Они несколько раз преломились и в этоге легли на курс текущего вектора. За прямые лучи отвечает Mie Scattering, a за рассеяные Reileight Scattering.
Чем больше таких луч мы учтем на данном направлении взгляда, тем качественнее у нас будет картинка. Просчитываем мы эти лучи путем разбиения расстояния (реального расстояния от камеры до внешнего радиуса) на отрезки итерации. Каждую итерацию мы просчитываем оптическую глубину в точке итерации, по ней и коэффициентам рассеивания высчитываем цвет.
Результат суммируем и отправляем на обработку в пиксельный шейдер. Подробностей математики, как я уже говорил я не знаю, честно тырил код.
Отображения результата в пиксельном шейдере
//--------------------------------------------------------------------------------------
// Pixel support functions
//--------------------------------------------------------------------------------------
float getRayleighPhase( float fCos2)
{
return 0.75 * (1.0 + fCos2);
}
float getMiePhase( float fCos, float fCos2 )
{
float3 t3;
t3.x = 1.5f * ( (1.0f - g_Gravity.y) / (2.0f + g_Gravity.y) );
t3.y = 1.0f + g_Gravity.y;
t3.z = 2.0f * g_Gravity.x;
return t3.x * (1.0f + fCos2) / pow(t3.y - t3.z * fCos, 1.5f);
}
//--------------------------------------------------------------------------------------
// Pixel shader
//--------------------------------------------------------------------------------------
float4 PS_Scattering( vertexOutput IN ) : COLOR
{
float4 Color = float4( 1.0f, 1.0f, 1.0f, 1.0f );
float2 fCos;
fCos.x = dot(-g_LightDir.xyz, IN.Direction ) / length(IN.Direction);
fCos.y = fCos.x * fCos.x;
// Color.rgb = getRayleighPhase(fCos2) * IN.ColorRayleigh.rgb + getMiePhase(fCos, fCos2) * IN.ColorMie.rgb;
// getRayleighPhase - HDR
// getMiePhase - sun disk
Color.rgb = getRayleighPhase(fCos.y) * IN.ColorRayleigh.rgb + getMiePhase(fCos.x, fCos.y) * IN.ColorMie.rgb;
Color.a = 1.0f;
return Color;
}В заключение я приведу параметры шейдера, которые я выставляю в материале, потому что при неправильных параметрах кажется, что неправильный у нас просчет.
<!-- // planet inner radius, planet outer radius, height min, max
-->
<asRadius x="6356.75" y="6456.55" z="-100.0" w="2000.0" />
<!-- // Scale = 1 / (fOuterRadius - fInnerRadius)
// ScaleDepth = (fOuterRadius - fInnerRadius)
// ScaleOverScaleDepth = Scale / ScaleDepth
// Iterations
-->
<!-- влияет на видимость солнца (оптическую толщину среды) -->
<asScaleIter x="0.01" y="0.25" z="0.04" w="10.0" />
<!-- // RayMult = 0.002f;
// MieMult = 0.0015f;
// KrESun = fRayMult * SunIntensity;
// KmESun = fMieMult * SunIntensity;
// kr4PI = fRayMult * 4.0f * PI;
// km4PI = fMieMult * 4.0f * PI;
// 0.01
// 0.02
-->
<asFactors x="0.04" y="0.03" z="0.025" w="0.0188" />
<!-- // g
// g*g
-->
<asGravity x="-0.991" y="0.982" z="0.0" w="0.0" />
<!-- // r = 650 nm
// g = 570 nm
// b = 475 nm
// InvWaveLength = 1.0 / powf( x, 4.0f );
-->
<asInvWaveLength x="5.602" y="9.473" z="19.644" w="0.0" />
Картинку результата вы уже видели в начале статьи.
3. Источники.
1. GPU Gems 2. Chapter 16. Accurate Atmospheric Scattering.
http://http.developer.nvidia.com/GPUGems2/gpugems2_chapter16.html
2. Atmosphere Scattering from Alex Urbano
http://xnacommunity.codeplex.com/wikipage?title=Componente%20Scatter
3. Modeling Skylight and Aerial Perspective
http://developer.amd.com/media/gpu_assets/PreethamSig2003CourseNotes.pdf
#render, #Totem 4 Engine, #Игровые движки
8 апреля 2012