Есть распространённый способ сделать игру красивее: пойти изучать шейдеры. Я так и сделал, потратил на это прилично времени и получил желейный куб, которым доволен.
А потом посмотрел на два скриншота рядом и понял неприятное: большую часть разницы дали не шейдеры. Её дали пять довольно тупых правок, каждая из которых занимает от двух до тридцати строк.
Ниже они по порядку - от самой выгодной к менее. Игра браузерная, на 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-набором, и это оказалось и быстрее, и лучше.
Если коротко
Дешёвые правки выигрывают у дорогих чаще, чем хочется признавать. Порядок, который я теперь советую самому себе:
Подвигать камеру.
Посадить объекты на поверхность контактной тенью.
Убрать одинаковость.
Добавить объём на стыках.
Спрятать край мира туманом.
И только после этого лезть в шейдеры - там уже красиво, но дальше начинается работа за проценты.
А какая самая дешёвая правка дала вам самый неожиданный результат? Мне кажется, у каждого есть своя история про две строки конфига.

