Почему я вообще за это взялся?
Короткий ответ: рекомендации ютуба.
В общем я наткнулся на видео от 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, а в практических целях нам нужна нормаль длиной 1, так что результат мы пропускаем через функцию
.
Итак, "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; }
Работает!

Теперь можно приступить к реконструкции нормалей.
Общие функции
Это обязательно идёт в начало каждого шейдера
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; } ...
Это именно тот метод, который описан в этой статье. Его начало аналогично предыдущему методу, однако теперь мы определяем какую сторону брать с помощью экстраполяции центральной глубины:

Продлить
и получить
Продлить
и получить
Если
, то
находится на
, иначе
находится на

Этот метод почти идеален, артефакты существуют только там, где размер элемента меньше 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
Финальный результат
После значительной возни с интерфейсом, я сделал мини проект, на который можно посмотреть на этой веб демо.
Надеюс�� вам понравилась моя первая статья(можно ли вообще данное чтиво так называть?), если интересно, можете взглянуть на исходный код проекта.