Конвейер мешей и отсечение

Отсечение объектов, входной слой отрисовки мешей и кэш результатов между кадрами.

Отсечение

Ответ на вопрос «виден ли этот объект вообще» до любой работы с треугольниками. Внутри — CullView: снимок активной камеры, который движок собирает сам один раз за кадр (вместе с проектором). Дальше проверка сферы — это 13 умножений в худшем случае и 3, если объект позади камеры; никаких мировых плоскостей, синусов и скрытых глобальных переменных.

Границы теста совпадают с Frustum::testSphere бит в бит (сфера, касающаяся плоскости, считается видимой) — проверено на железе на 8448 тестах за прогон. Отличие в скорости: вместо шести точечных произведений по мировым плоскостям — три скалярных произведения с камерным базисом и попарные проверки боковых плоскостей.

CullView

Поле Смысл
pos, fwd, right, up Поза камеры: точка и единичные оси (с учётом тряски — те же, что у проектора)
projScale Пикселей на метр на глубине 1 м — перевод мирового размера в экранный
nearZ, farZ Ближняя и дальняя плоскости, метров
Метод Описание
void build(const Camera &cam, const Viewport &vp) Собрать снимок камеры. Движок вызывает сам за кадр; вручную — только если рендерите своим конвейером
SphereHit testSphere(center, radius) Сфера против пирамиды за один проход. Возвращает {visible, inside, eyeZ}: видна ли хоть частично, целиком ли внутри, глубина центра вдоль взгляда
float eyeZ(const Vector3 &p) Глубина точки вдоль взгляда — то же число, что w проектора
float screenRadius(radius, z) Радиус сферы на экране в пикселях на глубине z (z > 0)

Поле inside — бесплатный бонус однопроходного теста: когда сфера целиком внутри пирамиды, движок пропускает покомпонентные проверки чанков меша. Своему коду он тоже пригодится — например, чтобы не проверять дальше фрустум для уже принятого объекта.

Выключатель

Метод Renderer Описание
void cullFrustum(bool on) / bool cullFrustum() Вкл/выкл отсечения по пирамиде (сфера инстанса + AABB чанков) и чтение состояния. Включено по умолчанию

Отсечение консервативное — видимое не теряет никогда, поэтому почти всегда его нужно держать включённым: выключение означает «рисовать всё, даже за спиной камеры», и кадр становится дороже ровно на всю отброшенную работу. Смысл выключателя — отладка и редакторы. Он не касается соседних систем: за задние грани отвечает cullBackfaces(true/false), за подпиксельные объекты движок и так не платит (радиус меньше пикселя пропускается всегда).

Свободная функция

Функция Описание
float eyeZ(p, camPos, camFwd) Глубина точки вдоль взгляда без CullView — для разовых вызовов, когда позиция и направление камеры уже на руках (внутренние пути растеризатора используют именно её)

Пример

Renderer &r = begin3D(SCREEN_WIDTH, SCREEN_HEIGHT, 6, 5, 7, 8, -1, -1, 80000000);
const Culling::CullView &view = r.getCullView();

for (MeshInstance *inst : myInstances)
{
    const auto hit = view.testSphere(inst->center(), inst->radius());
    if (!hit.visible)
        continue;                                  // вне пирамиды - рисовать не надо

    // глубина центра и экранный размер - бесплатно из того же прохода
    const float px = view.screenRadius(inst->radius(), hit.eyeZ);
    if (px < 1.0f)
        continue;                                  // меньше пикселя - пропустить

    r.draw(inst);
}
r.flushQueue();

Треугольный слой

Последняя остановка треугольника перед растеризаторами: проверка near-плоскости, разрез пересекающих её треугольников, отсечение по полосе рендера и передача в заливку. Слой внутренний и не входит в публичный интерфейс библиотеки, поэтому игровой код не должен обращаться к namespace MeshRenderer напрямую.

Публичный вход — один

Метод Renderer Описание
void drawTriangle3D(const Vector3 &v0, const Vector3 &v1, const Vector3 &v2, uint16_t color) Нарисовать одиночный треугольник в мировых координатах: проекция, flat-освещение текущими источниками света, z-буфер, текущая полоса. Для отладочной графики, маркеров, лучей и неоновых «крыш» поверх мира
// вспышка выстрела: светящийся клин от дула
Vector3 muzzle = gun.worldMuzzle();
r.drawTriangle3D(muzzle, muzzle + right * 0.05f + up * 0.02f + fwd * 1.2f,
                 muzzle + right * -0.05f + up * 0.02f + fwd * 1.2f,
                 Color::rgb(255, 220, 120).rgb565);

Треугольник, пересекающий near-плоскость, разрезается на веер автоматически — «дырок у носа камеры» не появляется. Целиком за near-плоскостью — отбрасывается бесплатно (три сравнения глубин).

Внутренний API (для тех, кто дорабатывает движок)

Namespace pip3d::MeshRenderer. Все входы принимают предспроецированные вершины (ScreenProjector::project) + камерные глубины d = dot(v - camPos, camFwd); при разрезе уже готовые проекции переиспользуются — заново проецируются только вершины, созданные разрезом (экономия 1–3 проекций на разрезанный треугольник по сравнению с наивным «обрежь и спроецируй заново»).

Символ Назначение
drawFlat / drawSmooth / drawPhong Предспроецированный треугольник: flat-цвет / вершинные цвета / нормали на вершинах; при пересечении near-плоскости сами режут на веер
clipDrawNearFan<Vert> Общий разрез + рисовка веером для путей с произвольными атрибутами (текстура, лайтмапа): клиппер пишет в outSrc, кто из выходных вершин исходная, а вызывающий через лямбду получает готовые проекции
clipDrawNearSmooth / clipDrawNearPhong / clipDrawNearFlat Готовые клип-пути гуро/фонга/flat
ClipFlat, ClipVertLM, ClipVertSmooth, ClipVertPhong Вершины клиппера: каждая несёт только то, что нужно её растеризатору после разреза
clipTriNear Сазерленд–Ходжман по одной плоскости, ≤ 4 вершины. Ребро с разными знаками даёт t ∈ [0,1] по построению — клэмп и fabs не нужны
lerpVert Перегрузки под каждый тип вершины; форма a + t*(b-a) — один FMA на компоненту
bboxCull Вне полосы рендера или экрана по X
BandRect Снимок полосы (top/bottom/topF): читается из глобалов один раз на точку входа и дальше живёт в регистрах

Кэш отрисовки

Персональный кэш вершин каждого MeshInstance: экранные и/или мировые копии вершин меша, мировые нормали, посчитанные цвета освещения, сэмплы запечённых проб и вершины плоских теней. Всё это лежит в одном heap-блоке и переиспользуется, пока не устареет.

Игровому коду сюда звать нечего. Кэш работает сам: r.draw(inst) и r.flushQueue() внутри сами решают, что пересчитать, а что взять готовым. Раздел — для тех, кто дорабатывает конвейер (полоса рендера, тени, вода и эффекты берут вершины именно отсюда).

Что это даёт по скорости

Сравнение с «наивным» рендером (как у Jet-main: каждый кадр каждый объект заново трансформирует и проецирует все свои вершины, с полным делением 1/z на вершину):

Ситуация Наивный рендер С DrawCache
Сцена и камера стоят полная цена каждый кадр ноль работы по вершинам
Двинулся только один инстанс все объекты целиком только он, один раз за кадр
Двинулась камера все объекты: трансформ + проекция только проекция (мировые вершины уже готовы)
Полосный рендер (8 полос) пересчёт на каждую полосу расчёт в первой полосе, остальные 7 берут кэш

Плюс на вершину: вместо деления — recip0.s (аппаратная оценка обратного, ~4 такта вместо ~14 у 1/z), а квантованные координаты чанков разворачиваются в проекцию за счёт предумноженных констант (ChunkProjector::composeChunk), без промежуточного декода.

Как устроены версии

Каждая порция данных помечена номером версии, с которой она посчитана. Перед использованием вызывающий спрашивает «что нужно пересчитать?» и после пересчёта записывает версию обратно. Номер версии инстанса растёт при любом его движении (instance->version()), номер версии вида — при любом движении камеры/смена прое­кции (viewProjVersion_).

Метод Описание
ProjState beginProjection(viewVersion, instanceVersion) Что делать с экранными вершинами: Cached — всё готово; NeedsReproject — камера сдвинулась, спроецировать заново из мировых; NeedsTransformAndProject — инстанс сдвинулся, полный путь
void commitProjection(viewVersion, instanceVersion, worldVertsWritten = true) Записать, что экранные вершины пересчитаны; worldVertsWritten = false, если заполняли только экран
bool worldVertsValid(instanceVersion) / void commitWorldVerts(instanceVersion) Проверка/запись версии мировых вершин (тот же шаблон)
void invalidateVerts() Сбросить обе версии — заставить полный пересчёт (например, когда под кэшем изменились запечённые данные)

Ёмкость и указатели

Метод Описание
bool ensureCapacity(vertCount, normalCount = 0, withWorld = false) Гарантировать место под vertCount вершин; normalCount > 0 — ещё и мировые нормали; withWorld — ещё и мировую копию вершин. false — только при неудаче аллокации (кэш после этого пуст)
Vector3 *screenVerts() Экранные вершины (слот переиспользуется плоскими тенями)
Vector3 *worldVerts() Мировые вершины или nullptr, если мировая копия не запрошена
Vector3 *worldNormals() Мировые нормали или nullptr
void release() Освободить все блоки и сбросить версии. MeshInstance::setMesh зовёт сам — блок старого меша новому не нужен

Блок один и ровно нужного размера: [мировые вершины?][экранные вершины][нормали?], выравнивание 16. Перевыделений в кадре нет — рост только когда меш стал больше прежнего.

Цвета освещения (flat)

Метод Описание
bool ensureLitColors(faceCount, chunkCount) Завести RGB565-цвет на грань (не более kMaxLitCacheFaces = 1024) и флаг на чанк
void beginLitColors(key) Сменить ключ света/внешности: флаги чанков сбрасываются, каждый чанк пересчитается один раз
uint16_t *litColors() / uint8_t *litChunkFlags() Доступ к цветам и флагам (флаг: 0 — не построено, 1 — готово)

Свет вершин (гуро)

Тот же шаблон «ключ → флаги чанков → пересчёт один раз», но 3 float (RGB 0..1) на атрибут меша — готовые вершинные цвета для гуро-интерполяции. Ключ учитывает свет, версию инстанса, внешний вид и камеру — но камеру только когда свет от неё реально зависит (блик/кайма/туман): матовый материал не пересчитывается при движении камеры. Работает для мешей без подмешей и запечённых проб (у тех свой цвет на грань).

Метод Описание
bool ensureVertexLit(attrCount, chunkCount) Завести 3 float на атрибут (не более kMaxVertexLitAttrs = 1024) и флаг на чанк
bool beginVertexLit(key) Сменить ключ света/внешности/камеры; возвращает true, если кэш сброшен и чанки надо пересчитать
float *vertexLit() / uint8_t *vertexLitChunkFlags() Доступ к вершинным цветам и флагам чанков

Запечённые пробы и тени

Метод Описание
uint8_t *ensureProbePlanes(vertCount) 4 байта на вершину (масштаб/амбиент/люма/тинт) из пробной сетки; nullptr при неудаче аллокации (дальше несколько кадров не рвётся за ней — защита от шторма)
uint32_t probePlanesVersion() / void commitProbePlanes(version) Версия состояния проб, при которой сэмплы актуальны
Vector3 *getShadowVerts(generation, count, needsCompute) Слот для вершин плоской тени; needsCompute = false, если там уже лежит вершина этой генерации
void commitShadowVerts(generation) Записать генерацию тени; экранная проекция при этом помечается устаревшей — теневой проход портит слот экранных вершин

Пример (внутренний код движка)

DrawCache *cache = &instance->drawCache();
if (!cache->ensureCapacity(vertCount, 0, true))
    return;                                    // OOM - честно пропускаем инстанс

switch (cache->beginProjection(viewVersion, instanceVersion))
{
case DrawCache::ProjState::NeedsTransformAndProject:
    for (uint32_t i = 0; i < vertCount; ++i)
    {
        worldVerts[i] = worldTransform.transformPoint(meshVert(i));
        screenVerts[i] = projector.project(worldVerts[i]);
    }
    cache->commitProjection(viewVersion, instanceVersion);
    break;

case DrawCache::ProjState::NeedsReproject:
    for (uint32_t i = 0; i < vertCount; ++i)
        screenVerts[i] = projector.project(worldVerts[i]);   // мировые уже готовы
    cache->commitProjection(viewVersion, instanceVersion);
    break;

case DrawCache::ProjState::Cached:
    break;                                     // ноль работы
}