Почему я вообще за это взялся?

Короткий ответ: рекомендации ютуба.

В общем я наткнулся на видео от t3ssel8r и мне очень понравился стиль отрисовки и я решил на порыве мотивации сделать что-то подобное.

С чем работаем?

В последнее время меня очень сильно привлекает игровой движок Godot, поэтому будем работать с ним.

Из преимуществ:

  • Движок довольно прост

  • Великолепная документация

  • Куча проектов-примеров

  • Можно собрать проект почти под что угодно

Из недостатков:

  • Последняя крупная версия всё ещё молода т.е. ожидаем баги

  • Недостаток возможностей по сравнению с UE4/5 и Unity

  • Разные возможности на разных бэкэндах

Задача

Для того чтобы правильно понимать что и как делать, надо понимать каким запросам должен отвечать конечный результат

Общаяя идея:

  • Иметь вид pixel-art:

    • Подсвечивать грани смотрящие на камеру

    • Затемнять границы объектов

    • Низкое разрешение

  • Должно работать в сборке под HTML5

В принципе не такие уж и сложные требования, давайте посмотрим сделал ли кто что-то подобное до нас? Конечно сделал! Это-же не ядерная физика в конце концов.
Именно то что мне надо уже было сделано до меня!
Но есть одна проблема: используется бэкэнд отрисовки Forward+ который даёт доступ к буферу нормалей, который активно используется шейдером.
Так в чём же проблема? Этот буфер не инициализируется при сборке под HTML5.
Но без него не возможно подсвечивать грани, смотрящие на камеру, так что же нам делать?

Реконструкция нормалей

Вообще к этому термину я пришёл не сразу, а после вот такой цепочки запросов:
"Godot compatibility renderer normal buffer" - Вывод: буфер не инициализируется в режиме отрисовки compatibility (HTML5);
"What buffers Godot uses in compatibility renderer" - Вывод: помимо буфера цвета Godot создаёт буфер глубины во всех режимах отрисовки;
"Godot reconstruct normals from depth" - Я не нашёл примеров припенения подобных техник в Godot, но мы добрались до ключевой пары слов, которая помогла мне найти нужный ресурс;

Для того чтобы вы понимали о чём я дальше буду говорить, пройдёмся по базовым знаниям.

  1. Нормаль - перпендикуляр к плоскости.

  2. Уникальную плоскость можно задать тремя точками.

  3. Плоскость также можно задать уравнением вида z = a*x + b*y +c

    И мы можем вычислить нормаль к любой плоскости с помощью функции cross(a,b) или cross(x_1 - x_c,x_2 - x_c) , где x_1,x_2,x_c - точки на плоскости. но полученная таким образом нормаль в большинстве случаев будет иметь длину != 1, а в практических целях нам нужна нормаль длиной 1, так что результат мы пропускаем через функцию normalize().

Итак, "Normal reconstruction":

  • Первая ссылка - Improved normal reconstruction from depth. Общая идея - вычислить нормали из позиций центрального и окружающих его пикселей, а потом посчитать среднее. Так как автор потом уменьшал разрешение изображения, артефактов почти не было, но это не совсем тот вариант, котрый мне нужен т.к мне нужен буфер нормалей такого-же размера как буфер цвета.

  • Вторая ссылка - Accurate Normal Reconstruction from Depth Buffer. Очень хорошая статья с прекрасным объяснением. Даже примеры есть, посмотрим...
    Я искал медь, но нашёл золото. Великолепно! Это именно то, что я искал! Пора перенести это в Godot, и попутно объяснить как работают разные представленные методы.

Для начала создадим сцену на которой будем тестировать наши шейдеры:

Простенькая тестовая сцена
Простенькая тестовая сцена

Теперь создадим две плоскости, которые с помощью шейдера растянем на весь экран:

Те самые плоскости
Те самые плоскости

Пока мы всё ещё используем метод отрисовки Forward+, давайте для проверки и наглядности сделаем так, чтобы левая часть экрана отображала истинные нормали

shader_type spatial;
render_mode unshaded,depth_draw_never;

uniform sampler2D normals : hint_normal_roughness_texture;

void vertex() {
	POSITION = vec4(VERTEX.xy,0.0,1.0);
}

void fragment() {
	ALBEDO = texture(normals,SCREEN_UV).rgb;
	ALPHA = 1.0;
}

А правая - немного подкрашена зелёным

shader_type spatial;
render_mode unshaded,depth_draw_never;

void vertex() {
	POSITION = vec4(VERTEX.xy,0.0,1.0);
}

void fragment() {
	ALBEDO = vec3(0.0,1.0,0.0);
	ALPHA = 0.5;
}

Работает!

2 шейдера на одном экране
2 шейдера на одном экране

Теперь можно приступить к реконструкции нормалей.

Общие функции

Это обязательно идёт в начало каждого шейдера

shader_type spatial;
render_mode unshaded,depth_draw_never;

uniform sampler2D depth_texture : hint_depth_texture,filter_nearest;

void vertex(){
	POSITION = vec4(VERTEX.xy,0.0,1.0);
}

По факту просто ставит вершины сразу в NDC (x(-1..=1),y(-1..=1),z(0..=1)) пропуская преобразования координат

vec3 viewPosDepth(float depth,vec2 uv,mat4 ipm){
	vec3 ndc = vec3(uv*2.0 - 1.0,depth);
	vec4 view = ipm * vec4(ndc,1.0);
	view.xyz /= view.w;
	return view.xyz;
}

vec3 viewPosSampler(sampler2D depth_tex,vec2 uv,mat4 ipm){
	float depth = texture(depth_tex,uv).x;
	return viewPosDepth(depth,uv,ipm);
}

Преобразуют координаты из нормализированного пространства (NDC) в координаты пространства вида т.е. координаты относительно камеры

Метод №1 Simple 3 tap

...

vec3 NR_3tap(vec2 uv,vec2 el,mat4 ipm,sampler2D depth_tex){
	float depth_c = texture(depth_tex,uv).x;
	//ранний выход если глубина слишком высока
	if (depth_c == 1.0){
		return vec3(0.5);
	}
	
	vec3 view_c = viewPosDepth(depth_c,uv,ipm);
	vec3 view_r = viewPosSampler(depth_tex,uv + vec2(1.0,0.0)*el,ipm);
	vec3 view_u = viewPosSampler(depth_tex,uv + vec2(0.0,1.0)*el,ipm);
	
	vec3 h_der = view_r - view_c;
	vec3 v_der = view_u - view_c;
	
	vec3 view_n = normalize(cross(v_der,h_der));
	
	return (view_n+1.0)*0.5;
}

void fragment() {
	vec2 uv = SCREEN_UV;
	vec2 el = 1.0/VIEWPORT_SIZE;
	mat4 ipm = INV_PROJECTION_MATRIX;
	vec3 normal = NR_3tap(uv,el,ipm,depth_texture);
	ALBEDO = normal;
}

Общая идея такова :

Зелёная точка - координаты пикселя (SCREEN_UV)
Мы берём её координаты и координаты пикселей справа и снизу, из них вычисляется горизонтальный и вертикальный сдвиг. Далее просто находим нормаль из полученных сдвигов.
И результат: слева - истинные нормали, справа - реконструированные

Не особо хорошо видна разница. Тогда просто понизим разрешение!

Как можно заметить, присутствуют значительные артефакты на границах объектов, а также отсутствует сглаживание нормалей, которое можно ожидать от MeshInstance.

Метод №2 Simple 4 tap

...

vec3 NR_4tap(vec2 uv,vec2 el,mat4 ipm,sampler2D depth_tex){
	float depth_l = texture(depth_tex,uv - vec2(1.0,0.0)*el).x;
	//early exit if on the end of view distance
	if (depth_l == 1.0){
		return vec3(0.5);
	}
	
	vec3 view_l = viewPosDepth(depth_l,uv - vec2(1.0,0.0)*el,ipm);
	vec3 view_d = viewPosSampler(uv - vec2(0.0,1.0)*el,ipm,depth_tex);
	vec3 view_r = viewPosSampler(uv + vec2(1.0,0.0)*el,ipm,depth_tex);
	vec3 view_u = viewPosSampler(uv + vec2(0.0,1.0)*el,ipm,depth_tex);
	
	vec3 h_der = view_r - view_l;
	vec3 v_der = view_u - view_d;
	
	vec3 view_n = normalize(cross(v_der,h_der));
	
	return (view_n+1.0)*0.5;
}

...

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

И результат:

Ещё хуже, но это было ожидаемо т.к. мы делаем более "грубое" приближение в данном случае, полностью пропуская пиксель, с которым работаем.

Метод №3 Improved 5 tap

...

vec3 NR_5tap(vec2 uv,vec2 el,mat4 ipm,sampler2D depth_tex){
	float depth_c = texture(depth_tex,uv).x;
	//early exit if on the end of view distance
	if (depth_c == 1.0){
		return vec3(0.5);
	}
	
	vec3 view_c = viewPosDepth(depth_c,uv,ipm);
	vec3 view_l = viewPosSampler(uv - vec2(1.0,0.0)*el,ipm,depth_tex);
	vec3 view_d = viewPosSampler(uv - vec2(0.0,1.0)*el,ipm,depth_tex);
	vec3 view_r = viewPosSampler(uv + vec2(1.0,0.0)*el,ipm,depth_tex);
	vec3 view_u = viewPosSampler(uv + vec2(0.0,1.0)*el,ipm,depth_tex);
	
	vec3 l = view_c - view_l;
	vec3 r = view_r - view_c;
	vec3 d = view_c - view_d;
	vec3 u = view_u - view_c;
	
	vec3 h_der = abs(l.z) < abs(r.z) ? l : r;
	vec3 v_der = abs(d.z) < abs(u.z) ? d : u;
	
	vec3 view_n = normalize(cross(v_der,h_der));
	
	return (view_n+1.0)*0.5;
}

...

В этом методе мы вычисляем разницу позиций для каждого направления, при этом сохраняя общее для оси направление(это важно для функции cross()).

Далее по разнице глубин выбираем направление, которое "ближе" и из "ближайших" горизонтального и вертикального вычисляем нормаль:

Почти идеально!
Почти идеально!

Как можно видеть, искажения всё ещё присутстуют, но их количество и заметность крайне малы.

Метод №4 Accurate 9 tap

...

vec3 NR_9tap(vec2 uv,vec2 el,mat4 ipm,sampler2D depth_tex){
	vec3 view_c = viewPosSampler(uv,ipm,depth_tex);
	vec3 view_l = viewPosSampler(uv - vec2(1.0,0.0)*el,ipm,depth_tex);
	vec3 view_r = viewPosSampler(uv + vec2(1.0,0.0)*el,ipm,depth_tex);
	vec3 view_d = viewPosSampler(uv - vec2(0.0,1.0)*el,ipm,depth_tex);
	vec3 view_u = viewPosSampler(uv + vec2(0.0,1.0)*el,ipm,depth_tex);
	
	vec3 l = view_c - view_l;
	vec3 r = view_r - view_c;
	vec3 d = view_c - view_d;
	vec3 u = view_u - view_c;
	
	
	//deside from which direction to sample
	//center depth
	float depth_c = texture(depth_tex,uv).x;
	//early exit if on the end of view distance
	if (depth_c == 1.0){
		return vec3(0.5);
	}
	//horizontal depths
	vec4 H = vec4(
		texture(depth_tex,uv - vec2(1.0,0.0)*el).x,
		texture(depth_tex,uv - vec2(2.0,0.0)*el).x,
		texture(depth_tex,uv + vec2(1.0,0.0)*el).x,
		texture(depth_tex,uv + vec2(2.0,0.0)*el).x
	);
	//vertical depths
	vec4 V = vec4(
		texture(depth_tex,uv - vec2(0.0,1.0)*el).x,
		texture(depth_tex,uv - vec2(0.0,2.0)*el).x,
		texture(depth_tex,uv + vec2(0.0,1.0)*el).x,
		texture(depth_tex,uv + vec2(0.0,2.0)*el).x
	);
	//find diff of true center and extrapolated one
	vec2 he = abs((2.0*H.xz - H.yw) - depth_c);
	vec2 ve = abs((2.0*V.xz - V.yw) - depth_c);
	
	vec3 h_der = he.x < he.y ? l : r;
	vec3 v_der = ve.x < ve.y ? d : u;
	
	vec3 view_n = normalize(cross(v_der,h_der));
	
	return (view_n+1.0)*0.5;
}

...

Это именно тот метод, который описан в этой статье. Его начало аналогично предыдущему методу, однако теперь мы определяем какую сторону брать с помощью экстраполяции центральной глубины:

  1. Продлить ab и получить c_1

  2. Продлить ed и получить c_2

  3. Если |c_1 - c| < |c_2 - c|, то c находится на ab, иначе c находится на de

Этот метод почти идеален, артефакты существуют только там, где размер элемента меньше 2 пикселей, однако его можно улучшить, уменьшив количество преобразований из NDC в координаты вида

Метод №5 Улучшенный мной метод №4

...

vec3 NR_9tap_plus(vec2 uv,vec2 el,mat4 ipm,sampler2D depth_tex){
	//center depth
	float depth_c = texture(depth_tex,uv).x;
	//early exit if on the end of view distance
	if (depth_c == 1.0){
		return vec3(0.5);
	}
	//horizontal depths
	vec4 H = vec4(
		texture(depth_tex,uv - vec2(1.0,0.0)*el).x,
		texture(depth_tex,uv - vec2(2.0,0.0)*el).x,
		texture(depth_tex,uv + vec2(1.0,0.0)*el).x,
		texture(depth_tex,uv + vec2(2.0,0.0)*el).x
	);
	//vertical depths
	vec4 V = vec4(
		texture(depth_tex,uv - vec2(0.0,1.0)*el).x,
		texture(depth_tex,uv - vec2(0.0,2.0)*el).x,
		texture(depth_tex,uv + vec2(0.0,1.0)*el).x,
		texture(depth_tex,uv + vec2(0.0,2.0)*el).x
	);
	//find diff of true center and extrapolated one
	vec2 he = abs((2.0*H.xz - H.yw) - depth_c);
	vec2 ve = abs((2.0*V.xz - V.yw) - depth_c);
	//from which direction to sample
	float h_sign = he.x < he.y ? -1.0 : 1.0;
	float v_sign = ve.x < ve.y ? -1.0 : 1.0;
	
	vec3 view_h = viewPosDepth(H[1 + int(h_sign)],uv + vec2(h_sign,0.0)*el,ipm);
	vec3 view_v = viewPosDepth(V[1 + int(v_sign)],uv + vec2(0.0,v_sign)*el,ipm);
	
	vec3 view_c = viewPosDepth(depth_c,uv,ipm);
	
	vec3 h_der = h_sign*(view_h - view_c);
	vec3 v_der = v_sign*(view_v - view_c);
	
	vec3 view_n = normalize(cross(v_der,h_der));
	
	return (view_n+1.0)*0.5;
}

...

Хотя внесённые изменения не выглядят серьёзными, они убирают 5 лишних запросов на буфер глубины и 2 перевода из NDC в координаты вида. Визуально от метода №4 не отличается, но снижает время кадра на 10% на моей встроенной видеокарте, так что я считаю это успехом.

Сборка под HTML5

В режиме отрисовки Compatibility Godot использует NDC отличные от таковых в режиме Forward+, с которым мы работали до сих пор, поэтому необходимо обновить функции, которые зависят от NDC:

void vertex(){
	POSITION = vec4(VERTEX.xy,-1.0,1.0);
}

vec3 viewPosDepth(float depth,vec2 uv,mat4 ipm){
	vec3 ndc = vec3(uv,depth)*2.0 - 1.0;
	vec4 view = ipm * vec4(ndc,1.0);
	view.xyz /= view.w;
	return view.xyz;
}

Разница в параметре глубины:

  • В Forward+ z: 0..=1

  • В Compatibility z: -1..=1

Финальный результат

После значительной возни с интерфейсом, я сделал мини проект, на который можно посмотреть на этой веб демо.

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

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


  1. lgorSL
    28.06.2023 00:04
    +1

    Очень круто.
    Но странно, что в godot нельзя сделать это стандартным способом. По-идее в WebGL 2.0 поддерживаются multiple render targets и можно за один проход писать данные сразу в несколько буферов - например, цвета и нормали.


    1. Snailwithtea Автор
      28.06.2023 00:04

      Да, даже есть PR по этой теме: https://github.com/godotengine/godot/pull/38926

      Однако его не приняли т.к он только для Godot 3.x, а автор не может написать и тестировать версию для 4.x из-за отсутствия оборудования, поддерживающего Vulkan