Отсечение
Ответ на вопрос «виден ли этот объект вообще» до любой работы с треугольниками. Внутри — 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; // ноль работы
}