Цвет и математика

Цветовые операции, векторы и матрицы, геометрические проверки и компактное хранение нормалей.

Цвет

Цвет движка — одно 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)) или для собственных расчётов.

Почему не полная 4×4

Движок аффинный — перспектива в матрице объекта ничего бы не сделала, кроме поломки проекции. Компактный формат бесплатен скоростью (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);  // развернуться по нормали