Тех-арт ворвался в геймдев

Привет ДТФ. Я Unreal Authorized Instructor (это который золотой, официальный, от Epic - нагуглите если лень). Лет шесть занимался техническим артом на галерах, делал шейдеры, VFX, механики, ui, ux, левел дизайн, левел арт для чужих проектов. Тут обо мне. В какой-то момент подумал - а почему я объясняю другим людям, как писать крутые штуки в Unreal, а сам свою классную игру так и не сделал? Ну и собрал напарника, и полетели.

Вот что из этого получилось: HOLD MY WHEEL - 5-player co-op выживач, где автобус это дом, кооп напарники это еда (если зомбанулись), а смерть это не конец а начало. Выходит 3 августа в Steam.

А теперь про то, как она внутри сделана, потому что именно ради этого я и пишу этот пост.

Без покупных ассетов на механики и визуал. Вообще

Когда солочный инди делает игру - он обычно идёт на FAB или маркет, берёт готовый package и собирает из кубиков. Это нормально, это работает, никого не осуждаю. Но мне было неинтересно.
Единственное, что я купили - low-poly environment pack для пустошей. Всё остальное - шейдеры, VFX, постпроцесс, физические системы, death state, анимации, все механики, репликация, войс чат - написаны ручками преимущественно на плюсах и hlsl.
Почему так? Потому что когда ты тех-арт, готовый ассет для шейдера ощущается как купить «генератор случайных чисел» для своего калькулятора. Ты уже умеешь писать то, что тебе нужно, ровно так как нужно, без лишнего оверхеда. Но самое главное ровно так, чтобы проявить какое-то творчество (пусть даже в системе испускания мочи с hlsl, ниагарой и инстансами).

Магнитная пушка, которой ты швыряешь автобус

Ты берёшь физический actor размером с автобус (~17 тонн mass), цепляешь к нему constraint-систему с задаваемой дистанцией и силой, и позволяешь игроку двигать это всё в 3D. Звучит просто-— но когда на автобусе пять коопщиков стоят на крыше, плюс он в кластере с грапплингом другого игрока, плюс на крыше турель стреляет - физика начинает играть в солитер с твоим кадром.
Пришлось писать свой solver для ситуаций, когда constraints конфликтуют. PhysX под UE5 не любит когда ты держишь 12-тонную хреновину и швыряешь её в зомби-толпу с 30-ю актёрами. Кто бы мог подумать. Плюс пришлось разбиратся с переопределением счётчика в логике репликации увеличивая и уменьшая количество реплицируемых данных в простое и в моменты активного взаимодействия.

Тех-арт ворвался в геймдев

Physics-based pathfinding для автобуса

В обычной игре NavMesh - это как рельсы. Ты кладёшь геометрию, движок запекает nav, NPC бегают. У нас всё наоборот: мир это куча обломков, мосты игрок строит на ходу из хлама, дороги могут быть перевёрнуты вверх ногами. Классический NavMesh тут отваливается через 10 секунд геймплея.
Поэтому nav для автобуса, пешек, зомби и т.п. считается не по NavMesh, а через физические raycast'ы вниз от предполагаемой траектории, с учётом массы автобуса, grip'а колёс и stability score по surface angle. То есть «есть ли на этой траектории поверхность которая выдержит 17 тонн и не опрокинет». Это не AI pathfinding в чистом виде, это ближе к soft-body simulation с продвинутой предварительной эвристикой. Для пешек же вариация с динамическим анализом окружения исходя из их текущей позиции, что намного дешевле чем динамика всего мира, а он к слову 8 на 8 км.

Виды смертей

Когда игрок умирает, он не уходит в черный экран с кнопкой Respawn - он превращается в ghost/zombie и ещё пару состояний, и продолжает играть, только с другими правилами. Визуально это надо было показать так, чтобы игрок реально чувствовал переход состояния.
Ничего сверхсложного, просто анимированный постпроцесс, для всех свой.

Тех-арт ворвался в геймдев

И даже поделюсь с вами, мне не жалко, как компенсацию за то, что вы читаете этот мильён букаф.

Тех-арт ворвался в геймдев

Distort

float2 uv = UV; float t = View.GameTime; float2 d = float2(sin(uv.y*12.0 + t*1.5), cos(uv.x*9.0 + t*1.2)) * 0.008; d += float2(sin(uv.y*30.0 + t*0.7), sin(uv.x*28.0 + t*0.9)) * 0.004; float2 c = uv - 0.5; float r = length(c); float2 rd = c / max(r, 0.0001); d += rd * sin(t*2.0 + r*14.0) * 0.01; return uv + d;

ZombieOverlay

float2 uv = UV; float3 scene = Scene; float t = View.GameTime; scene *= float3(0.55, 1.15, 0.55); float lum = dot(scene, float3(0.3, 0.59, 0.11)); scene = lerp(float3(lum*0.4, lum, lum*0.4), scene, 0.65); float2 c = uv - 0.5; float r = length(c); scene *= smoothstep(0.9, 0.25, r); float blobs = 0.0; for (int i=0; i<5; i++) { float fi = float(i); float2 bc = float2(0.5 + sin(t*0.35 + fi*2.1)*0.32, 0.5 + cos(t*0.45 + fi*1.7)*0.32); float br = length(uv - bc); blobs += exp(-br*br*90.0); } scene *= 1.0 - saturate(blobs)*0.35; scene += float3(0.0, 0.25, 0.0) * saturate(blobs); float edge = smoothstep(0.22, 0.55, r); float s1 = abs(sin(uv.y*38.0 + sin(uv.x*8.0 + t*0.3)*3.0 + t*0.4)); float s2 = abs(sin(uv.x*42.0 + sin(uv.y*9.0 + t*0.25)*3.0 + t*0.5)); float s3 = abs(sin((uv.x+uv.y)*55.0 + sin(uv.y*6.0 + t*0.2)*2.5)); float vessel = (1.0 - smoothstep(0.0, 0.045, s1)) + (1.0 - smoothstep(0.0, 0.045, s2))*0.8 + (1.0 - smoothstep(0.0, 0.035, s3))*0.6; vessel *= edge; float2 vp = floor(uv*280.0); float vh = frac(sin(dot(vp, float2(12.9898, 78.233)))*43758.5453); vessel *= smoothstep(0.25, 0.25, vh); scene = lerp(scene, float3(0.65, 0.03, 0.03), saturate(vessel*0.85)); float heart = 0.5 + 0.5*sin(t*2.8); scene += float3(0.09, 0.0, 0.0) * heart; scene.g *= 1.05; return scene;

Экран смерти - очень низкоуровневая логика

Эффект разрыва экрана при взрыве автобуса. Когда автобус разваливается, весь экран игрока (вместе с UI, худом, таймерами коопщиков) физически рвётся на куски и разлетается как стекло. UI-элементы не исчезают, а расходятся вместе с кусками картинки мира. Штатный SceneCaptureComponent2D так не умеет - он захватывает только world-геометрию, без UI. Потому что Slate (UI-слой Unreal) композится уже после основного рендера, в конце pipeline'а. Пришлось писать кастомный RDG pass, который врезается в самый конец рендер-графа, после Slate, и граббит готовый back buffer в persistent. И берет пару кадров, обьединяет их. И этот RT идёт на вход widget'а, который через HLSL бьёт картинку на tile'ы и разбивает их через псевтосмещение UV в плоскости. Коротко: беру не скриншот мира, а буквально тот кадр который видит игрок — со всем UI и постпроцессом — замораживаю и рву.

Тех-арт ворвался в геймдев

Анимации зомби / тряпичной куклы / призрака

Больше 3х разных state. У ragdoll физика включена полностью, анимация выключена - ты болтаешься тряпкой, но всё ещё можешь влиять на движение инерцией. У зомби — Control Rig на смеси IK и эвентов через монтажи для укусов( и да, делали сами). У призрака - ничего осбоеного простой hlsl шейдер с полупрозрачностью. И так далее.
Для инди-игры это overkill. Я в курсе. Мне было интересно.

Шейдеры, ну как же.

Раз уж я начал про шейдеры - один конкретный пример. Флюидная жидкость в игре (наполнитель бутылочек которые используютсья как ресы, сделана примерно как в Half-Life , но с отсылочкой на Фалаут- у нас это разные виды Atomic Cola. Материал с fake-refraction через screen-space distortion, нормалями поверхности, считающимися из суммы трёх octave-скроллящихся gerstner-волн, и динамической толщиной по альфа-каналу через distance field к ближайшей геометрии. Никакого примитива - всё через Custom онли. И сейчас это 100+ нод и performance не топ, передалаю на hlsl 1-2 ноды.

Тех-арт ворвался в геймдев

И ещё пример ультрадешового шейдера - Магнитный трос.

С вашего позволения отдаю первую версию, расчитаную на цилиндрические формы. В конечном виде использую другую.

Тех-арт ворвался в геймдев
Тех-арт ворвался в геймдев
float along = UV.y; float angle = UV.x; float t = View.GameTime * Speed; float pi = 3.14159265; float endFade = sin(saturate(along)*pi); float jitter = 0.5 + 0.5*sin(along*83.0 + t*11.0); float w = max(Thickness, 0.001) * (0.55 + jitter*0.9); float ang1 = 0.5 + sin(along*9.0 + t*2.1)*0.35 + sin(along*21.0 + t*3.7 + 1.3)*0.18 + sin(along*47.0 + t*5.9 + 2.6)*0.08; ang1 = frac(ang1); float d1 = abs(angle - ang1); d1 = min(d1, 1.0 - d1); float core1 = (1.0 - smoothstep(0.0, w, d1)) * endFade; float ang2 = frac(ang1 + 0.5 + sin(along*6.0 + t*2.4)*0.12); float d2 = abs(angle - ang2); d2 = min(d2, 1.0 - d2); float core2 = (1.0 - smoothstep(0.0, w*0.75, d2)) * 0.6 * endFade; float ang3 = frac(0.25 + sin(along*14.0 + t*4.3)*0.4 + sin(along*33.0 + t*7.1)*0.15); float d3 = abs(angle - ang3); d3 = min(d3, 1.0 - d3); float core3 = (1.0 - smoothstep(0.0, w*0.55, d3)) * 0.5 * endFade; float halo = (1.0 - smoothstep(0.0, w*3.5, d1)) * 0.3 * endFade; float2 gp = floor(float2(along, angle)*float2(220.0, 120.0) + float2(t*3.0, 0.0)); float h = frac(sin(dot(gp, float2(12.9898, 78.233)))*43758.5453); float grain = smoothstep(0.30, 0.55, h); float flick = 0.72 + 0.28*sin(t*23.0 + along*13.0); float mask = saturate((core1 + core2 + core3)*grain*flick + halo*grain*0.6); return mask;

ElectricSketch

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

Короче

Это не пост «купите мою игру пожалуйста». Это пост «смотрите, что можно дешего сделать, если не бояться закопаться глубже. Если хоть один тех-арт прочитает это и подумает «блин, а чего я сам игру не делаю» — я буду считать задачу выполненной.

Под конец - про объём. Если грубо переводить в аутсорс-прайс по рынку СНГ: лично я один закрывал роли, которые в нормальной студии распределяются между 4-5 специалистами разных специализаций - shader artist, physics programmer, render programmer, character tech artist, VFX artist. Плюс то о чём я выше не писал - UI-система и её шейдерная часть, звуковой дизайн и интеграция, менеджмент проекта и обвязка всего этого в продакшен-пайплайн. По уровню - middle/senior в каждой из этих ролей, не джуновая сборка "как получилось". В деньгах по текущим средним ставкам это где-то 800-900к рублей за 2-3 месяцев работы такой распределённой команды. И это только моя часть - не считая того сколько сделали напарники, они тоже пахали, но это их история, не моя. Всё это параллельно с основной работой, по вечерам и ночам без пива и игр, под сериалы и фильмы про квантовый мир. Считайте это либо фанатизмом, либо овертаймом, либо тем что когда ты тех-арт со стажем - часть задач делается в разы быстрее чем когда собираешь команду под них с нуля.

Если есть технические вопросы - пишите в комменты.

Ну конечно виш листом вы можете меня поддержать.

Дополнение для особенных.

Это -

float h = frac(sin(dot(gp, float2(12.9898, 78.233)))*43758.5453);

классический hash-шейдер для генерации псевдослучайного числа из 2D-координаты. Используется в шейдерах десятки лет. Глубоко иследующие могут прочитать статью с обьяснением тут. Ей 10+ лет.

P.s.

Какое бы у вас ни сложилось впечатление - уточню: сфера настолько необъятная, что я смело говорю "я ни черта не знаю". И чем больше узнаю со временем, тем яснее - она расширяется экспоненциально, охватить её целиком нереально.

35
6
5
3
3
1
31 комментарий