18 декабря 2011 в 19:10

XNA Draw: пишем отложенное освещение на три источника с помощью шейдера tutorial

Привет хабравчанам!

Давненько я не писал на хабр: учеба, сессия надвигается, сами понимаете. Сегодня я попробую рассказать, как в XNA реализовать Deferred Lighting (отложенное освещение) с использованием normal mapping на три источника света, при этом использовать мы будем Reach-профиль и Shader model 2.0.
Напомню, раньше мы уже затрагивали тему шейдеров: тут. Остальное под катом, видео и демо там же.







В этой части:
  • Что такое Deferred Lighting
  • Что такое Normal mapping
  • Реализация, подключение шейдера
  • Реализация пиксельного шейдера


Теория


Почему именно три источника света одновременно? У шейдеров есть ограничения: в Shader model 2.0, может быть не более 64 арифметических операций за один проход шейдера. Для использования источников света более трех в одном проходе — необходима Shader model 3.0, она поддерживает 512 арифметических операций, примерно ~30 источников за проход. Но для модели 3.0 нужен hidef-профиль, а для него требуется DirectX10-suitable видеокарта. Поэтому мы ограничимся тремя источниками (да и более того, что мешает рисовать источники в несколько проходов? Поэтому три источника — ограничение всего-лишь шейдера, в статье мы будем рассматривать только один проход).

Кстати, сравнение шейдерных моделей:



Подробнее можно посмотреть тут.

С ограничениями пиксельных шейдеров второй модели вроде разобрались.

Теперь посмотрим на еще одно странное слово: "deferred lighting (отложенное освещение)".

Deferred lighting

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

Если очень просто объяснить, то считаем все пиксели от источника света и тут же пишем его во фреймбуффер, в нашем случае будет четыре суммы в фреймбуффер (амбиент + результат трех исчтоников)

Очень кратко, что такое deferred lighting мы разобрались, осталось понять, что за normal mapping.

Normal mapping

Так же известная в простонародье — карта нормалей текстуры, для еще соображающих: нормаль — это перпендикуляр к поверхности.

Давайте посмотрим, что можно с помощью её выиграть? Приведу пример освещения с использованием карты нормалей и без. А потом объясню, как это работает.

Вот так наша сцена выглядит без использования освещения:


Так наша сцена выглядит с использованием освещения (без использования Normal Mapping):


Так наша сцена выглядит с использованием освещения (c использованием Normal Mapping):


Как видите, результат на лицо, сцена с Normal Mapping является как бы «объемной» и более реалистично смотрится, особенно в динамике (при двивжении источников света).
Давайте взглянем на сами текстуры.

Сама текстура (Color map):



Текстура нормалей (Normal Map):



На второй текстуре сразу и не поймешь, что к чему, помните статью про displacement-шейдер? Там мы передавали через R,G каналы информацию о том, как гнуть пиксель, так и тут, мы передаем с помощью R, G, B информацию о нормалях, т.е. R = X, G = Y, B = Z. А уже в шейдере мы оперируем X,Y,Z координатами при расчете освещения, просто, да?

И если читатель опять ничего не понял, даже после того, как увидел первый раз слово нормаль и получил объяснение к нему, то с помощью карты нормалей — текстура получает 3D представление для освещения.

И да, про создание карты нормалей, нарисовать их практически невозможно, они либо снимаются с высокополигональной модели, либо генерируются из самой текстуры (например, плагином для фотошопа).

С теорией вроде чуть-чуть разобрались, давайте попробуем это все реализовать кодом.

Практика


Тут я уже буду приводить куски кода с комментариями.
Создаем представление источника света, класс LightEmmiter:
public class LightEmmiter
{
	// позиция источника света по трем координатам: X, Y, Z
        public Vector3 position;
       // цвет источника
        public Vector3 color;
       // корректор источника (иначе говоря — яркость)
        public float corrector;
	// радиус источника
        public float radius;

	// отдаем параметрам шейдеру информацию об источнике
        internal void UpdateEffect(EffectParameter effectParameter)
        {
            effectParameter.StructureMembers["position"].SetValue(position);
            effectParameter.StructureMembers["color"].SetValue(color * corrector);
            effectParameter.StructureMembers["invRadius"].SetValue(1f / radius);
	}
}


Теперь работаем с Game1 (главный класс):
Создаем переменные:
Texture2D texture; // Color карта (сама текстура)
Texture2D textureNormal; // Normal карта

private Effect deferred; // шейдер
SpriteFont spriteFont; // шрифт, чтобы выводить Debug-информацию
EffectParameter lightParameter; // буффер для параметров шейдера

private float lightRadius; // для изменения радиуса источников
private float lightZ; // для изменения Z-позиции источников
private float lightC; // для изменения яркости источников

LightEmmiter[] lights = new LightEmmiter[2]; // массив источников


Теперь это все инициализируем в методе LoadContent:
texture = Content.Load<Texture2D>("test1"); // загружаем текстуру
textureNormal = Content.Load<Texture2D>("test1_map"); // загружаем текстуру
           
deferred = Content.Load<Effect>("deferred"); // загружаем шейдер
spriteFont = Content.Load<SpriteFont>("default"); // загружаем шрифт

lightRadius = 320f; // начальное значение радиуса
lightZ = 50f; // начальное значение Z-позиции
lightC = 1f; // начальное значение яркости

lights[0] = new LightEmmiter();
lights[0].position = new Vector3(20, 30, 0);
lights[0].radius = lightRadius;
lights[0].corrector = lightC;
lights[0].color = new Vector3(1f, 0f, 0f);

lights[1] = new LightEmmiter();
lights[1].position = new Vector3(20, 30, 0);
lights[1].radius = lightRadius;
lights[1].corrector = lightC;
lights[1].color = new Vector3(1f, 1f, 1f);


Обновляем сам Draw:
// чистим экран и задаем RenderTarget — экран
GraphicsDevice.Clear(Color.LightSkyBlue);
GraphicsDevice.SetRenderTarget(null);

// обновляем информацию источникам света
            lights[0].position = new Vector3(Mouse.GetState().X, Mouse.GetState().Y, lightZ);
            lights[0].radius = lightRadius;
            lights[0].corrector = lightC;

            lights[1].position = new Vector3(800 - Mouse.GetState().X, 480 - Mouse.GetState().Y, lightZ);
            lights[1].radius = lightRadius;
            lights[1].corrector = lightC;

// выбираем технологию
            deferred.CurrentTechnique = deferred.Techniques["Deferred"];
           
// задаем параметры шейдеру
            deferred.Parameters["screenWidth"].SetValue(GraphicsDevice.Viewport.Width);
            deferred.Parameters["screenHeight"].SetValue(GraphicsDevice.Viewport.Height);
            deferred.Parameters["ambientColor"].SetValue(new Vector3(1, 1, 1) * 0.1f);
            deferred.Parameters["numberOfLights"].SetValue(2);

            deferred.Parameters["normaltexture"].SetValue(textureNormal);

// получаем ссылку на lights-массив в шейдере
            lightParameter = deferred.Parameters["lights"];

// задаем lights-массиву в шейдере значения
            for (int i = 0; i < lights.Length; i++)
            {
                LightEmmiter l = lights[i];
                l.UpdateEffect(lightParameter.Elements[i]);
            }
// делаем проходы (у нас он один)
            foreach (EffectPass pass in deferred.CurrentTechnique.Passes)
            {
                spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.Opaque, SamplerState.LinearClamp, DepthStencilState.None, RasterizerState.CullCounterClockwise);

                pass.Apply();
                spriteBatch.Draw(texture, new Rectangle(0, 0, 800, 480), Color.White);
                spriteBatch.End();
            }

// рисуем Debug-информацию

            spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.LinearClamp, DepthStencilState.None, RasterizerState.CullCounterClockwise, null);

            //800 480
            spriteBatch.DrawString(spriteFont, "lightRadius: " + lightRadius + "\nlightCorrector: " + lightC + "\nligthZ: " + lightZ, new Vector2(10, 10), Color.LightYellow);
            spriteBatch.End();


Все, осталось самое важное, создаем шейдер deferred.fx, листинг:
// структура источника света, для удобства
struct Light
{
	float3 position;
	float3 color;
	float invRadius;
};

// карта нормалей
texture normaltexture;

// кол-во активных источников и массив из 3-ех источников
int numberOfLights;
Light lights[3];

// цвет эмбиента
float3 ambientColor;

// ширина, высота экрана
float screenWidth;
float screenHeight;

// сэмплер Color-карты (текстура)
sampler ColorMap : register(s0);

// сэмплер Normal-карты
sampler NormalMap : samplerState
{
	Texture = normaltexture;
	MinFilter = Linear;
	MagFilter = Linear;
	AddressU = Clamp;
	AddressV = Clamp;
}; 

// функция подсчета еденичного исчтоника света
float3 CalculateLight(Light light, float3 normal,
					float3 pixelPosition)
{
	// направление
	float3 direction = light.position - pixelPosition;
	float atten = length(direction);

	direction /= atten;

       // скалярное произведение нормали и направления
	float amount = max(dot(normal, direction), 0);

	atten *= light.invRadius;

	// делаем так, чтобы modifer был всегда больше нуля или равен ему, дабы при далеких источниках область не становилась темной
	float modifer = max((1 - atten), 0);

       // возращаем результирующий цвет пикселя
	return light.color * modifer * amount;
}

float4 DeferredNormalPS(float2 texCoords : TEXCOORD0) : COLOR
{
	float4 base = tex2D(ColorMap, texCoords); // получаем цвет из color-карты по координатам texCoords
	float3 normal = normalize(tex2D(NormalMap, texCoords) * 2.0f - 1.0f); // получаем значения X,Y,Z из нормальной-карты по координатам texCoords и заодно приводим к виду: -1, 1.

       // преобразуем координаты пикселя 
	float3 pixelPosition = float3(screenWidth * texCoords.x,
							screenHeight * texCoords.y,0);

	// задаем буффер-пикселя 
	float3 finalColor = 0;

	for (int i=0;i<numberOfLights;i++)
	{
		// подсчитываем все источники света и записываем их в буффер
		finalColor += CalculateLight(lights[i], normal, pixelPosition);
	}
	
	// возращаем результат шейдера, эмбиент * результирующий цвет пикселя от света, делаем multiply с картой цвета и отдаем с альфой карты цвета
	return float4((ambientColor + finalColor) * base.rgb, base.a);
}

technique Deferred
{
    pass Pass0
    {
        PixelShader = compile ps_2_0 DeferredNormalPS();
    }
}


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

Видео-демонстрация освещения:


Ссылка на демо (exe): тут.
Ссылка на исходники (проект, VS2010): тут.

Так же, особую благодарность хотелось бы выразить lazychaser, за помощь в разборе материала и навод на чистый девственный путь.

Удачи ;)
+58
7641
180
ForhaxeD 68,1

Комментарии (23)

–2
DROS, #
E7500, 4Gb DDR2, ATI Radeon 4850 512Mb, Win7 x64 Ult
не запустилась демка
+2
ForhaxeD, #
Нужна прослойка XNA и .net 4.0
0
AlexeiKozlov, #
Первый раз увидел саму суть создания карты нормалей.
Еще в таком же изложении про другие виды карт для освещения, можешь написать?
+4
ForhaxeD, #
У меня не так много времени, но если оно у меня будет, то я обязательно напишу еще ряд статей.
0
SoldovskijBB, #
Спасибо!
В целом графика интересна, в частности на xna, ибо проще чем Dx, но то сил, то времени нету, что бы сесть и разобраться, хотя бы ради интереса, а благодаря таким вот статьям немножко познаю область.

Кстати, еще было бы неплохо написать про bmp карту, глубина цвета в которой потом превращается в высоту ландшафта, если такое есть на xna — не знаю :(
И про постпроцессинги тоже интересно!
0
ForhaxeD, #
Постпроцессинг я описывал в прошлой статье :)
0
lazychaser, #
Эта карта называется картой высот. В ней белые области означают возвышенность. Т.е. по сути это карта задает высоты в пределах от 0 до 255. Сэмплируем пиксель, применяем масштаб и на выходе получаем высоту для точки ландшафта. Относительно сложным здесь является как раз сэмплирование. Тут надо масштабировать карту под размеры ландшафта (интерполяция). Это все относится больше к обработке изображений.
0
lazychaser, #
Да тут не много вариантов, добавляется еще specular map обычно. Черно-белая текстура (обычно низкого разрешения, а как вариант — хранить в альфа-канале normal map), белые области которой говорят о том, что поверхность хорошо отражает свет, а черные — наоборот. Так можно определить где на текстуре металлические части, которые хорошо отражают свет, а где более матовые.
0
satyr, #
Не совсем так — спекуляр управляется двумя параметрами — яркость блика и размер блика.
На матовой хорошо отражающей поверхности будет яркий блик большого размера, на темном металле — блик будет тусклый и небольшой. Но на светлом металле блик будет небольшой, но яркий.

Поэтому либо нужны два вспомогательных канала для спекуляра (если мы используем deferred shading с g-буфером из трех текстур — можно использовать альфу диффуза и альфу нормалей), либо, если у нас нет двух «лишних» каналов — строим индекс материалов и храним в свободном канале число, по которому шейдер сможет понять, какой набор параметров использовать. Например, в движке X-Ray (который в S.T.A.L.K.E.R.'е) использовался второй подход.
+1
SoldovskijBB, #
Интересно очень, а можно по подробней про индекс материала? Как оно реализуется в общем виде?
+2
lazychaser, #
есть texture lookup, т.е. текстура с определенно подобранным форматом. Я их точно все не помню, но здесь, к примеру, будет достаточно RG. В R будет храниться яркость бликов, а в G — экспонента. Индекс материала — это просто текстурные координаты. С помощью интерполирования можно добиться разных вариантов. Кажется в сталкере именно так и было сделано. Вот, кстати, статья: http.developer.nvidia.com/GPUGems2/gpugems2_chapter09.html
+1
SoldovskijBB, #
Спасибо, жаль плюсануть не могу ))
+20
satyr, #
Это все, разумеется, интересно и познавательно, но описанная техника не имеет никакого отношения к deferred lighting :)

Суть всех deferred-техник в разделении отрисовки на отрисовку вначале только данных геометрии, а затем — наложении освещения на отрисованную геометрию.

Поясню поподробнее.
1. На первом этапе мы отрисовываем в отдельные текстуры информацию о нормалях в каждой точке экрана и о глубине в каждой точке экрана.
Мы получаем карту глубины:
image
и карту нормалей:
image
2. На втором этапе мы, пользуясь данными из этих текстур, восстанавливаем положение точки пространства в каждом пикселе экрана, а также нормаль. Зная нормаль и координаты точки в мировом пространстве — не составляет труда посчитать освещенность этой точки. Одновременно с этим мы можем наложить теневую карту, если хотим стоить тени. Таким образом, пройдясь по всем источникам света, мы получаем карту освещения:
image
3. Мы объединяем карту освещения с неосвещенной геометрией:
image
И в итоге получаем готовую картинку:
image

Чем хороша такая техника?
Тем, что освещение в каждом пикселе считается единожды. Вся невидимая геометрия отсекается на этапе построения буфера геометрии. Если бы мы использовали обычный forward lighting — пришлось бы для каждого источника света заново отрисовывать каждый объект. Если источников света много — видеокарточка просто захлебнется.
Поэтому, используя deferred-технику, можно отрисовывать в каждом кадре дикое число источников света (тысячи) с человеческой производительностью. А столько источников света позволяют получить сложные визуальные эффекты — как пример, таким образом можно реализовать непрямое освещение.

Чем же плоха такая техника?
1. Повышаются требования к пропускной способности видеопамяти, т.к. нам теперь приходится, в нагрузку к всему прочему, еще и текстуры буфера геометрии, а они достаточно большие, т.к. по размеру равны размеру экрана.
2. Как несложно понять — deferred-техники фактически вычисляют освещение некой сплошной поверхности. Т.е. если у нас некоторые объекты полупрозрачны — мы не сможем их отрисовать, потому что нам потребуется считать в одном и том же пикселе сразу две освещенности: для полупрозрачного объекта, и для того объекта, который находится за ним. А если у нас за одним полупрозрачным объектом находится еще один полупрозрачный… Этот недостаток обходится, но…
3. В реальности карты нормалей и карты глубины не хватает — нужна еще карта материалов. Дело в том, что на соседних пикселях могут находится объекты с совершенно разными материалами — один, к примеру, блестящий металлический, а другой — матовый пластиковый. И чтобы мы могли корректно вычислить освещенность — нам придется хранить дополнительную карту, считывать в шейдере ее значение и в зависимости от него использовать разные формулы.

Приведу примеры игр с deferred shading и deferred lighting: S.T.A.L.K.E.R. (только в режиме динамического освещения), Crysis 2 (в первом Crysis освещение было прямым), Метро 2033. Список, разумеется, неполный :)

А в данной статье, по факту, рассматривается обычный forward shading с простейшей оптимизацией — просчет более одного источника света за проход (кстати говоря, движок Crysis использовал именно такую технику).

Автору: статья-то хорошая, но озаглавлена некорректно :) Ждем новой статьи, тем более что поднятая тема весьма любопытна для многих, я уверен :)
0
ForhaxeD, #
Спасибо за столь развернутый комментарий!
+1
lazychaser, #
Все правильно, но на самом деле все тоже самое. Только здесь геометрия плоская, а принцип такой же. Рисуем все спрайты в две текстуры (на самом деле можно воспользоваться одной). Т.к. геометрия плоская, то нам не нужна глубина пикселей. В самом примере рисуется какая-то конкретная текстура, но она легко заменяется на то, что получается в первом проходе, когда рисуется сцена и формируется g-buffer.
+1
satyr, #
В том-то и дело. Да, нашу плоскую геометрию можно считать какбы g-буфером, но построение g-буфера — это составная часть deferred-техники. А в статье она не описывается.

А раз g-буфер не строится — то уже не получится описать восстановление мировых координат по g-буферу, а это весьма любопытный момент.

Т.е. у нас тут получается строго 2D-ситуация. Неинтересно же, 3D гораздо веселее :)
0
lazychaser, #
согласен, 3д намного интереснее :)
0
Agent_Smith, #
а как собственно обходится рендеринг полупрозрачных поверхностей?
0
lazychaser, #
Рисуется поверх непрозрачной, используя один и тот же depth buffer :) траву вообще можно рисовать с альфа-тестом
0
tym32167, #
ссылки на демки с ваших предыдущих статей не рабочие. Везде ERROR 404 — Not Found! А так хотелось поглядеть, эх.
0
ForhaxeD, #
На исходники тоже ссылки битые, сервер падал сильно и на руках у меня этого всего — увы, нет Но я попробую что-нибудь придумать.
0
DartRaven, #
Тем, кто еще не видел, советую поглядеть на игрушку NormalTanks. Там как раз-таки такое же 2D с освещением и парой трюков, но выглядит более чем приятно.
0
lorc, #
А почему на карте нормалей текстуры не обозначены дырки в кирпичах? Я почему-то ожидал увидеть имено их, а не границы самих кирпичей.

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