Эта статья является частью серии статей о WebGL. Первая статья начинается с основ.
Туман в WebGL интересен мне тем, насколько фальшивым он кажется, когда я думаю о том, как он работает. В основном то, что вы делаете, это используете какой-то вид глубины или расстояния от камеры в ваших шейдерах, чтобы сделать цвет более или менее цветом тумана.
Другими словами, вы начинаете с базового уравнения вроде этого
outColor = mix(originalColor, fogColor, fogAmount);
Где fogAmount
- это значение от 0 до 1. Функция mix
смешивает первые 2 значения. Когда fogAmount
равен 0, mix
возвращает originalColor
. Когда fogAmount
равен 1, mix
возвращает fogColor
. Между 0 и 1 вы получаете процент обоих цветов. Вы могли бы реализовать mix
сами так
outColor = originalColor + (fogColor - originalColor) * fogAmount;
Давайте сделаем шейдер, который делает это. Мы будем использовать текстурированный куб из статьи о текстурах.
Давайте добавим смешивание в фрагментный шейдер
#version 300 es
precision highp float;
// Передается из вершинного шейдера.
in vec2 v_texcoord;
// Текстура.
uniform sampler2D u_texture;
uniform vec4 u_fogColor;
uniform float u_fogAmount;
out vec4 outColor;
void main() {
vec4 color = texture(u_texture, v_texcoord);
outColor = mix(color, u_fogColor, u_fogAmount);
}
Затем во время инициализации нам нужно найти новые локации uniform
var fogColorLocation = gl.getUniformLocation(program, "u_fogColor");
var fogAmountLocation = gl.getUniformLocation(program, "u_fogAmount");
и во время рендеринга установить их
var fogColor = [0.8, 0.9, 1, 1];
var settings = {
fogAmount: .5,
};
...
function drawScene(time) {
...
// Очищаем canvas И буфер глубины.
// Очищаем до цвета тумана
gl.clearColor(...fogColor);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
...
// устанавливаем цвет тумана и количество
gl.uniform4fv(fogColorLocation, fogColor);
gl.uniform1f(fogAmountLocation, settings.fogAmount);
...
}
И вот вы увидите, если вы перетащите слайдер, вы можете изменить между текстурой и цветом тумана
Так что теперь все, что нам действительно нужно сделать, это вместо передачи количества тумана, мы вычисляем его на основе чего-то вроде глубины от камеры.
Вспомните из статьи о камерах, что после применения матрицы вида все позиции относительны к камере. Камера смотрит вниз по оси -z, поэтому если мы просто посмотрим на z позицию после умножения на мировую и видовую матрицы, мы получим значение, которое представляет, как далеко что-то находится от z плоскости камеры.
Давайте изменим вершинный шейдер, чтобы передать эти данные в фрагментный шейдер, чтобы мы могли использовать их для вычисления количества тумана. Для этого давайте разделим u_matrix
на 2 части. Матрицу проекции и мировую видовую матрицу.
#version 300 es
in vec4 a_position;
in vec2 a_texcoord;
uniform mat4 u_worldView;
uniform mat4 u_projection;
out vec2 v_texcoord;
out float v_fogDepth;
void main() {
// Умножаем позицию на матрицу.
gl_Position = u_projection * u_worldView * a_position;
// Передаем texcoord в фрагментный шейдер.
v_texcoord = a_texcoord;
// Передаем только отрицательную z позицию относительно камеры.
// камера смотрит в направлении -z, поэтому обычно вещи
// перед камерой имеют отрицательную Z позицию
// но отрицая это, мы получаем положительную глубину.
v_fogDepth = -(u_worldView * a_position).z;
}
Теперь во фрагментном шейдере мы хотим, чтобы он работал так: если глубина меньше некоторого значения, не смешивать никакой туман (fogAmount = 0). Если глубина больше некоторого значения, то 100% туман (fogAmount = 1). Между этими 2 значениями смешиваем цвета.
Мы могли бы написать код для этого, но GLSL имеет функцию smoothstep
, которая делает именно это. Вы даете ей минимальное значение, максимальное значение и значение для тестирования. Если тестовое значение меньше или равно минимальному значению, она возвращает 0. Если тестовое значение больше или равно максимальному значению, она возвращает 1. Если тест между этими 2 значениями, она возвращает что-то между 0 и 1 пропорционально тому, где тестовое значение находится между min и max.
Итак, должно быть довольно легко использовать это в нашем фрагментном шейдере для вычисления количества тумана
#version 300 es
precision highp float;
// Передается из вершинного шейдера.
in vec2 v_texcoord;
in float v_fogDepth;
// Текстура.
uniform sampler2D u_texture;
uniform vec4 u_fogColor;
uniform float u_fogNear;
uniform float u_fogFar;
out vec4 outColor;
void main() {
vec4 color = texture(u_texture, v_texcoord);
float fogAmount = smoothstep(u_fogNear, u_fogFar, v_fogDepth);
outColor = mix(color, u_fogColor, fogAmount);
}
и конечно нам нужно найти все эти uniforms во время инициализации
// ищем uniforms
var projectionLocation = gl.getUniformLocation(program, "u_projection");
var worldViewLocation = gl.getUniformLocation(program, "u_worldView");
var textureLocation = gl.getUniformLocation(program, "u_texture");
var fogColorLocation = gl.getUniformLocation(program, "u_fogColor");
var fogNearLocation = gl.getUniformLocation(program, "u_fogNear");
var fogFarLocation = gl.getUniformLocation(program, "u_fogFar");
и установить их во время рендеринга
var fogColor = [0.8, 0.9, 1, 1];
var settings = {
fogNear: 1.1,
fogFar: 2.0,
};
// Рисуем сцену.
function drawScene(time) {
...
// Вычисляем матрицу проекции
var aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
var projectionMatrix =
m4.perspective(fieldOfViewRadians, aspect, 1, 2000);
var cameraPosition = [0, 0, 2];
var up = [0, 1, 0];
var target = [0, 0, 0];
// Вычисляем матрицу камеры используя look at.
var cameraMatrix = m4.lookAt(cameraPosition, target, up);
// Делаем видовую матрицу из матрицы камеры.
var viewMatrix = m4.inverse(cameraMatrix);
var worldViewMatrix = m4.xRotate(viewMatrix, modelXRotationRadians);
worldViewMatrix = m4.yRotate(worldViewMatrix, modelYRotationRadians);
// Устанавливаем матрицы.
gl.uniformMatrix4fv(projectionLocation, false, projectionMatrix);
gl.uniformMatrix4fv(worldViewLocation, false, worldViewMatrix);
// Говорим шейдеру использовать texture unit 0 для u_texture
gl.uniform1i(textureLocation, 0);
// устанавливаем цвет тумана и настройки near, far
gl.uniform4fv(fogColorLocation, fogColor);
gl.uniform1f(fogNearLocation, settings.fogNear);
gl.uniform1f(fogFarLocation, settings.fogFar);
}
Пока мы этим занимаемся, давайте нарисуем 40 кубов вдаль, чтобы легче было увидеть туман.
var settings = {
fogNear: 1.1,
fogFar: 2.0,
xOff: 1.1,
zOff: 1.4,
};
...
const numCubes = 40;
for (let i = 0; i <= numCubes; ++i) {
var worldViewMatrix = m4.translate(viewMatrix, -2 + i * settings.xOff, 0, -i * settings.zOff);
worldViewMatrix = m4.xRotate(worldViewMatrix, modelXRotationRadians + i * 0.1);
worldViewMatrix = m4.yRotate(worldViewMatrix, modelYRotationRadians + i * 0.1);
gl.uniformMatrix4fv(worldViewLocation, false, worldViewMatrix);
// Рисуем геометрию.
gl.drawArrays(gl.TRIANGLES, 0, 6 * 6);
}
И теперь мы получаем туман на основе глубины
Примечание: Мы не добавили никакого кода, чтобы убедиться, что fogNear
меньше или равен fogFar
, что, возможно, недопустимые настройки, поэтому обязательно установите оба соответствующим образом.
Как я упомянул выше, это кажется мне трюком. Это работает, потому что цвет тумана, к которому мы переходим, соответствует цвету фона. Измените цвет фона, и иллюзия исчезает.
gl.clearColor(1, 0, 0, 1); // красный
дает нам
так что просто помните, что вам нужно установить цвет фона, чтобы он соответствовал цвету тумана.
Использование глубины работает и это дешево, но есть проблема. Допустим, у вас есть круг объектов вокруг камеры. Мы вычисляем количество тумана на основе расстояния от z плоскости камеры. Это означает, что когда вы поворачиваете камеру, объекты будут появляться и исчезать из тумана слегка, когда их z значение в пространстве вида становится ближе к 0
Вы можете увидеть проблему в этом примере
Выше есть кольцо из 8 кубов прямо вокруг камеры. Камера вращается на месте. Это означает, что кубы всегда на одинаковом расстоянии от камеры, но на разном расстоянии от z плоскости, и поэтому наш расчет количества тумана приводит к тому, что кубы у края выходят из тумана.
Исправление заключается в том, чтобы вместо этого вычислять расстояние от камеры, которое будет одинаковым для всех кубов
Для этого нам просто нужно передать позицию вершины в пространстве вида из вершинного шейдера в фрагментный шейдер
#version 300 es
in vec4 a_position;
in vec2 a_texcoord;
uniform mat4 u_worldView;
uniform mat4 u_projection;
out vec2 v_texcoord;
out vec3 v_position;
void main() {
// Умножаем позицию на матрицу.
gl_Position = u_projection * u_worldView * a_position;
// Передаем texcoord в фрагментный шейдер.
v_texcoord = a_texcoord;
// Передаем позицию вида в фрагментный шейдер
v_position = (u_worldView * a_position).xyz;
}
и затем во фрагментном шейдере мы можем использовать позицию для вычисления расстояния
#version 300 es
precision highp float;
// Передается из вершинного шейдера.
in vec2 v_texcoord;
in vec3 v_position;
// Текстура.
uniform sampler2D u_texture;
uniform vec4 u_fogColor;
uniform float u_fogNear;
uniform float u_fogFar;
out vec4 outColor;
void main() {
vec4 color = texture(u_texture, v_texcoord);
float fogDistance = length(v_position);
float fogAmount = smoothstep(u_fogNear, u_fogFar, fogDistance);
outColor = mix(color, u_fogColor, fogAmount);
}
И теперь кубы больше не выходят из тумана, когда камера поворачивается
Пока что весь наш туман использовал линейный расчет. Другими словами, цвет тумана применяется линейно между near и far. Как и многие вещи в реальном мире, туман, по-видимому, работает экспоненциально. Он становится гуще с квадратом расстояния от зрителя. Общее уравнение для экспоненциального тумана
#define LOG2 1.442695
fogAmount = 1. - exp2(-fogDensity * fogDensity * fogDistance * fogDistance * LOG2));
fogAmount = clamp(fogAmount, 0., 1.);
Чтобы использовать это, мы изменили бы фрагментный шейдер на что-то вроде
#version 300 es
precision highp float;
// Передается из вершинного шейдера.
in vec2 v_texcoord;
in vec3 v_position;
// Текстура.
uniform sampler2D u_texture;
uniform vec4 u_fogColor;
uniform float u_fogDensity;
out vec4 outColor;
void main() {
vec4 color = texture(u_texture, v_texcoord);
#define LOG2 1.442695
float fogDistance = length(v_position);
float fogAmount = 1. - exp2(-u_fogDensity * u_fogDensity * fogDistance * fogDistance * LOG2);
fogAmount = clamp(fogAmount, 0., 1.);
outColor = mix(color, u_fogColor, fogAmount);
}
И мы получаем туман на основе расстояния exp2 плотности
Одна вещь, которую стоит заметить о тумане на основе плотности, это то, что нет настроек near и far. Это может быть более реалистично, но также может не соответствовать вашим эстетическим потребностям. Какой из них вы предпочитаете - это художественное решение.
Есть много других способов вычисления тумана. На маломощном GPU вы можете просто использовать gl_FragCoord.z
. gl_FragCoord
- это глобальная переменная, которую устанавливает WebGL. Компоненты x
и y
- это координаты пикселя, который рисуется. Компонент z
- это глубина этого пикселя от 0 до 1. Хотя не напрямую переводится в расстояние, вы все еще можете получить что-то, что выглядит как туман, выбрав некоторые значения между 0 и 1 для near и far. Ничего не нужно передавать из вершинного шейдера в фрагментный шейдер, и никакие вычисления расстояния не нужны, так что это один способ сделать дешевый эффект тумана на маломощном GPU.