Сплошной растеризатор
Самый нижний уровень конвейера: заливка одноцветного треугольника с 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;ALPHAc255рисуется по самому быстрому 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(...)).