Растеризация, буферы и освещение

Растеризаторы, кадровый и Z-буферы, управление проходами и параметры затенения.

Сплошной растеризатор

Самый нижний уровень конвейера: заливка одноцветного треугольника с z-тестом в буфер полосы. Всё, что выше (освещение, текстуры, клиппинг, разбивка на полосы), живёт в соседних файлах Rasterizer/ и в конвейере — этот кусок делает ровно одну вещь: интерполирует рёбра по строкам, глубину по пикселям и пишет выигравшие пиксели.

В игре вы обычно не вызываете его сами — им пользуется конвейер через r.draw(...). Прямой вызов имеет смысл для собственных путёв отрисовки: оверлеи, кастомные пассы, экспериментальные шейдеры без интерполяции атрибутов.

Функция

Функция Описание
bool fillTriangle(x0, y0, z0, x1, y1, z1, x2, y2, z2, color, frameBuffer, zBuffer, config) Залить сплошной треугольник с z-тестом. Возвращает true, если треугольник обработан, false — если вырожден (нулевая высота/площадь) или буферы не заданы

Контракт

  • Координаты — экранные, в системе полосы: x/y минус верх полосы (в конвейере это y - bandTop), z — как из project() камеры: больше = ближе.
  • Z-буфер «обратный»: чистый буфер — нули, пиксель пишется, когда новая глубина больше хранимой. Не «больше = дальше», как в учебнике, а наоборот — так чистый буфер означает «самое дальнее», и ближайшая поверхность всегда побеждает простым сравнением.
  • Внутриполосные клипы делаются сами: строки и пиксели за пределами config.width/height отсекаются, за границы буфера запись не выходит.
  • Цвет — готовый RGB565 (например, из Color::rgb(...).rgb565). Освещение растеризатор не делает — цвет приходит уже посчитанным.
  • Возврат: true = треугольник растеризован (хотя бы обработан), false = вырожденный — конвейер ведёт по этому флагу статистику «нарисовано/отклонено».

Пример (кастомный пасс поверх конвейера)

Renderer &r = begin3D(
    SCREEN_WIDTH, SCREEN_HEIGHT,
    /*mosi=*/6, /*sclk=*/5, /*cs=*/7,
    /*dc=*/8, /*rst=*/-1);

// внутри своего цикла по полосам, после обычной сцены:
for (int band = 0; band < SCREEN_BAND_COUNT; ++band)
{
    r.beginFrameBand(band);
    // ... r.draw(...) обычной сцены ...

    // своя заливка: красный треугольник поверх сцены, координаты -
    // в системе текущей полосы (y от её верха)
    Rasterizer::fillTriangle(
        40.0f, 10.0f, 0.5f,
        10.0f, 40.0f, 0.5f,
        70.0f, 40.0f, 0.5f,
        Color::rgb(255, 60, 60).rgb565,
        r.getFrameBuffer(),                       // буфер текущей полосы
        &r.getZBuffer(),                          // z-буфер полосы
        DisplayConfig(SCREEN_WIDTH, SCREEN_BAND_HEIGHT));

    r.endFrameBand(band);
}

Растеризатор билбордов

Нижний уровень всего billboard-рендера: заливка текстурированного треугольника с z-тестом и одним из четырёх режимов смешивания. Именно сюда попадает каждый текстурированный квад частиц и любой билборд, который вы рисуете поверх сцены. Соседний уровень конвейера строит квад из позиции и размера (buildBillboardQuadGeometry), отбрасывает по фрустуму и сам разбивает его на два вызова этой функции.

Прямой вызов нужен для собственных пассов: спрайты в мире, HUD-элементы в 3D, кастомные системы квадов. Один вызов = один треугольник; квад = два вызова с общими w (глубиной вдоль взгляда).

Режимы смешивания (BillboardBlend)

Режим Поведение
BB_BLEND_OPAQUE Прямая запись текселя — самый быстрый путь
BB_BLEND_CUTOUT Прямая запись; тексели, равные chromaKey, пропускаются (дырки в спрайте)
BB_BLEND_ALPHA Тексел полупрозрачно ложится поверх фона; сила — alphaByte
BB_BLEND_ADDITIVE Яркость текселя прибавляется к фону (свечения, гало); чёрные тексели пропускаются

Функция

Функция Описание
bool fillTriangleBillboard(x0, y0, z0, x1, y1, z1, x2, y2, z2, u0, v0, ..., u2, v2, w0, w1, w2, lr0, lg0, lb0, ..., lb2, tex, blendMode, frameBuffer, zBuffer, config, chromaKey = 0, alphaByte = 255, writeZ = false) Залить текстурированный треугольник в буфер полосы. Возвращает false только для вырожденного треугольника (нулевая площадь)

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

Контракт

  • Координаты — экранные, в системе полосы (в конвейере это y - bandTop); z — как из project() камеры: больше = ближе. UV — 0..1 на всю текстуру, заворачивание по маскам степени двойки.
  • w0..w2 — глубина каждой вершины вдоль взгляда (из camW конвейера). По ним делается перспективно-корректная выборка текстуры: у ближнего края квад текстура «раскрывается», у дальнего — сжимается, как у настоящей 3D-поверхности. Если глубина у всех вершин одна (экранно-параллельный квад — обычный случай билбордов и частиц), функция сама это замечает и рисует без деления на каждый пиксель, оставаясь точной до текселя.
  • Свет lr/lg/lb — множители 0..1 на канал, везде одинаковая шкала. Если все три вершины освещены одинаково (обычный случай — конвейер всегда так делает), включается быстрый плоский путь; полный свет (1, 1, 1) передаёт тексель на экран без изменений; градиент освещения между вершинами тоже работает, включая вертикальный.
  • alphaByte — сила смешивания 0..255 для ALPHA/ADDITIVE. Полностью прозрачный вызов (0) ничего не рисует и сразу возвращает true; ALPHA c 255 рисуется по самому быстрому opaque-пути. OPAQUE/CUTOUT этот параметр игнорируют.
  • writeZ — писать ли глубину выигранных пикселей. Конвейер включает его только для первого треугольника ALPHA-квада: частицы не должны перегораживать друг друга глубиной, но должны отмечать глубину для следующей геометрии. Полупрозрачные режимы честно проходят z-тест, но сами z не трогают (ADDITIVE — никогда).
  • Внутриполосные клипы делаются сами: строки и пиксели за пределами config.width/height отсекаются.
  • Туман применяется ко всем режимам одинаково (глубинное затухание к цвету тумана), если включён Fog.

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

// квад 32x32 пикселя в точке спроецированной позиции, поверх обычной сцены,
// внутри цикла по полосам (координаты — в системе текущей полосы)
const float z = screen.z; // из project()
Rasterizer::fillTriangleBillboard(
    screen.x - 16, screen.y - 16, z,  screen.x + 16, screen.y - 16, z,
    screen.x + 16, screen.y + 16, z,
    0, 1,  1, 1,  1, 0,                     // UV первого треугольника
    camW, camW, camW,                       // глубина вдоль взгляда
    1, 1, 1,  1, 1, 1,  1, 1, 1,            // без затенения
    spriteTex, BB_BLEND_CUTOUT,
    r.getFrameBuffer(), &r.getZBuffer(),
    DisplayConfig(SCREEN_WIDTH, SCREEN_BAND_HEIGHT),
    0x0000);                                // chromaKey; alpha/writeZ - по умолчанию

Живой пример со всеми четырьмя режимами и перспективным квадом на полу — Examples/BillboardDemo.cpp (сборка: idf.py -B build-bbdemo -D PIP3D_DEMO=BillboardDemo build flash).


Кадровый буфер

Экран рисуется по горизонтали полосами (band) высотой SCREEN_BAND_HEIGHT: пока DMA уносит предыдущую полосу на панель, движок уже заполняет следующую. Внутри FrameBuffer — двойной буфер полосы (своп за swapStagingSlot()), фон полосы, небо и облака. Классом напрямую играет движок; игре достаётся управление через рендерер и прямой доступ к пикселям полосы.

Что настраивает игра (через Renderer)

Вызов Описание
r.setClearColor(color) Цвет сплошного фона — когда небо выключено (r.setSkyboxEnabled(false))
r.setSkyboxEnabled(bool) / r.setSkyboxType(DAY) Небо-градиент вкл/выкл и смена пресета (DAY, SUNSET, NIGHT, …). setSkybox() делает то же + подтягивает температуру света
r.getSkybox() Тонкая ручная настройка неба (свои цвета стопов); после правок — r.invalidateSkyboxCache()
r.setCloudsEnabled(bool) Облака поверх неба
r.setCloudColor/Alpha/Height/Scale/DriftAngle(...) Цвет, прозрачность, высота, масштаб, ветер облаков
r.updateClouds(dt) / r.resetClouds() Продвинуть ветер на dt / вернуть облака к дефолту
r.getFrameBuffer() Сырой указатель на буфер текущей полосы — для своих HUD-пассов и эффектов

Прямой доступ к полосе

Буфер полосы — обычная RGB565-канва SCREEN_WIDTH × SCREEN_BAND_HEIGHT, координаты — от верха полосы. Хочется свой HUD/эффект поверх кадра — рисуйте прямо в него (так работают HudRenderer::drawText, гизмо и линза-флэр движка).

uint16_t *fb = r.getFrameBuffer();
fb[10 * SCREEN_WIDTH + 10] = Color::YELLOW.rgb565;   // точка (10,10) полосы

Как рисуется фон

fillBackground() — по одному вызову на полосу, выбирает путь сам:

  • Сплошной (небо выключено): вся полоса заполняется одним цветом. Вся работа — 128-битные SIMD-сторы в zero-overhead цикле: ~1415 Mpix/s на ESP32-S3 (480×320, 240 МГц) — на уровне самого быстрого способа записать SRAM.
  • Небо (включено): вертикальный градиент 888-точности с упорядоченным дизерингом 8×8 и сдвигом строк по тангажу камеры (посмотри вверх — небо едет вниз). Пиксели строки дизеруются без ветвлений и заполняются теми же 128-битными сторами: ~508 Mpix/s — с дизерингом и сдвигом, которых у сплошной заливки нет. Результат побитово совпадает с попиксельным дизерингом (контрольная сумма полосы в Tests/FBBench.cpp).

Скорость фона на весь кадр: сплошной ~0.11 мс, небо ~0.3 мс из ~33 мс кадра — фон не является узким местом сцены (узкое место — шина дисплея, ~32.8 мс на полный экран, и она скрывается асинхронным DMA-флашем).

Пример (свой HUD-эффект поверх сцены)

// вспышка выстрела: полупрозрачная пелена на верхнюю четверть экрана,
// внутри цикла по полосам
ColorBlender flash(Color::rgb(255, 220, 150), 90);
for (int band = 0; band < SCREEN_BAND_COUNT; ++band)
{
    r.beginFrameBand(band);
    // ... сцена ...
    if (band == 0)                                   // верхняя полоса
        flash.blendSpan(r.getFrameBuffer(),
                        SCREEN_WIDTH * SCREEN_BAND_HEIGHT);
    r.endFrameBand(band);
}

Z-буфер

Буфер глубины одной полосы кадра: движок владеет им, чистит раз за полосу и гоняет по нему z-тест каждого треугольника. Игре он нужен в двух случаях — спросить «не закрыт ли пиксель сценой» (прицел, маркеры, линза, частицы за дымом) и получить сырой массив для собственного прохода поверх сцены (раздел Сплошной растеризатор).

Формат ячейки

Одна ячейка — uint16_t на пиксель полосы:

Биты Смысл
0..14 Глубина: 0 = чистый буфер («самое дальнее»), больше = ближе. Максимум — kZDepthMax
15 Служебный флаг тени (kZShadowFlag): движок помечает им воду и уже затенённые пиксели. На глубину не влияет

«Обратный» z (больше = ближе, чистый буфер = нули) — тоже самое, что и в растеризаторах: ближайшая поверхность побеждает простым сравнением. Перевод мирового z в хранимую глубину: depth = int32(z * kZDepthScale) >> kZDepthShift.

Методы ZBuffer

Метод Описание
void clear() Очистить полосу: все ячейки = «самое дальнее». Движок вызывает сам 8 раз за кадр
uint16_t depthAt(x, y) Готовая глубина пикселя полосы без флагового бита, координаты — в системе полосы. 0 = фон
uint16_t *data() / const uint16_t *data() Сырые ячейки, построчно (строка = kStride значений) — для своих проходов

Размеры: kPixelCount (ячеек в полосе), kByteSize (байт), kStride (= SCREEN_WIDTH). Всё статическое — класс ничего не выделяет.

Константы формата

Имя Значение Когда нужно
kZDepthMask 0x7FFF Отчистить флаговый бит у сырой ячейки: cell & kZDepthMask
kZShadowFlag 0x8000 Поставить/проверить флаг тени у сырой ячейки
kZDepthMax 0x7FFE «Бесконечно близко» — для зажима своих глубин
kZClearDepth 0x0000 Значение чистого буфера — «пиксель пустой»
kZDepthShift / kZDepthScale 14 / 16384 Квантование z в хранимую глубину для своих растеризаторов

Пример

Renderer &r = begin3D(SCREEN_WIDTH, SCREEN_HEIGHT, 6, 5, 7, 8, -1);

// внутри цикла по полосам: маркер прицела рисуем, только если сцена
// дальше точки прицела (пиксель не занят более близкой геометрией)
ZBuffer &z = r.getZBuffer();
const uint16_t aimDepth = static_cast<uint16_t>(screen.z); // из project()

if (z.depthAt(screen.x, screen.y) < aimDepth)   // сцена дальше прицела
    r.drawText(screen.x - 4, screen.y - 6, "+", Color::WHITE);

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

  • clear() — SIMD-заливка из IRAM: 38.4 КБ полосы за ~11 мкс (~3.5 ГБ/с, ядро Simd::fillSpan16 — один 128-битный стор на итерацию при zero-overhead цикле). Это ~90% потолка записи SRAM ESP32-S3 и в ~4.8 раза быстрее memset (было 53 мкс). Инлайнится прямо в IRAM-вызов движка.
  • depthAt/data — force-inline, разворачиваются в одно чтение и маску: можно звать на каждый пиксель эффекта.
  • RAM: ровно один экземпляр на рендерер, 38.4 КБ внутренней SRAM, куча не используется.
  • On-device бенч этого куска — Examples/ZBufferBench.cpp (idf.py -B build-zbench -D PIP3D_DEMO=ZBufferBench build flash -p COM4): фазы CLEAR/ZCLEAN/ZBUSY/ZFRONT/FLAG + видимая 3D-сцена, печатает [zbench] RESULT-строки и рисует цифры на экране.

Освещение и материал

Как свет превращается в цвет поверхности. Игре обычно нужны только две вещи: режим шейдинга (r.setShadingMode(...)) и материал — набор из пяти готовых пресетов или свои числа. Всё остальное (пересчёт по вершинам/граням/пикселям, тонмаппинг, туман) движок делает сам в конвейере; прямые вызовы расчётов нужны для своих пассов — билборды, спрайты, HUD-объекты в мире.

Режимы (ShadingMode)

Режим Поведение
SHADING_FLAT Один цвет на грань (по нормали грани) — самый быстрый
SHADING_GOURAUD Свет считается по вершинам, между ними плавно интерполируется
SHADING_PHONG Честный пересчёт света на каждый пиксель — самые живые блики, самый дорогой

Материал — ShadingParams

Десять чисел, которые описывают «ощущение» поверхности. Меняются одним вызовом; начинают с пресета и при желании докручивают поля:

Поле Смысл
diffuseStrength Сила рассеянного света (0 — поверхность не реагирует на свет)
diffuseWrap 0..1: насколько свет «заворачивается» на тёмную сторону (мягкие тени)
specularStrength Сила блика (0 — матовая поверхность)
specularExponent Размер блика: больше = пятно уже и резче
rimStrength Подсветка краёв, контурный свет (0 — выключена)
hemiScale Насколько небо/земля красят верх/низ объекта
ambientBase Базовая подсветка даже в полной тени
exposureCoeff Яркость картинки до тонмаппинга
toneKnee Мягкость пересветов в тонмаппинге
saturation Насыщенность цвета после света (1 — без изменений)

Пресеты

Пресет Характер
ShadingParams::balanced() Дежурный вид движка — стоит по умолчанию
ShadingParams::matte() Без бликов вообще: керамика, гипс, ткани. Самый дешёвый свет
ShadingParams::soft() Лёгкий мягкий блик — пластик, кожа
ShadingParams::glossy() Заметный блик и контур — металл, лак, леденцы
ShadingParams::punchy() Контрастный «игровой» вид: жёсткий свет, узкие блики

Настройка — Shading

Метод Описание
void setParams(const ShadingParams &p) Сменить материал. Движок сам сбрасывает свои кэши света (счётчик paramsVersion() растёт)
const ShadingParams &getParams() Прочитать текущий материал
uint32_t paramsVersion() Версия материала — растёт при каждом setParams
void ensureReady() Прогрев внутренних таблиц. Движок зовёт сам раз в кадр; вручную — только при прямых вызовах расчётов до первого кадра

Прямые расчёты (для своих пассов)

Четыре готовых расчёта — от полного к самому дешёвому. Все принимают базовый цвет в float 0..1 (раскладка Color::rgb565 → toFloat) и отдают линейный RGB 0..1 после тонмаппинга — готовый для Color::rgbF(...):

Метод Считает Цена
calculateVertexLightingGouraud(pos, normal, camPos, lights, n, base, out, dirScale = 1) Полный свет в точке + туман по расстоянию до камеры полный
calculateFaceLighting(v0, v1, v2, camPos, lights, n, base, out) Нормаль грани из геометрии + полный свет + туман полный + кросс-произведение
calculateLighting(pos, normal, viewDir, lights, n, base, out, dirScale = 1) Полный свет, взгляд передаёте сами; туман не применяется полный
calculateLambert(normal, lights, n, base, out) Только окружение + рассеянный: без бликов, каймы, тумана самый дешёвый

Список источников — обычный массив Light; у конвейера его даёт r.setMainDirectionalLight(...) и точечные Light. dirScale — множитель направленного света (движок использует его для запечённых теней; играм можно оставить по умолчанию).

Подмешать туман к готовому цвету можно отдельно: Shading::applyFog(dist, r, g, b, outR, outG, outB) — out-ссылки могут совпадать с входными.

Пример

// один раз при старте: игровой вид
Shading::setParams(ShadingParams::punchy());

// закат: теплее, мягче, темнее — и снова одним вызовом
ShadingParams dusk = ShadingParams::soft();
dusk.exposureCoeff = 0.9f;
dusk.saturation = 1.05f;
Shading::setParams(dusk);

// свой пасс: осветить билборд/спрайт по нормали, как это делает движок
float lr, lg, lb;
Shading::calculateLambert(faceNormal, lights, lightCount,
                          baseR, baseG, baseB, lr, lg, lb);
Color lit = Color::rgbF(lr, lg, lb);
Куда смотреть дальше

Режим и материал задаются на весь кадр; для отдельного объекта режим устанавливается через inst->setShadingOverride(...) (см. Инстансы), а свет в сцене — через рендерер (r.setMainDirectionalLight(...), точечные Light, r.setAmbientScale(...)).