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

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

В общем я наткнулся на видео от 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

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

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

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