Меши, инстансы и примитивы

Создание мешей, размещение повторяющейся геометрии и настройка готовых форм.

Меши

Mesh — геометрия одного объекта: вершины, треугольники, границы, флаги материала. Хранится она не «как в учебнике», а упакованной: позиции квантованы и сгруппированы в чанки по ~64 треугольника, поэтому вершину движок читает за 3–6 байт вместо 12–36 у обычных форматов — на ESP32 без PSRAM это и память, и скорость (конвейер вершин каждый кадр тащит в разы меньше байт). Из-за этого же примитивы (Sphere, Box, Torus, …) и модели из конвертера почти ничего не добавляют в heap.

Подсистема разделена на формат данных меша, ресурс-меш с владением, ленивыми границами и кодировщиком build(), а также MeshBuilder для пользовательской геометрии. Старое имя Mesh::Builder осталось его псевдонимом.

Игре обычно нужны три вещи: создать меш, посчитать границы и подключить к сцене через MeshInstance (он сам соберёт трансформ и кэши). Всё остальное — по желанию.

Создание меша

Четыре способа — от простого к ручному:

Способ Когда брать
new Sphere(radius, segments) и остальные примитивы Любая стандартная форма
MeshBuilder Геометрия строится кодом: стены, уровни, процедурка
Mesh::create(verts, …) Уже есть готовые массивы Vertex/Face16
new Cube(…) / модели из Convert.py Данные упакованы заранее, читаются из rodata без heap

Примитивы: Cube, Box, Pyramid, Octahedron, Plane, Circle, Cylinder, Cone, Sphere, Torus, Capsule, Helix, TrefoilKnot + модели (Teapot, Suzanne, Bunny). У круглых (Circle, Cylinder, Cone) сегменты зажимаются в 3–64, у Sphere — 3 уровня детализации. Cube/Pyramid/Octahedron читаются из flash-данных вообще без heap и построения.

MeshBuilder — меш из кода

Пушите вершины и грани в любых float-координатах, finish() упаковывает всё в меш. Масштаб подгоняется под авторские координаты. Нормали: по умолчанию сглаживание считается само (среднее по смежным граням с учётом площади), а если нужны жёсткие рёбра — задайте нормаль явно методом normal(...) прямо перед вершиной.

Метод Описание
MeshBuilder(hasUV = false, hasNormals = true) Нужны ли UV и нормали
uint16_t vertex(x, y, z, u = 0, v = 0) Добавить вершину, получить индекс (при нехватке памяти — 0xFFFF)
void normal(x, y, z) Явная нормаль для следующей vertex(...) — плоские грани, рёбра коробок. Остальные вершины получают авто-сглаживание
void triangle(a, b, c) Треугольник по трём индексам
void quad(a, b, c, d) Квадрат двумя треугольниками: (a,b,c) и (a,c,d)
uint16_t vertexCount() / faceCount() Сколько уже добавлено
Mesh *finish(worldSize = 0) Собрать меш; worldSize > 0 масштабирует его до этого размера поперёк, иначе остаются авторские координаты

finish() возвращает nullptr, если нечего упаковывать. После finish() билдер обнуляется и готов строить следующий меш — удобно для пулов однотипной геометрии. Готовый меш освобождается Mesh::destroy(mesh), не delete — примитивы и модели удаляются тем же вызовом.

Mesh::create — меш из массивов

Mesh *m = Mesh::create(verts, vertCount, faces, faceCount, /*hasUV=*/true);

Массивы — авторский формат Vertex/Face16 (позиции уже в квантованном int16, см. раздел «Упаковка нормалей»). create сам пакует их в чанки; исходные массивы остаются вашими. Размер в мире задаётся autoScale(size) — «меш размером size поперёк».

Размер и границы

Метод Описание
void autoScale(size) Растянуть меш до size поперёк (вызов один раз после создания)
const Vector3 &center() / float radius() Центр и радиус охватывающей сферы (считаются лениво, один раз)
void getBounds(center, radius) То же одной функцией
void getLocalExtents(halfExtents) Полуразмеры AABB — для ящиков, коллизий, спавна

Границы нужны пипикингу, фрустуму и теням — движок берёт их сам; свои считать вручную не надо. Считаются лениво при первом обращении; если хочется убрать даже этот разовый хитч с кадра (например, перед стартом уровня) — вызовите ensureBounds() заранее, это прогрев без побочных эффектов.

Внешний вид и флаги

Метод Описание
setTexture(tex) / getTexture() / isTextured() Материал-текстура
setWantsTexture(true) Рисовать «шахматкой», даже если текстуру ещё не назначили
setCastShadows(false) Отключить отбрасывание теней (по умолчанию включено)
setSingleColorLighting(true) Осветить весь меш одним цветом — дёшево, для болванок

Подмешы

Модель может быть покрашена кусками: numSubMeshes(), subMeshColor(i) — цвет куска, subMeshFaceEnd(i) — где он заканчивается по граням. Заполняется конвертером моделей.

Обход геометрии

mesh->forEachPosition([&](const Vector3 &p) { /* мировые координаты вершины */ });
mesh->forEachFace([&](uint32_t face, uint32_t p0, uint32_t p1, uint32_t p2,
                      uint32_t a0, uint32_t a1, uint32_t a2) { /* индексы вершин и атрибутов */ });

Для физики (хуллы, фиттинг), запекания и собственных генераторов. Индексы глобальные по всему мешу.

Низкоуровневый доступ (рендер и инструменты)

Упакованный поток открыт, но игре он почти никогда не нужен: чанки (chunkData(), getChunk(i)), грани (faceData()), атрибуты (chunkAttrs(), attrUV(), attrNormal()), декод позиций (chunkPosDecode(), chunkPosition()). Из полезного:

Метод Описание
numFaces() / numVertices() / numChunks() Сколько чего в меше
hasUV() / hasNormals() / hasSubMeshes() Что в меше есть
chunkXform(chunk, qScale, world) + chunkPoint(...) Декод позиции, совмещённый с трансформом: 1 раз на чанк, без лишних умножений на вершину. Так рендер получает мировые точки из упакованных данных
maxChunkPosCount() / maxChunkAttrCount() Размер наибольшего чанка — под буферы
Формат зашит в данные

Раскладку MeshChunk/Face задаёт конвертер моделей. Поля должны совпадать побайтно, поэтому после изменения формата переконвертируйте все модели.

Пример

// Пандус — меш из кода, без массивов
MeshBuilder b(/*hasUV=*/false, /*hasNormals=*/true);

uint16_t g0 = b.vertex(-1.0f, 0.0f, -1.0f);
uint16_t g1 = b.vertex( 1.0f, 0.0f, -1.0f);
uint16_t g2 = b.vertex( 1.0f, 0.0f,  1.0f);
uint16_t g3 = b.vertex(-1.0f, 0.0f,  1.0f);
uint16_t t1 = b.vertex( 1.0f, 0.6f, -1.0f);   // верх пандуса
uint16_t t2 = b.vertex( 1.0f, 0.6f,  1.0f);

b.quad(g3, g2, g1, g0);   // пол
b.quad(g0, g1, t1, t2);   // наклонная плоскость (нормали посчитаются сами)

Mesh *ramp = b.finish();  // размеры — как нарисовано (2×2 м)
MeshInstance *inst = new MeshInstance(ramp);
inst->setPosition(4.0f, 0.0f, 2.0f);
g_sceneInstances.push_back(inst);   // дальше обычный путь рендера

// ... при выгрузке уровня
Mesh::destroy(ramp);

Инстансы

MeshInstance — один объект в сцене: меш + трансформ (позиция, кватернион-поворот, масштаб) + цвет + свечение + лайтмап. Создаётся new MeshInstance(mesh) и живёт, пока нужен сцене. Трансформ и мировые границы кэшируются с версионированием: пока инстанс стоит на месте, рендер не пересчитывает по нему ничего.

Трансформ

Метод Описание
setPosition(Vector3) / setPosition(x, y, z) Поставить в точку
setRotation(Quaternion) Поворот кватернионом
setEuler(pitchDeg, yawDeg, rollDeg) Поворот в градусах: наклон X, рыскание Y, крен Z
setScale(Vector3) / setScale(x, y, z) / setScale(uniform) Масштаб: покомпонентно или равномерно
setTransform(pos, rot, scl) Всё разом
setWorldMatrix(AffineMatrix) Продвинутый: задать матрицу целиком (масштаб извлечётся)
pos() / getRotation() / getScale() Прочитать текущее
transform() Итоговая матрица мира (собирается лениво, кэшируется)

Сеттеры трансформа всегда помечают кэш грязным, даже если значение не изменилось, — это честная цена «двинул объект».

Внешний вид

Метод Описание
setColor(Color) / color() Тинт меша. Повтор той же краски бесплатный — кэш освещения не перестраивается
setEmissive(enabled, glowColor = WHITE, intensity = 1.2f, shape = kEmissiveShapeAuto) Свечение: пятно по размерам меша. shape — значения перечисления GlowShape; kEmissiveShapeAuto (255) — форма подбирается сама
setEmissiveBoxSize(w, h, d) Своя габаритная коробка свечения (иначе берётся из меша)
isEmissive() Будет светиться в этом кадре (флаг + видимость + меш на месте)
setShadingOverride(mode) Свой шейдинг вместо глобального
setVisible(bool) / isVisible() Выключить отрисовку, не удаляя объект

Свет и тени

Метод Описание
setBlobShadow(true) / getBlobShadow() Дешёвая круглая тень-пятнышко вместо честной (честные тени меша отключаются mesh->setCastShadows(false))
setLightmap(atlas, uv, rect...), hasLightmap() Запечённый лайтмап (заполняет конвертер/запекатель — вручную обычно не нужно)
setIgnoreBakedProbes(bool) Не брать пробный свет из запечки для этого объекта

Границы

center() и radius() — центр и радиус охватывающей сферы в мире (кэшируются, движок сам гоняет по ним фрустум-куллинг, пикинг и тени). Пересчитываются автоматически при любом движении.

Разбросанные пропсы — PlacementSet

Много однотипных объектов (камни, деревья, мусор) одним проходом: массив Placement по 12 байт (индекс пропа, позиция в int16, yaw, цвет) + список мешей. Заполняется конвертером моделей, рисуется через drawProps. Флаг kPlacementVisible включает точку.

Пример

MeshInstance *crate = new MeshInstance(new Box(1.0f, 1.0f, 1.0f));
crate->setPosition(2.0f, 0.5f, -1.0f);
crate->setEuler(0.0f, 30.0f, 0.0f);           // рыскание 30°
crate->setScale(0.8f);                        // равномерный масштаб
crate->setColor(Color::rgb(180, 120, 60));
crate->setBlobShadow(true);

// мигаем светом окна: тот же цвет повторно — дёшево
crate->setEmissive(frame % 2 == 0, Color::rgb(255, 220, 120), 1.5f);

r.draw(crate);

Память и сотни инстансов

Бюджет одного инстанса: 256 байт на сам объект + ленивые кэши при первом рендере: вершинный блок (6–24 байта на вершину меша — мировой и экранный набор выделяется по мере нужды), 2 байта на грань под кэш освещения, 4 байта на чанк под полосный кэш. Для мелкого пропа это порядка 0.5–1 КБ на инстанс.

Когда нужны сотни однотипных объектов (камни, деревья, мусор, декор) — не плодите MeshInstance, а соберите PlacementSet: 12 байт на объект (позиция в int16, yaw, цвет) + несколько общих мешей. Экономия ~50–100×, и рендер идёт одним проходом. Индивидуальные анимации/свечение на такие толпы всё равно не смотрят.

Дополнительные рычаги: mesh->setCastShadows(false) убирает участие в теневом проходе; у emissive/uniform-пропсов движок и так не хранит мировые вершины (кэш вдвое меньше); setSingleColorLighting(true) на меше упрощает освещение.


Базовые примитивы

Готовые простые формы: создал — обёрнул в инстанс — рисуй. Каждый класс наследует Mesh и работает со всем остальным API как обычный меш: цвет, свечение, тени, текстуры, пикинг — через MeshInstance (см. раздел 13). Все размеры — в мировых единицах, форма центрирована вокруг своего нуля.

Делайте один меш на форму и много инстансов, а не меш на объект: инстанс весит ~256 байт + ленивые кэши, меш — свои данные. Тысяча ящиков из одного Box дешевле тысячи Box.

Формы

Класс Конструктор Что даёт
Cube Cube(size = 1) Куб size×size×size с UV. Ноль кучи, ноль построения — геометрия лежит во флеше
Box Box(w = 1, h = 1, d = 1) Бокс произвольных пропорций с UV и плоскими нормалями по граням
Pyramid Pyramid(size = 1) Квадратная пирамида с основанием size и высотой size. Из флеша
Octahedron Octahedron(size = 1) Октаэдр с диагональю size. Из флеша
Plane Plane(w = 2, d = 2, subdiv = 1, uvScale = 1) Двусторонняя плоскость XZ (смотрит вверх и вниз). subdiv — сетка 1..64 (больше = мельче триангуляция для света/воды), uvScale — сколько раз текстура тайлится по всей поверхности
Circle Circle(radius = 1, segments = 16) Двусторонний диск XZ, 3..64 сегментов
Cylinder Cylinder(r = 1, h = 2, segments = 16, uvU = 1, uvV = 1) Цилиндр по Y с гладкими боковыми нормалями и плоскими крышками, 3..64 сегментов
Cone Cone(r = 1, h = 2, segments = 16, uvU = 1, uvV = 1) Конус по Y, остриём вверх, гладкие бока, плоское дно, 3..64 сегментов

Параметры uvU/uvV у Cylinder/Cone и uvScale у Plane — множители UV для тайлящихся текстур; по умолчанию текстура ложится ровно один раз.

Пример

Mesh *ground = new Plane(30.0f, 30.0f);            // пол 30×30
Mesh *crate  = new Box(1.0f, 1.0f, 1.0f);          // один меш ящика
Mesh *barrel = new Cylinder(0.4f, 1.0f, 12);       // бочка

MeshInstance *floorInst = new MeshInstance(ground);
floorInst->setColor(Color::rgb(70, 90, 60));

MeshInstance *crate1 = new MeshInstance(crate);    // два ящика — один меш
crate1->setPosition(-2.0f, 0.5f, 0.0f);
MeshInstance *crate2 = new MeshInstance(crate);
crate2->setPosition(2.0f, 0.5f, 1.0f);
crate2->setEuler(0.0f, 45.0f, 0.0f);

r.draw(floorInst);
r.draw(crate1);
r.draw(crate2);

Производительность

  • Cube/Pyramid/Octahedron — геометрия в rodata: их «создание» — это присвоение двух полей, кучи они не касаются вообще. Это самые дешёвые меши движка.
  • Остальные строятся один раз при создании: квантование в int16 + упаковка нормалей в 2 байта. Временные буферы выделяются ровно под сегменты и освобождаются сразу.
  • Для референса: цилиндр на 16 сегментов здесь — 64 грани и ~30 уникальных вершин в чанке; «наивная» генерация (каждое кольцо стороны отдельным рядом вершин на каждое деление высоты) раздувает это в ~9 раз по вершинам и ~16 по граням. В Pip3D такого нет — позиции внутри чанка дедуплицируются, боковая поверхность — два кольца независимо от «высоты в сегментах».
  • Ничего из этого файла не лежит в IRAM — и правильно: примитивы работают один раз при создании сцены. Горячий (кадровый) путь рендера — конвейер, а не генераторы форм.

Капсула

Capsule — цилиндр с полусферическими шапками, классическая форма для персонажей, пуль, кнопок, фишек. Наследует Mesh и работает со всем API как обычный меш (инстанс, цвет, тени — раздел 13). Нормали гладкие по всей поверхности, UV развёрнуты по длине дуги: шов один, на полюсах текстура не стягивается в точку.

Конструктор

Параметр Что задаёт
radius Радиус капсулы
height Полная высота по Y, включая шапки
segments = 12 Деления по окружности (3..64)
rings = 6 Кольца триангуляции на каждую полусферу (1..16)

height ≤ 2*radius — цилиндрической части нет, получается UV-сфера радиуса radius. Временные буферы выделяются ровно под выбранные segments/rings и освобождаются сразу после постройки; охватывающая сфера записывается готовой (полюса учтены точно).

Пример

Mesh *pawn = new Capsule(0.4f, 1.4f, 12, 6);   // корпус пешки
Mesh *orb  = new Capsule(0.5f, 1.0f, 16, 8);   // height == 2*radius: сфера

MeshInstance *inst = new MeshInstance(pawn);
inst->setPosition(0.0f, 0.7f, 0.0f);
inst->setColor(Color::rgb(220, 180, 90));
r.draw(inst);

Производительность

  • На 12 сегментов и 6 кольцах: 180 вершин и 288 граней — против ~675 вершин и 1152 граней у «наивной» генерации с полным набором одинаковых колец на каждое деление высоты цилиндрической части. Меньше вершин — быстрее каждый кадр.
  • Позиции внутри чанка дедуплицируются: полюс схлопывается в одну точку, шовный столбец — в позиции основного кольца.
  • Построение — один проход упаковки чанков, без повторного хэширования; квантование координат без вызовов libm.

Спираль

Helix — трубка-пружина, свитая по винтовой линии: пружины, спирали ламп, кабели, бархатные прутки. Наследует Mesh, работает со всем API как обычный меш (раздел 13). Трубка идёт по parallel-transport фрейму — сечение не закручивается вокруг оси, и трубка не «сучится» на крутых витках. Оба торца закрыты плоскими крышками.

Конструктор

Параметр Что задаёт
radius Радиус винтовой линии (ось спирали — Y)
height Полная высота по Y
turns Число витков
segments = 48 Делений на всю длину пути (3..128)
tubeSegments = 8 Граней в сечении трубки (3..32)
uvScaleU / uvScaleV Тайлы текстуры вдоль пути / вокруг трубки
tubeRadius = -1 Радиус самой трубки; по умолчанию 0.3*radius

UV: u — вдоль пути, v — по окружности сечения; шов замкнут дублирующим столбцом, текстура ложится без растяжений. Крышки имеют свои нормали и полярные UV.

Пример

Mesh *spring = new Helix(0.35f, 1.8f, 3.0f, 48, 8);              // пружина
Mesh *coil   = new Helix(0.6f, 0.8f, 5.0f, 64, 10, 1, 1, 0.06f); // плотный кабель

MeshInstance *s = new MeshInstance(spring);
s->setPosition(0.0f, 0.9f, 0.0f);
s->setColor(Color::rgb(190, 195, 205));
r.draw(s);

Производительность

  • Дефолтная спираль (3 витка, 48×8) — 784 грани и ~460 вершин, ~7 мс построения один раз при старте сцены. Дальше меш рендерится как обычный чанковый: 13 чанков дают мелкое зерно полосного куллинга — невидимые витки не обрабатываются.
  • Тригонометрия пути и сечений — по таблицам (LUT), квантование координат без libm, упаковка меша — один проход.
  • Не нужна труба целиком — уменьшайте segments/tubeSegments: цена построения и рендера растёт пропорционально их произведению.

Сфера

Sphere — равномерная икосфера: треугольники одного размера по всей поверхности (без сплюснутых полюсов широтно-долготной сетки), гладкие нормали. Наследует Mesh, работает со всем API как обычный меш (раздел 13). UV развёрнуты равнопромнуто, шов и полюса разрежены корректно — текстуры ложатся без складок.

Конструктор

Параметр Что задаёт
radius Радиус сферы
quality = 8 Уровень детализации — ступени, не сегменты: 1..8 → ~80 граней, 9..16 → ~320, 17+ → ~1280. Промежуточные значения округляются вниз до ступени

Ступени выбраны не случайно: каждое подразделение икосферы учетверяет число граней, плавной шкалы между ними не существует. Для коллизий/физики хватает низкого уровня, для крупных объектов на экране — среднего; высокий берите точечно.

Пример

Mesh *ball   = new Sphere(0.5f, 16);   // 320 граней - игровой объект
Mesh *detail = new Sphere(1.2f, 32);   // 1280 граней - крупный план

MeshInstance *b = new MeshInstance(ball);
b->setColor(Color::rgb(90, 190, 255));
r.draw(b);

Производительность

  • Сетка равномерная: при равном числе граней икосфера покрывает сферу качественнее широтно-долготной (у той полюса в 4-6 раз плотнее экватора) — меньше граней за ту же картинку.
  • Построение: подразбиение с хэш-кэшем рёбер (без квадратичного поиска), UV через быстрые аппроксимации atan2/asin (ошибка ~0.0002 радиана — на порядок ниже шага UV-квантования), один проход упаковки чанков. Служебные буферы — точного размера, стек не используется.
  • По сравнению с «наивной» UV-сферой на 16 сегментов (561 вершина / 1024 грани, ~20 КБ кучи) — 320 граней и втрое меньше памяти, при том же времени построения.

Тор

Torus — бублик вокруг оси Y: кольца, шины, кольца порталов, зрачки-«пончики». Гладкие нормали по всей поверхности, замкнут по обоим направлениям. Наследует Mesh, работает со всем API как обычный меш (раздел 13).

Конструктор

Параметр Что задаёт
majorRadius Радиус осевой окружности (до центра трубки)
minorRadius Радиус самой трубки
segments = 16 Колец вокруг большого круга (3..64)
tubeSegments = 8 Граней в сечении трубки (3..64)
uvScaleU / uvScaleV Тайлы текстуры по большому кругу / вокруг трубки

UV замкнуты шовной строкой и столбцом — текстура стыкуется без обратной протяжки по обоим кругам.

Пример

Mesh *ring  = new Torus(1.0f, 0.3f);            // классический бублик
Mesh *donut = new Torus(2.6f, 0.65f, 24, 12);   // гладкий крупный тор

MeshInstance *r1 = new MeshInstance(ring);
r1->setPosition(0.0f, 1.2f, 0.0f);
r1->setEuler(0.0f, 0.0f, 0.0f);                 // лежит плашмя (ось Y)
r1->setColor(Color::rgb(220, 170, 60));
r.draw(r1);

Производительность

  • Построение — два LUT-прохода синуса/косинуса (по кольцам и сечениям) и один проход упаковки чанков; квантование без libm.
  • Вершин в итоговом меше ровно segments × tubeSegments позиций: шовные строка и столбец схлопываются дедупликацией в атрибуты, не раздувая геометрию.
  • Внешний радиус majorRadius + minorRadius записан в границы при создании — куллинг и пикинг точны без пересчёта.

Узел-трилистник

TrefoilKnot — трубка, свитая по кривой узла-трилистника: декоративные кольца, логотипы, «магические» артефакты. Замкнутый свип без шва скрутки: фрейм переносится параллельным транспортом вокруг всей петли, рассогласование угла на стыке измеряется и размазывается равномерно назад по витку — трубка сходится сама в себя ровно. Наследует Mesh, работает со всем API как обычный меш (раздел 13).

Конструктор

Параметр Что задаёт
scale Общий размер (радиус трубки = 0.55*scale, пропорции узла фиксированы)
segments = 64 Делений на всю петлю (3..128). Для чистой геометрии нужно ≥ ~24: кривая узла крутая, при более грубом сэмплинге поверхность самопересекается
tubeSegments = 12 Граней в сечении трубки (3..64)
uvScaleU / uvScaleV Тайлы вдоль петли / вокруг трубки

UV замкнуты швом по обоим направлениям; стык первого и последнего ряда точный благодаря коррекции закрутки.

Пример

Mesh *knot = new TrefoilKnot(0.5f, 64, 12);   // классический узел

MeshInstance *k = new MeshInstance(knot);
k->setPosition(0.0f, 1.0f, 0.0f);
k->setEuler(0.0f, t * 20.0f, 0.0f);           // медленно вращаем
k->setColor(Color::rgb(255, 180, 50));
r.draw(k);

Производительность

  • Дефолт (64×12, 1536 граней) строится ~14 мс один раз; основная цена — параллельный транспорт фрейма и упаковка чанков, а не тригонометрия.
  • Границы точные: самый дальний точки пути — ровно 3*scale от центра (доказуемо по параметризации), плюс радиус трубки 0.55*scale → сфера 3.55*scale без запаса.
  • Не нужен детальный узел на весь экран — снижайте segments/tubeSegments: цена пропорциональна их произведению.