Думаю, многие инди‑разработчики сталкивались с ситуацией: делая небольшую игру в Godot, вам хотелось просто взять кисточку и нарисовать грязь, мох или потёртости прямо поверх 3D‑модели в самом редакторе движка. Нужно добавить немного грязи по краям — и начинается: уходим в Blender / 3D Coat / тяжёлый Substance Painter, текстурируем, печём маски, экспортируем, возвращаем в движок, смотрим во вьюпорте, находим косяк, повторяем. Для продакшен‑ассета это нормальный пайплайн, для прототипа или маленькой инди‑игры — непомерно дорого по времени.
К тому же, мне посчастливилось (или не очень) быть обладателем довольно‑таки старенького ПК прямиком из 2008 года, а это, на минутку, Core 2 Duo E8400, 6 GB RAM DDR2 (расширенные из 2 GB, потому что иначе выжить невозможно, хах), ATI Radeon HD Series 4600. Использование таких программ, как Substance, для меня невозможно в принципе (если только не ковырять какие‑то совсем древние версии, в работоспособности которых с данной конфигурацией я тоже не уверен), Blender выше версии 2.79 открывается только с программной эмуляцией Mesa3D, что делает его непригодным для комфортного использования.
В итоге мой арсенал — Cinema 4D R21, 3DS Max для особых случаев, Blender с костылями — только на крайний случай в качестве комбайна.
Также проблема в том, что версии Godot выше 4.4.1 я не могу запускать физически — новые сборки требуют поддержки от процессора инструкций AVX (и, вероятно, SSE4.2), коих у меня нету, а существующие решения для Godot часто либо предназначены для новых версий Godot (4.5/4.6+), либо слишком громоздки, либо вовсе сломаны (как VPainter, оставшийся в эпохе Godot 3).
Поэтому я решил написать свой плагин MatCaster — минималистичный инструмент для смешивания материалов, рисования масок и вершинных весов, который будет работать даже на «калькуляторе» и работает на более старых версиях Godot без проблем. Получился некий «встроенный Substance на минималках».

Проблема № 1: Как не убить слабый GPU убер‑шейдером?
Как я уже говорил, моя видеокарта хоть и не из времён палеолита, но к Древнему миру точно ближе. Она поддерживает максимум DirectX 10.1 и OpenGL 3.3 (да и то с оговорками). Из‑за этого даже Godot 4.4.1 ругается на старте и принудительно переводит отрисовку под Windows в режим ANGLE.
ANGLE (Almost Native Graphics Layer Engine) — это открытая графическая библиотека от компании Google. Она выполняет роль «переводчика» между мобильным API OpenGL ES и нативными графическими интерфейсами конкретной платформы (в моём случае — DirectX).
Если мы хотим смешивать 5–10 слоев материалов (база, ржавчина, грязь, мох, царапины), нам нужен шейдер. Обычно пишут один огромный убер‑шейдер, который принимает в себя кучу текстур. Но на слабых видеокартах (особенно в режиме Compatibility/OpenGL) есть жесткие лимиты на количество текстурных сэмплеров (часто не более 16). Если лимит превышен — Godot просто отрендерит противный серый материал.

Считаем: каждый слой смешивания — это альбедо, нормали и маска (3 сэмплера), плюс общие текстуры масок, по четыре слоя на текстуру. Пять слоёв — уже 15 сэмплеров. Статичный «шейдер на все случаи» либо не линкуется на слабых GPU, либо простаивает на сильных «вхолостую».
Решение: Процедурная генерация шейдера и физическое «прощупывание» видеокарты.
Изначально я планировал просто использовать стандартный статический шейдер, как это делает большинство аддонов для Godot. Первое, с чем пришлось столкнуться: как оказалось, в языке шейдеров Godot (gdshader) нет массивов сэмплеров, как я изначально думал, пытаясь отыскать в документации примеры. Нельзя написать uniform sampler2D layers[16] и гонять цикл. Каждый слой — это отдельные именованные uniform'ы: layer0_albedo, layer0_normal, layer1_albedo...
В итоге вместо статичного шейдера я написал генератор, который собирает код spatial‑шейдера ровно под то количество слоёв, которые сейчас используется. Если у вас 1 слой — объявляем минимальное количество юниформ. 5 слоёв? Динамически добавляем новые по мере добавления через интерфейс. Сгенерированный код — обычный gdshader‑файл, который лежит рядом со сценой и читается обычным Godot.
И главная фишка: сцены, сохранённые с MatCaster, рендерятся без плагина. Шейдер генерируется самодостаточным, материалы — обычные.tres с встроенными масками. Можно отдать проект другому человеку без аддона — всё продолжит работать, а при желании он поставит плагин и продолжит редактировать те же слои.

Лимит текстурных юнитов Godot не отдаёт — никакого API для этого нет (по крайней мере, для данной версии Godot). Чтобы узнать реальный предел конкретной видеокарты пользователя, плагин делает скрытый стресс‑тест при запуске (GPU Probe):
Создает невидимый SubViewport.
Пытается отрендерить зелёный квадрат (Quad), постепенно наращивая количество слоев в генерируемом шейдере.
Читает пиксели и проверяет через простой бинарный поиск: если квадрат зелёный — GPU слинковал шейдер успешно. Если серый/черный — лимит достигнут.
Так плагин сам понимает, когда нужно заблокировать кнопку «Добавить слой», спасая проект от падения рендера.
Конечно, пока что проверка тоже не идеальна. Она меряет «чистый» потолок: сэмплеры самого шейдера. Но в реальной сцене рендерер резервирует юниты под собственные нужды. На моей видеокарте GPU‑проба обещает пять слоёв, но в сцене с тенями линкуются максимум только четыре. Практический вывод: считайте запас в один слой. В будущих версиях я буду проверять сцену более честно — со светом с тенями. Это сразу даст худший реальный случай, либо попытаюсь найти ещё более эффективный метод оценки видеокарты.
Тут же стоит предостеречь разработчиков: рекомендуется смешивать не более четырёх слоёв (включая самый первый Base‑слой), если вы точно не уверены, что в вашу игру будут играть обладатели современных видеокарт. В моём же аддоне в будущем планируется добавить запекание материалов (объединение слоёв), упрощённый режим шейдера (только Albedo/Color), что гипотетически позволит «утрамбовывать» больше слоёв на один объект, чтобы это корректно обрабатывалось даже на слабейших видеокартах.
Проблема № 2: В Godot 4 нет рейкаста с получением UV
В Godot 4.4.1 встроенного рейкаста по мешу с UV просто нет (Mesh.intersect_ray в этой версии не существует). Физика в редакторе — тоже не вариант.
Рейкаст (Mesh Raycasting) — это алгоритм в трехмерной графике и разработке игр, который проверяет пересечение выпущенного луча (ray) с полигональной сеткой трёхмерного объекта.
Многие спросят: «Зачем писать свой, если есть физический PhysicsDirectSpaceState3D или геометрический Geometry3D.ray_intersects_triangle()?».
Во‑первых, физический рейкаст Godot возвращает точку и нормаль, но не даёт UV. Во‑вторых, метод из Geometry3D возвращает только 3D‑точку пересечения. Чтобы найти UV, нам пришлось бы вызывать вторую функцию get_triangle_barycentric_coords, а перед этим — циклом искать нужный треугольник. Два вызова C++ API на каждый треугольник внутри обхода BVH при движении мыши убили бы производительность.
В итоге пришлось писать свой класс для BVH. При выборе модели плагин локализует меш, вытягивает из него вершины и строит дерево ограничивающих объемов (BVH — Bounding Volume Hierarchy).
BVH (Bounding Volume Hierarchy, или иерархия ограничивающих объёмов) — это специальная структура данных в виде дерева, которая используется в компьютерной графике и разработке игр для быстрого поиска пересечений и ускорения расчётов.
Когда пользователь водит кистью, плагин пускает луч, находит нужный треугольник через алгоритм Моллера — Трумбора, вычисляет барицентрические координаты точки пересечения и интерполирует UV‑координаты.
Попадание даёт барицентрическую интерполяцию UV и, бонусом, локальную UV‑плотность — сколько пикселей маски приходится на мировую единицу в этом месте развёртки. Из неё выводится радиус кисти в пикселях: размер кисти задан в мировых единицах, и нарисованный след совпадает с курсором на экране, даже если плотность развёртки неравномерна. Дальше штампы идут в UV‑пространство: между двумя попаданиями мазок интерполируется, штампы ставятся на точных кратных интервала вдоль всей траектории. Это важно: частота событий мыши разная (быстрый взмах — событий мало), и если раскладывать штампы на сегмент, шаг дрожал бы от скорости мыши — слайдер «интервал» фактически не работал бы.
Всё это работает в реальном времени и реализовано на GDScript (было бы слишком накладно делать и компилировать плагин на C++, особенно в моём случае).
# Фрагмент BVH-рейкастера. Адаптация оригинального алгоритма Моллера-Трумбора func _ray_triangle(orig: Vector3, dir: Vector3, tri: int, out: Array) -> bool: var ia: int = _tri[tri * 3] var ib: int = _tri[tri * 3 + 1] var ic: int = _tri[tri * 3 + 2] var a: Vector3 = _positions[ia] var e1: Vector3 = _positions[ib] - a var e2: Vector3 = _positions[ic] - a var pvec: Vector3 = dir.cross(e2) var det: float = e1.dot(pvec) if absf(det) < 1e-10: return false var inv_det: float = 1.0 / det var tvec: Vector3 = orig - a var u: float = tvec.dot(pvec) * inv_det if u < -1e-5 or u > 1.0 + 1e-5: return false var qvec: Vector3 = tvec.cross(e1) var v: float = dir.dot(qvec) * inv_det if v < -1e-5 or u + v > 1.0 + 1e-5: return false var dist: float = e2.dot(qvec) * inv_det if dist < 1e-5: return false out[0] = dist out[1] = clampf(u, 0.0, 1.0) out[2] = clampf(v, 0.0, 1.0 - clampf(u, 0.0, 1.0)) out[3] = ia out[4] = ib out[5] = ic return true

Проблема № 3: четыре слоя в одной текстуре и вершинные веса (Vertex Paint)
Маска слоя — это, по сути, один канал. Хранить каждую маску отдельной RGBA‑текстурой расточительно и по памяти, и по сэмплерам. Поэтому решение примерно такое же, как в ORM: одна текстура RGBA вмещает маски четырёх слоёв, слой N читает канал N%4 из текстуры маски. Пять слоёв смешивания — две текстуры вместо пяти.
Маски живут как CPU‑массивы байт и сливаются в ImageTexture после каждого мазка — рисование работает с обычными массивами, это дёшево и тривиально откатывается. Отмена действий устроена патчами: мазок запоминает затронутый прямоугольник и байты «до», EditorUndoRedoManager получает компактную операцию, а не дамп всей текстуры (что жрало бы память и мгновенно заполнило её, как делают некоторые прожорливые графические редакторы, уповая на бесконечные запасы RAM у пользователей). На диске сохраняется финальная копия маски в PNG, лежащая в создаваемой аддоном папке под свои ресурсы.
Вертексный режим поначалу преподнёс самый противный баг. Наивная реализация — цепочка дискретных штампов, каждый из которых добавляет немного веса вершинам под кистью. На ровных местах выглядит нормально, однако на криволинейных объектах сразу вскрылся неприятный момент: при рисовании кистью весов наблюдались равномерные швы по всей поверхности объекта, образующие некую «сетку», линии которой точно повторяли форму идущих вдоль граней меша модели. Причина была в том, что при рисовании где‑то вершине присваивался вес дважды, где‑то всегда только один раз, затем шейдер ещё и усиливал эту разницу встроенным сглаживанием (которое всегда округляло значения одинаково). Как результат — регулярно расположенные ямки‑швы, «рябь» по краю мазка и тому подобное «глитчи».
Как оказалось, дело в том, что в текстуре (с разрешением 1024×1024 и выше) пиксели лежат довольно плотно. А вот вершины на 3D‑модели распределены неравномерно. Когда мы делаем аддитивное наложение (добавляя вес с каждым шагом мыши), вершины, оказавшиеся на пересечении нескольких «штампов» кисти, накапливают вес быстрее, чем соседние. А функция сглаживания (smoothstep), как я уже говорил, в шейдере безжалостно превращает эту крошечную разницу в жесткую границу.

Вместо того чтобы оставлять цепочку штампов, плагин вычисляет точную капсулу (отрезок между прошлой и текущей позицией мыши) и считает расстояние от каждой вершины до этого отрезка. Новый цвет вертекса зависит только от близости кисти к ней за весь мазок, а не от того, сколько раз мышка прислала событие InputEventMouseMotion. Поле весов становится чистой функцией расстояния до траектории кисти. Вот как выглядит ядро этой логики на GDScript:
# Вызывается для каждой вершины в радиусе кисти. # wmax — кэш максимального ослабления для вершины в текущем мазке # before — цвет вершины до начала мазка func _vertex_weight_apply(i: int, colors: PackedColorArray, f: float, gain: float, channel: int, erase: bool, before: PackedColorArray, wmax: PackedFloat32Array) -> bool: # 1. Сдвинуть вес вершины может только касание СИЛЬНЕЕ всех предыдущих в этом мазке. # Слабые и повторные проходы по одному месту — холостые. if wmax.size() == colors.size(): if f <= wmax[i]: return false # Кисть уже касалась этой вершины сильнее, пропускаем wmax[i] = f var c := colors[i] var val: float = c[channel] # 2. База смешивания: цвет, который был на старте мазка var base: float = before[i][channel] if before.size() == colors.size() else val var target: float = 0.0 if erase else 1.0 # 3. lerp от ИСХОДНОГО цвета к цели, используя текущую силу falloff val = base + (target - base) * clampf(gain, 0.0, 1.0) * f if val != c[channel]: c[channel] = val colors[i] = c return true # Цвет изменился return false
Проблема № 4: Производительность и падение редактора от пересборок мешей
Но как обновить цвета вершин на модели? Стандартный путь в Godot — это получить массивы через mesh.surface_get_arrays(), изменить ARRAY_COLOR, вызвать mesh.clear_surfaces() и пересобрать её через mesh.add_surface_from_arrays().
Если делать это при каждом движении мыши, редактор начинает биться в агонии. Инспектор судорожно обновляет UI, гизмо выделения мигает, а FPS падает до нуля, потому что движок считает, что ресурс меша полностью изменился. Полная пересборка убивает плавность. Такое точно не подходит.
В итоге было решено использовать путь в обход CPU (прямое обновление GPU‑буфера)
Оказалось, что в Godot ArrayMesh имеет низкоуровневый метод surface_update_attribute_region (хоть и «опасный»). Он позволяет залить сырые байты прямо в VRAM (в буфер атрибутов), не трогая структуру меша и не уведомляя редактор об изменениях ресурса — то что надо.
Чтобы это заработало, нужно знать, как движок кодирует атрибуты под капотом. Цвета вершин хранятся не как 4 float, а как 4 байта (RGBA8). При движении мыши плагин собирает измененные вершины, кодирует их в байты и отправляет непрерывными «прогонами» (runs) прямо в GPU:
# Кусок кода, который спасает FPS при Vertex-рисовании func _write_color_region(mesh: ArrayMesh, surf: int, v: Dictionary, stride: int) -> void: var dirty: PackedInt32Array = v["gpu_dirty"] # Массив индексов измененных вершин var colors: PackedColorArray = v["colors"] dirty.sort() # ... логика поиска непрерывных блоков (run_start, run_end) ... # (пропущена ради компактности) # Сливаем прогон измененных вершин в GPU var count: int = run_end - run_start + 1 var buf := PackedByteArray() buf.resize(count * stride) # stride - размер атрибутов одной вершины в байтах for i in range(run_start, run_end + 1): var c: Color = colors[i] var p: int = (i - run_start) * stride # Кодируем Color во внутренний формат движка (uint8) buf.encode_u8(p, int(clampf(c.r * 255.0, 0.0, 255.0))) buf.encode_u8(p + 1, int(clampf(c.g * 255.0, 0.0, 255.0))) buf.encode_u8(p + 2, int(clampf(c.b * 255.0, 0.0, 255.0))) buf.encode_u8(p + 3, int(clampf(c.a * 255.0, 0.0, 255.0))) # Прямая запись в память видеокарты без пересборки меша: mesh.surface_update_attribute_region(surf, run_start * stride, buf)
Грязный хак: чтобы правильно вычислить stride (смещение), пришлось написать функцию, которая эмулирует логику C++ исходников Godot, считывая format поверхности и суммируя размеры UV, UV2 и Custom Data.
В итоге на меше в 91 тыс. вершин один взмах виртуальной кисточкой укладывается в ~16 мс. Полная, тяжелая структурная пересборка (clear_surfaces + add_surface) происходит только в момент отпускания кнопки мыши (на конце мазка), чтобы зафиксировать состояние для системы Undo/Redo и сохранения сцены. Но и это занимает считанные миллисекунды — я на своём Core 2 Duo даже не замечаю задержек.
В Godot ресурсы, как правило, общие. Если вы начнете красить вершины на импортированной модели.gltf или.obj, вы измените импортированный ресурс для всех инстансов в игре. Чтобы избежать гнева пользователей, перед первым мазком плагин делает локализацию меша. Он вызывает mesh.duplicate(true) и, что самое главное, принудительно очищает путь: localized.resource_path = "". Если пользователь пытался раскрасить базовый примитив (например, BoxMesh), плагин на лету конвертирует его в ArrayMesh с помощью SurfaceTool.“”
В метаданные ноды записывается путь к оригиналу, поэтому в плагине есть кнопка «Вернуть исходное» — она моментально откатывает меш к заводскому состоянию, сбрасывая все нарисованные веса. Именно в ArrayMesh превращаются и параметрический встроенные модели Godot, и импортированные модели.
Проблема № 5: Кисти. Как затащить формат Photoshop (.abr) в движок?
Рисовать стандартным круглым градиентом скучно и непрофессионально. Нужны альфа‑кисти. Можно было заставить пользователя вручную нарезать PNG‑файлы (их поддержка, разумеется, есть), но у каждого художника уже есть гигабайты кистей из Photoshop (формат ABR).
Формат ABR — это та еще солянка. Мало того, что существует куча версий (v1, v2, v6, v7, v10), так еще и пиксели могут лежать как в сыром виде, так и сжатые старым маковским (я не люблю маки) алгоритмом PackBits RLE. Photoshop разных лет пишет сэмпл‑кисти по‑своему. При разборе реальных файлов выяснилось, что между «канонической» реализацией импорта подобных кистей (например, в исходниках GIMP) и тем, что генерируют другие инструменты, есть неприятные расхождения в структуре байтов.

Немного помучавшись, в итоге получилось написать методом проб и ошибок алгоритм импорта. Плагин читает байты, распаковывает RLE, конвертирует 8/16-битные данные в Image и раскладывает их по папочкам в виде.png файлов внутри проекта. Теперь импорт крутых кистей происходит в пару кликов прямо в редакторе. Конечно, есть проблема, из‑за которой не все версии ABR импортируются, но, думаю, я смогу это либо решить в будущих версиях, либо это вообще не проблема — сейчас распарсить ABR‑наборы через онлайн‑конвертеры и извлечь PNG не составляет особого труда. Посмотрите, как это выглядит:
# Распаковка PackBits RLE прямо на GDScript static func _rle_decode(r: _Reader, out_size: int, height: int) -> PackedByteArray: var out := PackedByteArray() out.resize(out_size) # PackBits сначала хранит длины всех строк в u16 var row_lens := PackedInt32Array() row_lens.resize(height) for i in range(height): row_lens[i] = r.u16() var wp := 0 for row in range(height): var packed := r.bytes(row_lens[row]) var j := 0 var total: int = packed.size() while j < total and wp < out_size: var n: int = packed[j] j += 1 # В GDScript нет типа int8 (byte со знаком), делаем преобразование сами… # packed[j] возвращает 0..255. Если бит знака установлен, конвертируем вручную: if n >= 128: n -= 256 if n < 0: # Если n от -1 до -127: следующий байт повторяется (-n + 1) раз if n == -128: continue # По спецификации -128 это no-op (пустая операция) var cnt: int = -n + 1 if j >= total: break var b: int = packed[j] j += 1 for _k in range(cnt): if wp >= out_size: break out[wp] = b wp += 1 else: # Если n >= 0: просто копируем (n + 1) литеральных байт var cnt2: int = n + 1 var take: int = mini(cnt2, total - j) for k in range(take): if wp >= out_size: break out[wp] = packed[j + k] wp += 1 j += cnt2 return out
Что в итоге умеет плагин?
У каждого слоя — альбедо и карта нормалей, оттенок, ползунки roughness и metallic, масштаб нормалей, режимы смешивания (Mix, Add, Multiply, Overlay, Soft Light), трансформация UV и height blend. Параметры редактируются в обычном инспекторе Godot.
Рисование прямо в 3D‑редакторе. Инструменты «Кисть», «Ластик», «Размытие» и «Палец» (Smudge, как в фотошопе).
Упрощенные хоткеи для более удобной работы.
Параметры кисти: размер, давление, мягкость, интервал, скаттер‑кисть, поворот.
Рисование двумя методами: Vertex‑веса и PNG‑маски (если открыть их просто через просмотр изображений, они могут выглядеть странно или быть пустыми).
Импорт своих кистей в формате.PNG или ABR.
Стохастический гекс‑тайлинг. Чтобы повторяющиеся текстуры (например, трава или камень) не выглядели как сетка при отдалении/взгляде под углом (так называемый эффект «паттерна»), в генерируемый шейдер внедрён алгоритм стохастического тайлинга (Mikkelsen & noddingSloth). Он разбивает текстуру на гексагоны и смешивает их.
Undo/redo через штатный EditorUndoRedoManager.
Итоговый шейдер может работать без плагина. Плагин необходим лишь для работы в редакторе и генерации самих масок/шейдеров.
Заключение
Я создавал этот инструмент в первую очередь как утилиту для себя, стараясь выжать максимум из производительности и не перегружать редактор. Он далек от монструозных корпоративных решений и не является заменой Blender/Substance/ещё чего‑то, что создавалось очень умными и богатыми дядями, но со своей задачей — быстро затекстурить уровень, нарисовать мох на стенах или грязь на машине, в целом справляется.
Я потратил довольно много времени и сил на этот плагин, находясь довольно в жёстких финансовых ограничениях, поэтому отдам его по принципу свободной цены. Если инструмент сэкономит вам время или вы хотите поддержать дальнейшую доработку, буду очень благодарен за любую помощь.
Для тех, у кого сейчас совсем нет денег или возможности помочь, но плагин нужен в проект — готов поделиться архивом в обмен на реальное техническое тестирование (так как я сам не могу проверить многие моменты, увы). Скриншоты распаковки архива в папку или сообщения в стиле «всё работает норм» или «ничё не работает, плагин — фигня» ценности не несут. Профессиональный отзыв — это запуск плагина в режимах Forward+ или Mobile на современных видеокартах (RTX / GTX / Radeon / Intel Graphics), проверка работы всех инструментов (скаттер, блюр, палец) и предоставление логов консоли Godot (ошибки, предупреждения, просадки FPS) при компиляции шейдеров (при наличии таковых в логах, разумеется).
Пишите в сообщения группы ВКонтакте или Телеграм:
— ВКонтакте: https://vk.ru/deblinux_dev?w=wall-241850972_2
— Telegram: https://t.me/deblinux_dev/27
Что дальше (при условии, что мне хватит сил развивать аддон)
Ближайшая большая задача — запекание слоёв. Идея: несколько отрисованных слоёв сводятся в одну текстуру базового слоя, слоты освобождаются, и можно добавлять новые — лимит видеокарты перестаёт быть потолком общего числа слоёв. Технически это GPU‑запекание, повторно использующее тот же сгенерированный код смешивания (переписывать математику на CPU нельзя — расхождение float‑точности даст швы, артефакты и тому подобное весёлые штуки). И, как уже говорил, честный GPU‑Probe под реальные сцены со светом, тенями и тому подобное, чтобы уточнить макс. количество слоёв, поддерживаемых ПК. Упрощённые варианты шейдеров (only‑Albedo Layers), ещё несколько видов проекции, которые бы не зависели от UV‑развёртки.
P. S. У автора установлена раскладка Ильи Бирмана, что объясняет французские кавычки и длинное тире. Между прочим, сие явление является нормой с точки зрения типографики русского языка ?.