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

Подробнее можно посмотреть тут.
С ограничениями пиксельных шейдеров второй модели вроде разобрались.
Теперь посмотрим на еще одно странное слово: "deferred lighting (отложенное освещение)".
Главным отличием отложенного освещения и затенения от стандартных методов освещения является то, что эти методы немедленно записывают результат работы шейдера во фреймбуфер цвета.
Если очень просто объяснить, то считаем все пиксели от источника света и тут же пишем его во фреймбуффер, в нашем случае будет четыре суммы в фреймбуффер (амбиент + результат трех исчтоников)
Очень кратко, что такое deferred lighting мы разобрались, осталось понять, что за 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:
Теперь работаем с Game1 (главный класс):
Создаем переменные:
Теперь это все инициализируем в методе LoadContent:
Обновляем сам Draw:
Все, осталось самое важное, создаем шейдер deferred.fx, листинг:
Вот и все, в этой статье — я не буду рассказывать, как реализовать бесконечное кол-во источников света через множественные проходы шейдеров, это можно сделать и самому ;)
Видео-демонстрация освещения:
Ссылка на демо (exe): тут.
Ссылка на исходники (проект, VS2010): тут.
Так же, особую благодарность хотелось бы выразить lazychaser, за помощь в разборе материала и навод на чистый девственный путь.
Удачи ;)

Напомню, раньше мы уже затрагивали тему шейдеров: тут. Остальное под катом, видео и демо там же.
В этой части:
- Что такое 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, за помощь в разборе материала и навод на чистый девственный путь.
Удачи ;)