Размытие (Motion Blur) - очень популярная техника для быстрых 3D игр, чья идея добавить размытия движущимся объектам. Это повышает чувства реализма у игрока. Размытие может быть выполнено несколькими способами. Возможна камера для размытия, которая основывается на движении камеры, а возможно размытие, основанное на объекте. В этом уроке мы рассмотрим одну из реализаций.
Идея размытия в том, что мы можем вычислить вектор движения (Motion Vector) для каждого рендуемуего пикселя между 2 кадрами. Взяв среднее значение вдоль этого вектора из текущего буфера цвета мы получим пиксели, которые представляют движение соответствующих объектов. Вот и все. Давайте углубимся в детали. Далее приведено краткое описание требуемых шагов, после которых мы перейдем к самому коду.
В основе этого урока лежит склетная анимация (#38). Мы рассмотрим изменения и покажем, что нужно добавить для получения размытия.
tutorial41.cpp:175
virtual void RenderSceneCB()
{
CalcFPS();
m_pGameCamera->OnRender();
RenderPass();
MotionBlurPass();
RenderFPS();
glutSwapBuffers();
}
Это главная функция рендера, она крайне проста. У нас имеется проход рендера для всех объектов сцены и проход постобработки для размытия.
tutorial41.cpp:190
void RenderPass()
{
m_intermediateBuffer.BindForWriting();
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
m_pSkinningTech->Enable();
vector<matrix4f> Transforms;
float RunningTime = (float)((double)GetCurrentTimeMillis() - (double)m_startTime) / 1000.0f;
m_mesh.BoneTransform(RunningTime, Transforms);
for (uint i = 0 ; i < Transforms.size() ; i++) {
m_pSkinningTech->SetBoneTransform(i, Transforms[i]);
m_pSkinningTech->SetPrevBoneTransform(i, m_prevTransforms[i]);
}
m_pSkinningTech->SetEyeWorldPos(m_pGameCamera->GetPos());
m_pipeline.SetCamera(m_pGameCamera->GetPos(),
m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
m_pipeline.SetPerspectiveProj(m_persProjInfo);
m_pipeline.Scale(0.1f, 0.1f, 0.1f);
Vector3f Pos(m_position);
m_pipeline.WorldPos(Pos);
m_pipeline.Rotate(270.0f, 180.0f, 0.0f);
m_pSkinningTech->SetWVP(m_pipeline.GetWVPTrans());
m_pSkinningTech->SetWorldMatrix(m_pipeline.GetWorldTrans());
m_mesh.Render();
m_prevTransforms = Transforms;
}
Это наш проход рендера. Он почти такой же, как и в уроке Скелетной Анимации. Промежуточный буфер (intermediate) - простой класс, который хранит буферы цвета, глубины и вектора движения в едином буфере кадра. Мы уже сталкивались с этим в уроках по deferred rendering (#35-#37), поэтому на нем останавливаться не будем. За подробностями в исходный код. Идея в рендере в FBO, а не прямо на экран. В проходе размытия мы будем считывать из промежуточного буфера.
Кроме этого, мы можете увидить. что мы добавили свойство класса в класс 'Tutorial41', которое хранит вектор преобразований костей из предыдущего кадра. Мы поставляем его в метод скининга с текущими преобразованиями костей. Мы увидим, как он используется в коде GLSL.
tutorial41.cpp:227
void MotionBlurPass()
{
m_intermediateBuffer.BindForReading();
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);
m_pMotionBlurTech->Enable();
m_quad.Render();
}
В проходе размытия мы привязываем промежуточный буфер для чтения (т.е. рендер будет происходить на экран) и рендерим прямоугольник на весь экран. Каждый пиксель экрана будет обработан 1 раз и в этот момент и будет вычислен эффект размытия.
skinning.glsl
struct VSInput
{
vec3 Position;
vec2 TexCoord;
vec3 Normal;
ivec4 BoneIDs;
vec4 Weights;
};
interface VSOutput
{
vec2 TexCoord;
vec3 Normal;
vec3 WorldPos;
vec4 ClipSpacePos;
vec4 PrevClipSpacePos;
};
const int MAX_BONES = 100;
uniform mat4 gWVP;
uniform mat4 gWorld;
uniform mat4 gBones[MAX_BONES];
uniform mat4 gPrevBones[MAX_BONES];
shader VSmain(in VSInput VSin:0, out VSOutput VSout)
{
mat4 BoneTransform = gBones[VSin.BoneIDs[0]] * VSin.Weights[0];
BoneTransform += gBones[VSin.BoneIDs[1]] * VSin.Weights[1];
BoneTransform += gBones[VSin.BoneIDs[2]] * VSin.Weights[2];
BoneTransform += gBones[VSin.BoneIDs[3]] * VSin.Weights[3];
vec4 PosL = BoneTransform * vec4(VSin.Position, 1.0);
vec4 ClipSpacePos = gWVP * PosL;
gl_Position = ClipSpacePos;
VSout.TexCoord = VSin.TexCoord;
vec4 NormalL = BoneTransform * vec4(VSin.Normal, 0.0);
VSout.Normal = (gWorld * NormalL).xyz;
VSout.WorldPos = (gWorld * PosL).xyz;
mat4 PrevBoneTransform = gPrevBones[VSin.BoneIDs[0]] * VSin.Weights[0];
PrevBoneTransform += gPrevBones[VSin.BoneIDs[1]] * VSin.Weights[1];
PrevBoneTransform += gPrevBones[VSin.BoneIDs[2]] * VSin.Weights[2];
PrevBoneTransform += gPrevBones[VSin.BoneIDs[3]] * VSin.Weights[3];
VSout.ClipSpacePos = ClipSpacePos;
vec4 PrevPosL = PrevBoneTransform * vec4(VSin.Position, 1.0);
VSout.PrevClipSpacePos = gWVP * PrevPosL;
}
Выше мы видим изменения в VS в алгоритме скиннинга. Мы добавили uniform-массив с преобразованиями костей из предыдущего кадра, он будет использован для нахождения позиции текущей вершины в пространстве клиппера в предыдущем кадре. Эта позиция, так же, как и позиция текущей вершины в пространстве клиппера в текущем кадре, будет передана в FS.
skinning.glsl:165
struct FSOutput
{
vec3 Color;
vec2 MotionVector;
};
shader FSmain(in VSOutput FSin, out FSOutput FSOut)
{
VSOutput1 In;
In.TexCoord = FSin.TexCoord;
In.Normal = normalize(FSin.Normal);
In.WorldPos = FSin.WorldPos;
vec4 TotalLight = CalcDirectionalLight(In);
for (int i = 0 ; i < gNumPointLights ; i++) {
TotalLight += CalcPointLight(gPointLights[i], In);
}
for (int i = 0 ; i < gNumSpotLights ; i++) {
TotalLight += CalcSpotLight(gSpotLights[i], In);
}
vec4 Color = texture(gColorMap, In.TexCoord) * TotalLight;
FSOut.Color = Color.xyz;
vec3 NDCPos = (FSin.ClipSpacePos / FSin.ClipSpacePos.w).xyz;
vec3 PrevNDCPos = (FSin.PrevClipSpacePos / FSin.PrevClipSpacePos.w).xyz;
FSOut.MotionVector = (NDCPos - PrevNDCPos).xy;
}
FS техники скиннинга обновлен так, что теперь он выдает 2 вектора в 2 отдельных буфера (буферы цвета и вектора движения). Цвет вычисляется как обычно. Для вычисления вектора движения мы проецируем позиции в пространстве клиппера текущего и предыдущего кадров через деление перспективы и вычитаем один из другого.
Заметим, что вектор позиции - всего 2D вектор. Это из-за того, что он 'живет' только на экране. Соответствующий буфер размытия создан с типом GL_RG для соответствия.
motion_blur.glsl
struct VSInput
{
vec3 Position;
vec2 TexCoord;
};
interface VSOutput
{
vec2 TexCoord;
};
shader VSmain(in VSInput VSin:0, out VSOutput VSout)
{
gl_Position = vec4(VSin.Position, 1.0);
VSout.TexCoord = VSin.TexCoord;
}
Это VS техники размытия. Мы просто передаем позицию и координаты текстуы каждой вершины полноэкранного прямоугольника.
motion_blur.glsl:19
uniform sampler2D gColorTexture;
uniform sampler2D gMotionTexture;
shader FSmain(in VSOutput FSin, out vec4 FragColor)
{
vec2 MotionVector = (texture(gMotionTexture, FSin.TexCoord).xy) / 2.0f;
vec4 Color = vec4(0.0);
vec2 TexCoord = FSin.TexCoord;
Color += texture(gColorTexture, TexCoord) * 0.4;
TexCoord -= MotionVector;
Color += texture(gColorTexture, TexCoord) * 0.3;
TexCoord -= MotionVector;
Color += texture(gColorTexture, TexCoord) * 0.2;
TexCoord -= MotionVector;
Color += texture(gColorTexture, TexCoord) * 0.1;
FragColor = Color;
}
Вот здесь все веселье размытия. Мы берем вектор движения из текущего пикселя и используем его для выборки 4-х текселей из буфера цвета. Цвет текущего пикселя взят из исходной позиции и получает наибольший вес (0.4). Далее мы движемся вдоль координат текстуры противоположно направлению вектора движения и выбираем еще 3 текселя. Далее мы комбинируем их уменьшая и уменьшая вес при удалении.
Как вы могли заметить, я разделил исходный вектор движения на 2. Вам возможно потребуется небольшой тюнинг, в том числе в настройке веса для получения наилучшего результата. Развлекайтесь.
Пример возможного результата: