Цвет
Цвет движка — одно 16-битное значение RGB565: ровно то, что лежит в кадре. Color хранит только его, ничего не выделяет и не пересчитывает; все операции встроены в места вызова. Создавайте, смешивайте, осветляйте — и передавайте куда угодно, где ждут цвет (r.draw(...), setColor(...), drawText(...)).
Создание
| Способ | Что делает |
|---|---|
Color(255, 128, 0) или Color::rgb(r, g, b) |
Из компонент 0..255 (лишние биты игнорируются) |
Color::rgbF(r, g, b) |
Из float 0..1 — путь шейдинга: результат освещения → цвет пикселя. Выходы за 0..1 зажимаются |
Color::hsv(h, s, v) |
Из HSV: оттенок h 0..1 (закольцован), насыщенность и яркость 0..1 — радуги, плавные переливы |
Color::kelvin(k) |
Цвет нагретого тела при температуре k кельвинов: свеча ≈ 1900, лампа ≈ 2700, дневной свет ≈ 6500. Для огней и солнца |
Color(0xF800) |
Из сырого RGB565 — из палитры текстур, файла, своей таблицы |
Color неявно превращается в uint16_t, поэтому один и тот же цвет можно передать и как Color, и как число.
Каналы и яркость
| Метод | Описание |
|---|---|
uint16_t r5() / g6() / b5() |
Каналы в родной квантизации RGB565: красный и синий 0..31, зелёный 0..63 |
uint8_t brightness() |
Яркость 0..255, взвешенная по восприятию (зелёный весомее) |
void toFloat(r, g, b) |
Разложить в float 0..1 (обратно к rgbF) |
r5/g6/b5 — не «цвет в байтах», а реальная точность канала в RGB565: играя с ними напрямую (например, (tc.g6() * light) >> 6), вы работаете ровно в тех величинах, из которых состоит пиксель кадра.
Смешение и тон
| Метод | Описание |
|---|---|
Color blend(const Color &c, uint8_t alpha) |
Смешать с c: alpha 0..255 (0 — этот цвет, 255 — целиком c) |
static Color lerp(const Color &a, const Color &b, float t) |
То же, но t — float 0..1: удобно для градиентов и частиц |
Color darken(uint8_t amt) / Color lighten(uint8_t amt) |
Затемнить / осветлить на 0..255 (255 = чёрный / белый) |
blend внутри считается в точной арифметике замаскированных полей RGB565 (4 умножения на цвет) — без распаковки каналов по одному.
Градиенты — Palette::sample
static const Color fire[] = {
Color::BLACK,
Color::rgb(170, 10, 0),
Color::rgb(255, 195, 30),
Color::WHITE,
};
Color c = Palette::sample(fire, 4, heat); // heat 0..1 — цвет пламени в точке
Palette ничего не хранит: работает с вашим массивом, отдаёт цвет в точке t градиента (между соседними цветами — линейное смешение).
Много пикселей одним цветом — ColorBlender
Когда один цвет с одной прозрачностью кладётся на много пикселей (туман, водяная пелена, вспышка, затемнение экрана), подготовьте блендер один раз и применяйте к пикселям дёшево:
ColorBlender fog(Color::rgb(140, 160, 175), 96); // цвет и прозрачность — один раз
Color c = fog.blend(pixel); // один пиксель
fog.blendSpan(fbRow + x, width); // строка пикселей прямо в буфере
// (на ESP32-S3 — векторный проход по 8 пикселей)
Вся дорогая работа (раскладка цвета и прозрачности) делается в конструкторе; blend на пиксель — минимум операций, а blendSpan на выровненной середине ряда уходит в векторное ядро Simd::blendSpanWeights: 8 пикселей за итерацию через MAC-блок ESP32-S3, результат побитово совпадает с попиксельным blend. Замер на устройстве: ~203 Mpix/s против ~89 Mpix/s у попиксельного смешения — быстрее в ~2.3 раза.
Создавайте ColorBlender на стеке или в куче (локальная переменная — как в примере): SIMD-проход читает его поля векторной загрузкой из ОЗУ.
Готовые цвета
Color::WHITE, BLACK, RED, GREEN, BLUE, CYAN, MAGENTA, YELLOW, GRAY, DARK_GRAY, LIGHT_GRAY, ORANGE, PINK, PURPLE, BROWN — живут в rodata, RAM не тратят.
Пример
// Солнце садится: небо и туман теплеют, свет желтеет
const Color dusk = Color::lerp(Color::rgb(60, 140, 255), Color::rgb(250, 130, 90), t);
const Color sunCol = Color::kelvin(2400.0f);
const Color halo = sunCol.blend(Color::WHITE, 120); // светлое ядро вокруг солнца
r.setFogColor(dusk.darken(60));
r.setMainDirectionalLight(Vector3(0.4f, -1.0f, -0.2f), sunCol, 1.0f);
Математика
Быстрая float-математика движка: векторы, матрицы, кватернионы, тригонометрия и сплайны. Всё — короткие встраиваемые функции: на ESP32-S3 они сворачиваются в несколько инструкций FPU в месте вызова, на PC работают те же формулы. Ничего не выделяет и не хранит состояния — пользуйтесь свободно, хоть каждый кадр.
Константы углов
kPi, kTwoPi, kHalfPi, kDegToRad, kRadToDeg — живут прямо в namespace движка, пишутся без префиксов: angle * kDegToRad.
Зажим значения — clamp
float pitch = clamp(rawPitch, -85.0f, 85.0f); // не ниже -85, не выше 85
Доступен без дополнительных подключений — это движковый аналог ардуиновского constrain. Типы аргументов могут отличаться (clamp(x, 0, 1) тоже работает): результат приводится к типу первого аргумента.
Быстрая тригонометрия — FastMath
| Метод | Описание |
|---|---|
float fastSin(rad) / float fastCos(rad) |
Синус/косинус рад. Точность ~0.0001, в разы быстрее sinf/cosf |
void fastSinCos(rad, &sin, &cos) |
Оба сразу — почти по цене одного |
uint16_t radToBin(rad) |
Угол → бинангл: 65536 = полный оборот |
void fastSinCosBin(bin, &sin, &cos) |
То же, но от бинангла: без конвертации, если углы уже в ней |
float fastReciprocal(x) |
1/x — ~4 такта FPU |
float fastInvSqrt(x) |
1/√x — ~6 тактов FPU. Основа всех нормализаций движка |
float fastFmod(x, y) |
x mod y, в ~10 раз дешевле fmodf |
float fastWrapCentered(x, range) |
Завернуть в [-range/2, +range/2) — разница курсов/углов без сюрпризов на границе ±180° |
Бинангл (radToBin) — родной угол этой таблицы: компактный uint16_t, сам закольцован при переполнении. Удобен как ключ поворота (курсор компаса, анимация колеса), когда радианы не нужны.
Синус считается по таблице 256 значений с линейной интерполяцией (таблица лежит в DRAM — чтение без промахов кэша). Этого хватает для графики и звука; для физики снарядов на километры используйте точную математику PC-сборки.
Вектор — Vector3
12 байт, передаётся по значению. Операторы: -v, a + b, a - b, v * s, s * v, v / s, и такие же +=, -=, *=, /=.
| Метод | Описание |
|---|---|
float dot(v) / Vector3 cross(v) |
Скалярное и векторное произведение |
float length() / float lengthSquared() |
Длина и квадрат длины. Для сравнений расстояний всегда берите lengthSquared — без корня |
void normalize() |
Нормализовать на месте; нулевой вектор останется нулевым (без NaN) |
float distance(v) / float distanceSquared(v) |
Расстояние до v; distanceSquared — дешёвый вариант для проверок «достаточно ли близко» |
static Vector3 lerp(a, b, t) |
Линейное смешение a→b: t 0..1 (вне диапазона — экстраполяция). Движение к цели, плавные переходы позиций |
Сплайн — catmullRom и CatmullRomPath
Плавная кривая через контрольные точки (в отличие от резких полилиний): полёт коллекционного предмета к игроку, траектория снаряда, патрульный маршрут.
// участок пути между p1 и p2, t = 0..1; p0 и p3 задают касательные
Vector3 pos = catmullRom(p0, p1, p2, p3, t);
Vector3 dir = catmullRomTangent(p0, p1, p2, p3, t); // направление вдоль кривой
Четыре точки руками передавать редко — проще описать весь путь массивом и ехать по нему параметром t:
static const Vector3 patrol[] = {
{0, 1, 0}, {6, 1, -4}, {10, 1, 0}, {6, 1, 6}}; // ваши опорные точки
CatmullRomPath path;
path.setPoints(patrol, 4, /*looped=*/true); // массив должен жить, пока играет путь
// в цикле: drone летит по маршруту, носом вперёд
drone->setPosition(path.pos(t));
Vector3 d = path.dir(t);
d.normalize();
drone->setEuler(0, atan2f(d.x, d.z) * kRadToDeg, 0);
CatmullRomPath ничего не хранит и не выделяет — только указатель на ваш массив (как ключи CameraTimeline). pos(t)/dir(t) — t 0..1 по всему пути, при looped=true замыкается в кольцо. Для перемещения камеры по ключам есть готовый CameraTimeline (ролики).
Матрица — AffineMatrix
Аффинный трансформ: базис (поворот/масштаб) + перенос. 48 байт — вдвое меньше обычной матрицы 4×4, потому что хвост, который всегда равен 0,0,0,1, вообще не хранится и не считается. Column-major: m[col * 3 + row], перенос — отдельным полем pos. Вид и проекция живут в Camera, который считает их быстрее любой матрицы.
| Метод | Описание |
|---|---|
AffineMatrix() / void reset() |
Единичный трансформ / вернуть к нему |
AffineMatrix operator*(b) |
Композиция: a * b применит b, затем a. Аппаратно честная — весь тип аффинный, умножать мёртвый хвост нечем и незачем (36 умножений против 64 у полной 4×4) |
Vector3 transformPoint(v) |
Применить трансформ к точке (базис + перенос) |
Vector3 transformDir(v) |
Применить только базис — направления и скорости (перенос не влияет) |
Готовая матрица трансформа никуда не передаётся вручную: MeshInstance сам собирает её из позиции/поворота/масштаба, а Node (сцен-граф) — из иерархии. AffineMatrix нужен, когда трансформ приходит извне (instance.setWorldMatrix(m)) или для собственных расчётов.
Движок аффинный — перспектива в матрице объекта ничего бы не сделала, кроме поломки проекции. Компактный формат бесплатен скоростью (transformPoint и так читает только 12 рабочих чисел), экономит 16 байт на каждый объект и не даёт случайно положить в трансформ проективную матрицу.
Нормали — NormalMatrix
NormalMatrix(world) строится из матрицы трансформа и переносит нормали так, чтобы они оставались единичными даже при неединообразном масштабе (растяжение по одной оси). Используется движком в гуро/фонг-шейдинге автоматически.
Поворот — Quaternion
16 байт против 64 у матрицы, без gimbal lock. Quaternion() — тождественный поворот.
| Метод | Описание |
|---|---|
static Quaternion fromAxisAngle(axis, angleRad) |
Поворот вокруг оси на угол |
static Quaternion fromEuler(pitch, yaw, roll) |
Из эйлеровых углов (рад.) |
Quaternion conjugate() |
Обратный поворот |
void normalize() |
Нормализовать на месте (после долгих умножений дрейфует — раз в кадр достаточно) |
Vector3 rotate(v) |
Повернуть вектор |
Quaternion operator*(rhs) |
Композиция поворотов: q1 * q2 применит q2, затем q1 |
Пример
// пропеллер крутится, item летит к игроку по дуге, камера чуть трясётся
const float spin = FastMath::fastWrapCentered(spin + 720.0f * dt, 360.0f);
propeller->setEuler(0.0f, spin * kDegToRad, 0.0f);
const float t = fminf(t + dt * 0.5f, 1.0f);
item->setPosition(catmullRom(shelf, shelfTop, midAir, playerPos, t));
if (itemNearPlayer && item->pos().distanceSquared(playerPos) < 1.0f)
pickUp();
Коллизии
Математика попаданий для игры: боксы, сферы, лучи. Всё — короткие встроенные структуры без выделений и состояний. Физика движка построена поверх этих же примитивов, так что вы пользуетесь теми же проверками, что и сам движок.
AABB — бокс по осям
Хранилище min / max + готовые проверки:
| Метод | Описание |
|---|---|
static AABB fromCenterSize(center, size) |
Бокс из центра и габарита — как у инстансов движка |
bool contains(p) |
Точка внутри бокса (зоны, триггеры, вода) |
bool intersects(o) |
Два бокса перекрываются — дешёвое отсечение пар в физике |
CollisionSphere — сфера
center + radius. Сфера-сфера — самая частая проверка в играх (пули, игроки, подборы):
| Метод | Описание |
|---|---|
bool intersects(o) |
Сферы касаются или пересекаются: одно сравнение расстояния без корня |
Ray — луч
Луч хранит origin, direction и предвычисленный invDirection — проверки попаданий идут без делений. Создавайте луч один раз и гоняйте по многим целям.
| Метод | Описание |
|---|---|
Vector3 at(t) |
Точка на луче: origin + direction * t |
bool intersects(box, &tMin, &tMax) |
Пересечение с боксом (slab-тест): tMin — вход, tMax — выход. Ложь = промах или бокс позади луча |
bool intersects(sphere, &t) |
Расстояние вдоль луча до сферы; ловит и случай «origin внутри сферы». Хит позади луча — ложь |
bool intersects(box) / bool intersects(sphere) |
Те же проверки без выносных параметров — «попал/не попал» |
Пример
Ray ray(cam.position, aimDir); // прицел игрока
CollisionSphere ball(enemyPos, 0.8f);
if (ray.intersects(ball)) // попал по врагу
enemy->damage(25);
if (fireZone.intersects(playerPos)) // зона огня: игрок внутри
player->burn();
if (playerSphere.intersects(pickupSphere)) // классический подбор предмета
player->pickUp(item);
Для лучей по мешам и телам есть готовый PhysicsWorld::raycast — он возвращает точку, нормаль и тело; примитивы отсюда служат его основой.
Упаковка нормалей
Нормаль вершины движка — это 2 байта вместо 12 у обычного Vector3: направление сжимается октаздрической упаковкой (подобие развёртки октаэдра на квадрат). Именно поэтому вершина движка занимает 16 байт, а не 28, и мешы так компактны во flash. На PC такой трюк делают ради памяти, здесь он ещё и скорость: меньше данных читает конвейер вершин каждый кадр.
Игре это обычно не нужно видеть: движок сам пакует нормали у всех примитивов (Sphere, Box, Torus, …) и сам распаковывает при освещении. Свои руки сюда суёте в двух случаях — свой генератор геометрии или «прилипание» предметов к поверхности по нормали меша.
packNormal(x, y, z) → uint16_t
Сжать направление в 2 байта. Функция constexpr — годится для константных таблиц вершин. Нулевое направление даёт 0. Нормализовать вход не обязательно — функция сделает сама.
PackedNormal — нормаль в 2 байтах
Хранилище одного упакованного значения (поле data — сырой код). Вершинный формат движка держит именно его.
| Метод | Описание |
|---|---|
PackedNormal(x, y, z) / PackedNormal(v) |
Упаковать направление из чисел или Vector3 |
void set(x, y, z) / void set(v) |
Перепаковать на месте |
Vector3 unpack() |
Разжать обратно в единичный вектор |
Vector3 unpack(const NormalMatrix &world) |
Разжать и повернуть в мировые координаты за один проход |
Биты на диске пишет конвертер моделей — они должны совпадать бит в бит. Меняете формат — переконвертируйте все меши.
Пример
// Генерация вершины своего меша: позиция + нормаль + UV
Vertex v(0, 128, 0, packNormal(0.0f, 1.0f, 0.0f), 0.5f, 0.5f);
// Прилепить стикер к стене: нормаль поверхности из данных меша
Vector3 n = wallNormalPacked.unpack(); // единичная нормаль стены
sticker->setPosition(hitPoint + n * 0.05f); // чуть отодвинуть от стены
sticker->setEuler(0.0f,
atan2f(n.x, n.z) * kRadToDeg,
-asinf(n.y) * kRadToDeg); // развернуться по нормали