javascript

Картинку в моей игре вытянули не шейдеры. Самой полезной правкой оказались две строки конфига

  • суббота, 29 августа 2026 г. в 00:00:07
https://habr.com/ru/articles/1075650/

Есть распространённый способ сделать игру красивее: пойти изучать шейдеры. Я так и сделал, потратил на это прилично времени и получил желейный куб, которым доволен.

А потом посмотрел на два скриншота рядом и понял неприятное: большую часть разницы дали не шейдеры. Её дали пять довольно тупых правок, каждая из которых занимает от двух до тридцати строк.

Ниже они по порядку - от самой выгодной к менее. Игра браузерная, на Three.js и Rapier, но четыре пункта из пяти к движку вообще не привязаны.

Было
Было

Было: камера сверху, одинаковые плитки, куб висит над поверхностью.

Стало
Стало

Стало: та же геометрия уровня, тот же движок.

Сводка: что сколько стоило

Правка

Объём

Что чинит

Опустить камеру

2 строки конфига

кадр как жанр

Контактная тень

~30 строк + текстура градиента

объект «висит» над полом

Разные тайлы по хэшу

~10 строк

мир читается как таблица

Vertex-AO на стыках

чистая функция, ~40 строк

плоские, «картонные» блоки

Туман и тонмаппинг

5 констант

обрыв уровня и пересветы

Дальше по каждой.

1. Опустить камеру. Две строки

Самая выгодная правка за весь проект, и мне до сих пор немного обидно.

cameraOffset: { x: 0, y: 1.5, z: 3.8 },
cameraLookAhead: { y: 0.35, z: -1.6 },

Было y: 2.4, z: 4.4. Камера смотрела на площадку сверху, и кадр читался как «вид на локацию»: игрок наблюдает за коробочкой снаружи. Стало - камера почти на уровне куба и придвинута вплотную, стены уходят вверх за край экрана, и это уже «я внутри коридора».

Ни одного нового полигона. Ни одной новой текстуры. Две строки.

Второй параметр важен не меньше первого: cameraLookAhead опускает точку взгляда, из-за чего горизонт поднимается выше в кадре. Без него низкая камера даёт ощущение, что вы ползёте по полу.

Вывод, который я записал большими буквами: прежде чем неделю воевать с материалами, подвигайте камеру. Материалы никуда не денутся.

2. Контактная тень под объектом

Второе место по соотношению «результат / усилия», и первое - по тому, насколько неочевидно.

Посмотрите на кадр «было»: куб не стоит на траве. Он над ней висит. При этом динамические тени в сцене включены и формально работают - просто на контакте они дают слишком мало.

Лечится плоскостью с радиальным градиентом, которая ездит под кубом:

const CONTACT_SHADOW_SIZE = 1.1;
const CONTACT_SHADOW_Y_OFFSET = 0.01;
const CONTACT_SHADOW_FADE_DISTANCE = 2;
const AO_BLOB_MAX = 0.3;

Вся логика - одна формула прозрачности:

const heightAboveSupport = Math.max(0, bottomY - supportTopY);
this.contactShadowMaterial.opacity =
  THREE.MathUtils.clamp(1 - heightAboveSupport / CONTACT_SHADOW_FADE_DISTANCE, 0, 1) *
  AO_BLOB_MAX;

Чем выше объект над опорой, тем слабее пятно; на высоте двух единиц оно исчезает совсем. Плоскость лежит на сантиметр выше верха опоры, чтобы не воевать с z-fighting.

Три детали, на которых легко ошибиться:

  • Размер пятна больше самого объекта (1.1 против стороны куба ~0.79). Тень ровно по размеру выглядит как приклеенная наклейка.

  • Максимальная непрозрачность 0.3, а не 1.0. Чёрное пятно под объектом читается как дыра в полу.

  • Нужна опора под объектом. Если под кубом пропасть, пятно надо прятать, а не рисовать в воздухе.

3. Соседние блоки не должны быть близнецами

На кадре «было» отлично видно проблему: плитки идентичны до пикселя, и мозг считывает это как таблицу. Не как стену, не как мир - как Excel.

Требование от владельца проекта звучало ровно так: соседние каменные блоки не должны выглядеть одинаково. Решается вариантом текстуры по хэшу от координат тайла:

const rng = mulberry32(hashTileFace(tile.x, tile.y, tile.z, STONE_UV_OFFSET_HASH_CHANNEL));

Здесь важны две вещи.

Хэш детерминированный, а не случайный. Никакого Math.random в размещении. Иначе уровень выглядит по-новому при каждой перезагрузке, а скриншотные тесты начинают падать через раз и вы вместе с ними.

У каждого потребителя свой канал хэша. Вариант травы, UV-сдвиг камня и seed потёртости кромок берут разные каналы от одних и тех же координат. Иначе они скоррелируют, и вместо разнообразия получится узор - а узор глаз ловит так же быстро, как повтор.

Вариантов травы три, камень варьируется сдвигом UV. Этого хватает: глаз перестаёт видеть решётку уже на двух-трёх.

4. Vertex-AO на стыках блоков

Тут чуть больше работы, но всё ещё дёшево - и эффект заметный: блоки перестают выглядеть плоскими наклейками и обретают объём.

Идея простая: вершины, которые лежат у грани, за которой есть сосед, затемняются.

export const AO_OPEN = 1.0;
export const AO_EDGE_DARKEN = 0.55;

Каждый тайл получает свой клон геометрии с вершинными цветами, материал идёт с vertexColors: true, и альбедо просто умножается на этот множитель. То есть затемнение стыков запечено в геометрию и не стоит ни одного прохода рендера.

Функция расчёта чистая: на вход позиции вершин и множество соседей, на выходе массив множителей от 0.55 до 1.0. Её можно покрыть юнит-тестами, не запуская браузер, что я и сделал.

Цена: 36 клонов геометрии на уровень вместо одной общей. Клоны надо не забыть диспозить при перезагрузке уровня - group.clear() этого не делает, память утекает молча.

5. Туман и тонмаппинг. Пять констант

Самое дешёвое из списка.

const FOG_COLOR = 0xb2c0d5;
const FOG_NEAR = 22;
const FOG_FAR = 52;

Уровни в игре парят в небе, то есть у мира есть край. Туман решает это без единой строчки логики: дальние блоки растворяются в цвет неба, и обрыв перестаёт читаться как «тут кончилась карта».

Тонмаппинг (ACES Filmic) убирает выжженные пересветы там, где солнце попадает на светлый камень. Разница не бросается в глаза на скриншоте, но без него светлые участки превращаются в белые пятна без деталей.

Что НЕ дало столько же

Честности ради - список правок, которые стоили гораздо дороже и дали меньше:

Transmission на кубе. Физически корректное просвечивание, дорогая фича: отдельный проход рендера сцены за объектом. Куб от него действительно стал желейным, но по вкладу в общее впечатление от кадра он проиграл камере. Плюс принёс отдельный баг с чёрной теневой стороной, который я потом искал чтением шейдеров three.js.

SSAO. Красиво, дорого, и на слабых машинах приходится выключать целиком. Vertex-AO из пункта 4 даёт часть того же эффекта бесплатно и работает везде.

Процедурные текстуры. Отличный черновик, пока не знаешь, какой камень нужен. Но когда стало понятно, они заменились обычным PBR-набором, и это оказалось и быстрее, и лучше.

Если коротко

Дешёвые правки выигрывают у дорогих чаще, чем хочется признавать. Порядок, который я теперь советую самому себе:

  1. Подвигать камеру.

  2. Посадить объекты на поверхность контактной тенью.

  3. Убрать одинаковость.

  4. Добавить объём на стыках.

  5. Спрятать край мира туманом.

И только после этого лезть в шейдеры - там уже красиво, но дальше начинается работа за проценты.

А какая самая дешёвая правка дала вам самый неожиданный результат? Мне кажется, у каждого есть своя история про две строки конфига.